Erweiterungen
This commit is contained in:
@@ -10,8 +10,8 @@ import java.net.URI;
|
|||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for LLM integration via LM Studio.
|
* Configuration for LLM integration via LM Studio. LM Studio provides an
|
||||||
* LM Studio provides an OpenAI-compatible API.
|
* OpenAI-compatible API.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -37,13 +37,16 @@ public class LlmConfig {
|
|||||||
// Test 1: Basic connectivity
|
// Test 1: Basic connectivity
|
||||||
testEndpoint(baseUrl + "/v1/models", "GET", null);
|
testEndpoint(baseUrl + "/v1/models", "GET", null);
|
||||||
|
|
||||||
// Test 2: Chat completions endpoint WITHOUT streaming (POST with minimal payload)
|
// Test 2: Chat completions endpoint WITHOUT streaming (POST with minimal
|
||||||
String testPayload = "{\"model\":\"" + model + "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}";
|
// payload)
|
||||||
|
String testPayload = "{\"model\":\"" + model
|
||||||
|
+ "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}";
|
||||||
log.info("Test payload (stream=false): {}", testPayload);
|
log.info("Test payload (stream=false): {}", testPayload);
|
||||||
testEndpoint(baseUrl + "/v1/chat/completions", "POST", testPayload);
|
testEndpoint(baseUrl + "/v1/chat/completions", "POST", testPayload);
|
||||||
|
|
||||||
// Test 3: Chat completions WITH streaming to compare behavior
|
// Test 3: Chat completions WITH streaming to compare behavior
|
||||||
String streamPayload = "{\"model\":\"" + model + "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":true}";
|
String streamPayload = "{\"model\":\"" + model
|
||||||
|
+ "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":true}";
|
||||||
log.info("Test payload (stream=true): {}", streamPayload);
|
log.info("Test payload (stream=true): {}", streamPayload);
|
||||||
testEndpoint(baseUrl + "/v1/chat/completions", "POST", streamPayload);
|
testEndpoint(baseUrl + "/v1/chat/completions", "POST", streamPayload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for AI-assisted statistics analysis with chart visualization.
|
* Service for AI-assisted statistics analysis with chart visualization. Uses LM
|
||||||
* Uses LM Studio via direct REST client (like aimailassistant) instead of Spring AI.
|
* Studio via direct REST client (like aimailassistant) instead of Spring AI.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -36,11 +36,8 @@ public class AiStatisticsService {
|
|||||||
/**
|
/**
|
||||||
* Response record containing text and optional chart data.
|
* Response record containing text and optional chart data.
|
||||||
*/
|
*/
|
||||||
public record StatisticsResponse(
|
public record StatisticsResponse(String textResponse, String chartType, String chartData) {
|
||||||
String textResponse,
|
}
|
||||||
String chartType,
|
|
||||||
String chartData
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analyze a statistics query and return a response with optional visualization.
|
* Analyze a statistics query and return a response with optional visualization.
|
||||||
@@ -48,11 +45,11 @@ public class AiStatisticsService {
|
|||||||
public StatisticsResponse analyzeStatisticsQuery(String userQuery) {
|
public StatisticsResponse analyzeStatisticsQuery(String userQuery) {
|
||||||
log.info("Processing statistics query: {}", userQuery);
|
log.info("Processing statistics query: {}", userQuery);
|
||||||
|
|
||||||
// Determine query type and prepare chart data (includes customer filter detection)
|
// Determine query type and prepare chart data (includes customer filter
|
||||||
|
// detection)
|
||||||
QueryAnalysis analysis = analyzeQueryType(userQuery);
|
QueryAnalysis analysis = analyzeQueryType(userQuery);
|
||||||
log.debug("Query analysis - Type: {}, Chart: {}, Customer: {}, Status: {}",
|
log.debug("Query analysis - Type: {}, Chart: {}, Customer: {}, Status: {}", analysis.queryType,
|
||||||
analysis.queryType, analysis.chartType,
|
analysis.chartType, analysis.customerFilter != null ? analysis.customerFilter : "none",
|
||||||
analysis.customerFilter != null ? analysis.customerFilter : "none",
|
|
||||||
analysis.statusFilter != null ? analysis.statusFilter : "none");
|
analysis.statusFilter != null ? analysis.statusFilter : "none");
|
||||||
|
|
||||||
// Gather context (statistics or job list depending on query type)
|
// Gather context (statistics or job list depending on query type)
|
||||||
@@ -62,8 +59,7 @@ public class AiStatisticsService {
|
|||||||
String prompt = buildPrompt(userQuery, statisticsContext, analysis);
|
String prompt = buildPrompt(userQuery, statisticsContext, analysis);
|
||||||
|
|
||||||
// System prompt - different for list vs statistics queries
|
// System prompt - different for list vs statistics queries
|
||||||
String systemPrompt = analysis.queryType.equals("list")
|
String systemPrompt = analysis.queryType.equals("list") ? buildListSystemPrompt()
|
||||||
? buildListSystemPrompt()
|
|
||||||
: buildStatisticsSystemPrompt();
|
: buildStatisticsSystemPrompt();
|
||||||
|
|
||||||
// Call LLM via direct REST client (like aimailassistant)
|
// Call LLM via direct REST client (like aimailassistant)
|
||||||
@@ -74,21 +70,19 @@ public class AiStatisticsService {
|
|||||||
return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData);
|
return new StatisticsResponse(llmResponse, analysis.chartType, analysis.chartData);
|
||||||
} else {
|
} else {
|
||||||
log.warn("LLM returned null or blank response, using fallback");
|
log.warn("LLM returned null or blank response, using fallback");
|
||||||
return new StatisticsResponse(
|
return new StatisticsResponse(buildFallbackResponse(analysis), analysis.chartType, analysis.chartData);
|
||||||
buildFallbackResponse(analysis),
|
|
||||||
analysis.chartType,
|
|
||||||
analysis.chartData
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private record QueryAnalysis(
|
private record QueryAnalysis(String queryType, String chartType, String chartData, String customerFilter, // null =
|
||||||
String queryType,
|
// no
|
||||||
String chartType,
|
// filter,
|
||||||
String chartData,
|
// show
|
||||||
String customerFilter, // null = no filter, show all data
|
// all
|
||||||
JobStatus statusFilter // null = no status filter
|
// data
|
||||||
) {}
|
JobStatus statusFilter // null = no status filter
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
private String buildStatisticsSystemPrompt() {
|
private String buildStatisticsSystemPrompt() {
|
||||||
return """
|
return """
|
||||||
@@ -134,47 +128,42 @@ public class AiStatisticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect user-specified chart type from the query.
|
* Detect user-specified chart type from the query. Returns null if no specific
|
||||||
* Returns null if no specific chart type was requested.
|
* chart type was requested.
|
||||||
*/
|
*/
|
||||||
private String detectUserChartTypePreference(String query) {
|
private String detectUserChartTypePreference(String query) {
|
||||||
String lowerQuery = query.toLowerCase();
|
String lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
// Balkendiagramm / Bar Chart
|
// Balkendiagramm / Bar Chart
|
||||||
if (lowerQuery.contains("balken") || lowerQuery.contains("bar chart") ||
|
if (lowerQuery.contains("balken") || lowerQuery.contains("bar chart") || lowerQuery.contains("säulen")
|
||||||
lowerQuery.contains("säulen") || lowerQuery.contains("balkendiagramm")) {
|
|| lowerQuery.contains("balkendiagramm")) {
|
||||||
return "bar";
|
return "bar";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tortendiagramm / Pie Chart
|
// Tortendiagramm / Pie Chart
|
||||||
if (lowerQuery.contains("torten") || lowerQuery.contains("pie") ||
|
if (lowerQuery.contains("torten") || lowerQuery.contains("pie") || lowerQuery.contains("kreis")
|
||||||
lowerQuery.contains("kreis") || lowerQuery.contains("tortendiagramm") ||
|
|| lowerQuery.contains("tortendiagramm") || lowerQuery.contains("kreisdiagramm")) {
|
||||||
lowerQuery.contains("kreisdiagramm")) {
|
|
||||||
return "pie";
|
return "pie";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Donut / Ring Chart
|
// Donut / Ring Chart
|
||||||
if (lowerQuery.contains("donut") || lowerQuery.contains("ring") ||
|
if (lowerQuery.contains("donut") || lowerQuery.contains("ring") || lowerQuery.contains("doughnut")) {
|
||||||
lowerQuery.contains("doughnut")) {
|
|
||||||
return "doughnut";
|
return "doughnut";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liniendiagramm / Line Chart
|
// Liniendiagramm / Line Chart
|
||||||
if (lowerQuery.contains("linie") || lowerQuery.contains("line") ||
|
if (lowerQuery.contains("linie") || lowerQuery.contains("line") || lowerQuery.contains("liniendiagramm")
|
||||||
lowerQuery.contains("liniendiagramm") || lowerQuery.contains("kurve") ||
|
|| lowerQuery.contains("kurve") || lowerQuery.contains("graph")) {
|
||||||
lowerQuery.contains("graph")) {
|
|
||||||
return "line";
|
return "line";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flächendiagramm / Area Chart
|
// Flächendiagramm / Area Chart
|
||||||
if (lowerQuery.contains("fläche") || lowerQuery.contains("area") ||
|
if (lowerQuery.contains("fläche") || lowerQuery.contains("area") || lowerQuery.contains("flächendiagramm")) {
|
||||||
lowerQuery.contains("flächendiagramm")) {
|
|
||||||
return "line"; // Line with fill=true
|
return "line"; // Line with fill=true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Radar Chart
|
// Radar Chart
|
||||||
if (lowerQuery.contains("radar") || lowerQuery.contains("netz") ||
|
if (lowerQuery.contains("radar") || lowerQuery.contains("netz") || lowerQuery.contains("spinne")) {
|
||||||
lowerQuery.contains("spinne")) {
|
|
||||||
return "radar";
|
return "radar";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,31 +205,29 @@ public class AiStatisticsService {
|
|||||||
String chartData;
|
String chartData;
|
||||||
|
|
||||||
// Status-bezogene Anfragen (Statistik, nicht Liste)
|
// Status-bezogene Anfragen (Statistik, nicht Liste)
|
||||||
if ((lowerQuery.contains("status") && (lowerQuery.contains("statistik") || lowerQuery.contains("verteilung") ||
|
if ((lowerQuery.contains("status") && (lowerQuery.contains("statistik") || lowerQuery.contains("verteilung")
|
||||||
lowerQuery.contains("übersicht") || lowerQuery.contains("wie viele"))) ||
|
|| lowerQuery.contains("übersicht") || lowerQuery.contains("wie viele")))
|
||||||
lowerQuery.contains("zählen") || lowerQuery.contains("anzahl")) {
|
|| lowerQuery.contains("zählen") || lowerQuery.contains("anzahl")) {
|
||||||
queryType = "status";
|
queryType = "status";
|
||||||
defaultChartType = "doughnut";
|
defaultChartType = "doughnut";
|
||||||
chartData = buildStatusChartData(customerFilter);
|
chartData = buildStatusChartData(customerFilter);
|
||||||
}
|
}
|
||||||
// Umsatz-bezogene Anfragen
|
// Umsatz-bezogene Anfragen
|
||||||
else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") ||
|
else if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") || lowerQuery.contains("einnahmen")) {
|
||||||
lowerQuery.contains("einnahmen")) {
|
|
||||||
queryType = "revenue";
|
queryType = "revenue";
|
||||||
defaultChartType = "bar";
|
defaultChartType = "bar";
|
||||||
chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter) : buildRevenueChartData();
|
chartData = customerFilter != null ? buildCustomerRevenueChartData(customerFilter)
|
||||||
|
: buildRevenueChartData();
|
||||||
}
|
}
|
||||||
// Trend-bezogene Anfragen
|
// Trend-bezogene Anfragen
|
||||||
else if (lowerQuery.contains("trend") || lowerQuery.contains("monat") ||
|
else if (lowerQuery.contains("trend") || lowerQuery.contains("monat") || lowerQuery.contains("entwicklung")
|
||||||
lowerQuery.contains("entwicklung") || lowerQuery.contains("jahr") ||
|
|| lowerQuery.contains("jahr") || lowerQuery.contains("verlauf")) {
|
||||||
lowerQuery.contains("verlauf")) {
|
|
||||||
queryType = "trend";
|
queryType = "trend";
|
||||||
defaultChartType = "line";
|
defaultChartType = "line";
|
||||||
chartData = buildTrendChartData(customerFilter);
|
chartData = buildTrendChartData(customerFilter);
|
||||||
}
|
}
|
||||||
// Task-bezogene Anfragen
|
// Task-bezogene Anfragen
|
||||||
else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") ||
|
else if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") || lowerQuery.contains("erledigt")) {
|
||||||
lowerQuery.contains("erledigt")) {
|
|
||||||
queryType = "tasks";
|
queryType = "tasks";
|
||||||
defaultChartType = "doughnut";
|
defaultChartType = "doughnut";
|
||||||
chartData = buildTaskChartData();
|
chartData = buildTaskChartData();
|
||||||
@@ -274,7 +261,8 @@ public class AiStatisticsService {
|
|||||||
if (lowerQuery.contains("abgeholt") || lowerQuery.contains("picked up")) {
|
if (lowerQuery.contains("abgeholt") || lowerQuery.contains("picked up")) {
|
||||||
return JobStatus.PICKED_UP;
|
return JobStatus.PICKED_UP;
|
||||||
}
|
}
|
||||||
if (lowerQuery.contains("in transport") || lowerQuery.contains("in transit") || lowerQuery.contains("unterwegs")) {
|
if (lowerQuery.contains("in transport") || lowerQuery.contains("in transit")
|
||||||
|
|| lowerQuery.contains("unterwegs")) {
|
||||||
return JobStatus.IN_TRANSIT;
|
return JobStatus.IN_TRANSIT;
|
||||||
}
|
}
|
||||||
if (lowerQuery.contains("zugestellt") || lowerQuery.contains("delivered") || lowerQuery.contains("geliefert")) {
|
if (lowerQuery.contains("zugestellt") || lowerQuery.contains("delivered") || lowerQuery.contains("geliefert")) {
|
||||||
@@ -283,7 +271,8 @@ public class AiStatisticsService {
|
|||||||
if (lowerQuery.contains("abgeschlossen") || lowerQuery.contains("completed") || lowerQuery.contains("fertig")) {
|
if (lowerQuery.contains("abgeschlossen") || lowerQuery.contains("completed") || lowerQuery.contains("fertig")) {
|
||||||
return JobStatus.COMPLETED;
|
return JobStatus.COMPLETED;
|
||||||
}
|
}
|
||||||
if (lowerQuery.contains("storniert") || lowerQuery.contains("cancelled") || lowerQuery.contains("abgebrochen")) {
|
if (lowerQuery.contains("storniert") || lowerQuery.contains("cancelled")
|
||||||
|
|| lowerQuery.contains("abgebrochen")) {
|
||||||
return JobStatus.CANCELLED;
|
return JobStatus.CANCELLED;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -294,45 +283,31 @@ public class AiStatisticsService {
|
|||||||
*/
|
*/
|
||||||
private boolean isListQuery(String lowerQuery) {
|
private boolean isListQuery(String lowerQuery) {
|
||||||
// Keywords that indicate a list/detail query (not statistics)
|
// Keywords that indicate a list/detail query (not statistics)
|
||||||
boolean hasListKeywords = lowerQuery.contains("zeige alle") ||
|
boolean hasListKeywords = lowerQuery.contains("zeige alle") || lowerQuery.contains("liste")
|
||||||
lowerQuery.contains("liste") ||
|
|| lowerQuery.contains("welche jobs") || lowerQuery.contains("alle jobs")
|
||||||
lowerQuery.contains("welche jobs") ||
|
|| lowerQuery.contains("alle aufträge") || lowerQuery.contains("zeige die jobs")
|
||||||
lowerQuery.contains("alle jobs") ||
|
|| lowerQuery.contains("zeige die aufträge") || lowerQuery.contains("zeig mir die")
|
||||||
lowerQuery.contains("alle aufträge") ||
|
|| lowerQuery.contains("gib mir die");
|
||||||
lowerQuery.contains("zeige die jobs") ||
|
|
||||||
lowerQuery.contains("zeige die aufträge") ||
|
|
||||||
lowerQuery.contains("zeig mir die") ||
|
|
||||||
lowerQuery.contains("gib mir die");
|
|
||||||
|
|
||||||
// Keywords that indicate statistics (override list detection)
|
// Keywords that indicate statistics (override list detection)
|
||||||
boolean hasStatsKeywords = lowerQuery.contains("statistik") ||
|
boolean hasStatsKeywords = lowerQuery.contains("statistik") || lowerQuery.contains("diagramm")
|
||||||
lowerQuery.contains("diagramm") ||
|
|| lowerQuery.contains("chart") || lowerQuery.contains("verteilung") || lowerQuery.contains("wie viele")
|
||||||
lowerQuery.contains("chart") ||
|
|| lowerQuery.contains("anzahl") || lowerQuery.contains("zähle") || lowerQuery.contains("umsatz")
|
||||||
lowerQuery.contains("verteilung") ||
|
|| lowerQuery.contains("trend") || lowerQuery.contains("torte") || lowerQuery.contains("balken");
|
||||||
lowerQuery.contains("wie viele") ||
|
|
||||||
lowerQuery.contains("anzahl") ||
|
|
||||||
lowerQuery.contains("zähle") ||
|
|
||||||
lowerQuery.contains("umsatz") ||
|
|
||||||
lowerQuery.contains("trend") ||
|
|
||||||
lowerQuery.contains("torte") ||
|
|
||||||
lowerQuery.contains("balken");
|
|
||||||
|
|
||||||
return hasListKeywords && !hasStatsKeywords;
|
return hasListKeywords && !hasStatsKeywords;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect customer filter from the query.
|
* Detect customer filter from the query. Returns the matching customer name or
|
||||||
* Returns the matching customer name or null if no filter detected.
|
* null if no filter detected.
|
||||||
*/
|
*/
|
||||||
private String detectCustomerFilter(String query) {
|
private String detectCustomerFilter(String query) {
|
||||||
String lowerQuery = query.toLowerCase();
|
String lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
// Keywords that indicate a customer filter
|
// Keywords that indicate a customer filter
|
||||||
String[] filterIndicators = {
|
String[] filterIndicators = { "für ", "von ", "bei ", "kunde ", "firma ", "unternehmen ", "für die firma ",
|
||||||
"für ", "von ", "bei ", "kunde ", "firma ", "unternehmen ",
|
"für den kunden ", "von der firma ", "vom kunden ", "nur ", "ausschließlich ", "speziell " };
|
||||||
"für die firma ", "für den kunden ", "von der firma ", "vom kunden ",
|
|
||||||
"nur ", "ausschließlich ", "speziell "
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if any indicator is present
|
// Check if any indicator is present
|
||||||
boolean hasIndicator = false;
|
boolean hasIndicator = false;
|
||||||
@@ -367,15 +342,14 @@ public class AiStatisticsService {
|
|||||||
List<String> labels = new ArrayList<>();
|
List<String> labels = new ArrayList<>();
|
||||||
List<Long> data = new ArrayList<>();
|
List<Long> data = new ArrayList<>();
|
||||||
// Moderne Farbpalette mit satteren Farben
|
// Moderne Farbpalette mit satteren Farben
|
||||||
List<String> colors = List.of(
|
List<String> colors = List.of("#06b6d4", // CREATED - cyan
|
||||||
"#06b6d4", // CREATED - cyan
|
"#f59e0b", // IN_PROGRESS - amber
|
||||||
"#f59e0b", // IN_PROGRESS - amber
|
"#3b82f6", // PICKUP_SCHEDULED - blau
|
||||||
"#3b82f6", // PICKUP_SCHEDULED - blau
|
"#8b5cf6", // PICKED_UP - violett
|
||||||
"#8b5cf6", // PICKED_UP - violett
|
"#f97316", // IN_TRANSIT - orange
|
||||||
"#f97316", // IN_TRANSIT - orange
|
"#22c55e", // DELIVERED - grün
|
||||||
"#22c55e", // DELIVERED - grün
|
"#6366f1", // COMPLETED - indigo
|
||||||
"#6366f1", // COMPLETED - indigo
|
"#ef4444" // CANCELLED - rot
|
||||||
"#ef4444" // CANCELLED - rot
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (JobStatus status : JobStatus.values()) {
|
for (JobStatus status : JobStatus.values()) {
|
||||||
@@ -402,12 +376,10 @@ public class AiStatisticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gradient-ähnliche Farbpalette für Balken
|
// Gradient-ähnliche Farbpalette für Balken
|
||||||
List<String> colors = List.of(
|
List<String> colors = List.of("#6366f1", "#8b5cf6", "#a855f7", "#c084fc", "#d8b4fe", "#e9d5ff", "#f3e8ff",
|
||||||
"#6366f1", "#8b5cf6", "#a855f7", "#c084fc",
|
"#faf5ff", "#ede9fe", "#ddd6fe");
|
||||||
"#d8b4fe", "#e9d5ff", "#f3e8ff", "#faf5ff",
|
return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())),
|
||||||
"#ede9fe", "#ddd6fe"
|
"Umsatz (EUR)");
|
||||||
);
|
|
||||||
return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Umsatz (EUR)");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildCustomerRevenueChartData(String customer) {
|
private String buildCustomerRevenueChartData(String customer) {
|
||||||
@@ -418,11 +390,8 @@ public class AiStatisticsService {
|
|||||||
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
|
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
|
||||||
|
|
||||||
List<String> labels = List.of("Aufträge gesamt", "Abgeschlossen", "In Bearbeitung", "Umsatz (€/100)");
|
List<String> labels = List.of("Aufträge gesamt", "Abgeschlossen", "In Bearbeitung", "Umsatz (€/100)");
|
||||||
List<Double> data = List.of(
|
List<Double> data = List.of((double) totalJobs, (double) completed, (double) inProgress,
|
||||||
(double) totalJobs,
|
totalRevenue.doubleValue() / 100 // Scale down for better visualization
|
||||||
(double) completed,
|
|
||||||
(double) inProgress,
|
|
||||||
totalRevenue.doubleValue() / 100 // Scale down for better visualization
|
|
||||||
);
|
);
|
||||||
List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#6366f1");
|
List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#6366f1");
|
||||||
|
|
||||||
@@ -435,16 +404,15 @@ public class AiStatisticsService {
|
|||||||
? statisticsService.getMonthlyJobCountsForCustomer(currentYear, customerFilter)
|
? statisticsService.getMonthlyJobCountsForCustomer(currentYear, customerFilter)
|
||||||
: statisticsService.getMonthlyJobCounts(currentYear);
|
: statisticsService.getMonthlyJobCounts(currentYear);
|
||||||
|
|
||||||
List<String> labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
|
List<String> labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov",
|
||||||
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez");
|
"Dez");
|
||||||
List<Long> data = new ArrayList<>();
|
List<Long> data = new ArrayList<>();
|
||||||
|
|
||||||
for (Month month : Month.values()) {
|
for (Month month : Month.values()) {
|
||||||
data.add(monthlyData.getOrDefault(month, 0L));
|
data.add(monthlyData.getOrDefault(month, 0L));
|
||||||
}
|
}
|
||||||
|
|
||||||
String datasetLabel = customerFilter != null
|
String datasetLabel = customerFilter != null ? String.format("%s - %d", customerFilter, currentYear)
|
||||||
? String.format("%s - %d", customerFilter, currentYear)
|
|
||||||
: String.format("Aufträge %d", currentYear);
|
: String.format("Aufträge %d", currentYear);
|
||||||
|
|
||||||
return String.format("""
|
return String.format("""
|
||||||
@@ -471,10 +439,7 @@ public class AiStatisticsService {
|
|||||||
Map<String, Long> taskStats = statisticsService.getTaskCompletionStats();
|
Map<String, Long> taskStats = statisticsService.getTaskCompletionStats();
|
||||||
|
|
||||||
List<String> labels = List.of("Erledigt", "Ausstehend");
|
List<String> labels = List.of("Erledigt", "Ausstehend");
|
||||||
List<Long> data = List.of(
|
List<Long> data = List.of(taskStats.getOrDefault("completed", 0L), taskStats.getOrDefault("pending", 0L));
|
||||||
taskStats.getOrDefault("completed", 0L),
|
|
||||||
taskStats.getOrDefault("pending", 0L)
|
|
||||||
);
|
|
||||||
List<String> colors = List.of("#22c55e", "#f59e0b");
|
List<String> colors = List.of("#22c55e", "#f59e0b");
|
||||||
|
|
||||||
return buildChartJson(labels, data, colors, "Aufgaben");
|
return buildChartJson(labels, data, colors, "Aufgaben");
|
||||||
@@ -485,8 +450,7 @@ public class AiStatisticsService {
|
|||||||
? statisticsService.getJobCountsByStatusForCustomer(customerFilter)
|
? statisticsService.getJobCountsByStatusForCustomer(customerFilter)
|
||||||
: statisticsService.getJobCountsByStatus();
|
: statisticsService.getJobCountsByStatus();
|
||||||
|
|
||||||
long total = customerFilter != null
|
long total = customerFilter != null ? statisticsService.getTotalJobCountForCustomer(customerFilter)
|
||||||
? statisticsService.getTotalJobCountForCustomer(customerFilter)
|
|
||||||
: statisticsService.getTotalJobCount();
|
: statisticsService.getTotalJobCount();
|
||||||
long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L);
|
long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L);
|
||||||
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
|
long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
|
||||||
@@ -570,8 +534,8 @@ public class AiStatisticsService {
|
|||||||
// General statistics (all data)
|
// General statistics (all data)
|
||||||
var statusCounts = statisticsService.getJobCountsByStatus();
|
var statusCounts = statisticsService.getJobCountsByStatus();
|
||||||
context.append("**Aktuelle Auftragsstatistiken:**\n");
|
context.append("**Aktuelle Auftragsstatistiken:**\n");
|
||||||
statusCounts.forEach((status, count) ->
|
statusCounts.forEach((status, count) -> context
|
||||||
context.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count)));
|
.append(String.format("- %s: %d Aufträge\n", status.getDisplayName(), count)));
|
||||||
|
|
||||||
context.append(String.format("\n**Gesamtübersicht:**\n"));
|
context.append(String.format("\n**Gesamtübersicht:**\n"));
|
||||||
context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount()));
|
context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount()));
|
||||||
@@ -591,8 +555,7 @@ public class AiStatisticsService {
|
|||||||
context.append("\n**Top 5 Kunden nach Umsatz:**\n");
|
context.append("\n**Top 5 Kunden nach Umsatz:**\n");
|
||||||
for (var entry : topCustomers) {
|
for (var entry : topCustomers) {
|
||||||
context.append(String.format("- %s: %.2f EUR\n",
|
context.append(String.format("- %s: %.2f EUR\n",
|
||||||
entry.getKey() != null ? entry.getKey() : "Unbekannt",
|
entry.getKey() != null ? entry.getKey() : "Unbekannt", entry.getValue()));
|
||||||
entry.getValue()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -606,8 +569,8 @@ public class AiStatisticsService {
|
|||||||
List<Job> jobs;
|
List<Job> jobs;
|
||||||
if (customerFilter != null && statusFilter != null) {
|
if (customerFilter != null && statusFilter != null) {
|
||||||
jobs = statisticsService.getJobsByCustomerAndStatus(customerFilter, statusFilter);
|
jobs = statisticsService.getJobsByCustomerAndStatus(customerFilter, statusFilter);
|
||||||
context.append(String.format("**Jobs für %s mit Status %s:**\n\n",
|
context.append(
|
||||||
customerFilter, statusFilter.getDisplayName()));
|
String.format("**Jobs für %s mit Status %s:**\n\n", customerFilter, statusFilter.getDisplayName()));
|
||||||
} else if (customerFilter != null) {
|
} else if (customerFilter != null) {
|
||||||
jobs = statisticsService.getJobsByCustomer(customerFilter);
|
jobs = statisticsService.getJobsByCustomer(customerFilter);
|
||||||
context.append(String.format("**Jobs für %s:**\n\n", customerFilter));
|
context.append(String.format("**Jobs für %s:**\n\n", customerFilter));
|
||||||
@@ -628,10 +591,10 @@ public class AiStatisticsService {
|
|||||||
context.append(String.format("... und %d weitere Jobs\n", jobs.size() - 10));
|
context.append(String.format("... und %d weitere Jobs\n", jobs.size() - 10));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
context.append(String.format("- %s: %s (%s)\n",
|
context.append(
|
||||||
job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nr.",
|
String.format("- %s: %s (%s)\n", job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nr.",
|
||||||
job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt",
|
job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt",
|
||||||
job.getStatus().getDisplayName()));
|
job.getStatus().getDisplayName()));
|
||||||
shown++;
|
shown++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,7 +602,8 @@ public class AiStatisticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String buildPrompt(String userQuery, String statisticsContext, QueryAnalysis analysis) {
|
private String buildPrompt(String userQuery, String statisticsContext, QueryAnalysis analysis) {
|
||||||
// User prompt contains only the context and question (system prompt is passed separately)
|
// User prompt contains only the context and question (system prompt is passed
|
||||||
|
// separately)
|
||||||
return String.format("""
|
return String.format("""
|
||||||
%s
|
%s
|
||||||
|
|
||||||
@@ -652,107 +616,93 @@ public class AiStatisticsService {
|
|||||||
JobStatus statusFilter = analysis.statusFilter;
|
JobStatus statusFilter = analysis.statusFilter;
|
||||||
|
|
||||||
return switch (analysis.queryType) {
|
return switch (analysis.queryType) {
|
||||||
case "list" -> {
|
case "list" -> {
|
||||||
List<Job> jobs;
|
List<Job> jobs;
|
||||||
if (customer != null && statusFilter != null) {
|
if (customer != null && statusFilter != null) {
|
||||||
jobs = statisticsService.getJobsByCustomerAndStatus(customer, statusFilter);
|
jobs = statisticsService.getJobsByCustomerAndStatus(customer, statusFilter);
|
||||||
yield String.format("Es wurden %d Jobs für %s mit Status \"%s\" gefunden.",
|
yield String.format("Es wurden %d Jobs für %s mit Status \"%s\" gefunden.", jobs.size(), customer,
|
||||||
jobs.size(), customer, statusFilter.getDisplayName());
|
statusFilter.getDisplayName());
|
||||||
} else if (customer != null) {
|
} else if (customer != null) {
|
||||||
jobs = statisticsService.getJobsByCustomer(customer);
|
jobs = statisticsService.getJobsByCustomer(customer);
|
||||||
yield String.format("Es wurden %d Jobs für %s gefunden.", jobs.size(), customer);
|
yield String.format("Es wurden %d Jobs für %s gefunden.", jobs.size(), customer);
|
||||||
} else if (statusFilter != null) {
|
} else if (statusFilter != null) {
|
||||||
jobs = statisticsService.getJobsByStatus(statusFilter);
|
jobs = statisticsService.getJobsByStatus(statusFilter);
|
||||||
yield String.format("Es wurden %d Jobs mit Status \"%s\" gefunden.",
|
yield String.format("Es wurden %d Jobs mit Status \"%s\" gefunden.", jobs.size(),
|
||||||
jobs.size(), statusFilter.getDisplayName());
|
statusFilter.getDisplayName());
|
||||||
} else {
|
} else {
|
||||||
yield "Hier sind die aktuellen Jobs.";
|
yield "Hier sind die aktuellen Jobs.";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case "status" -> {
|
}
|
||||||
var counts = customer != null
|
case "status" -> {
|
||||||
? statisticsService.getJobCountsByStatusForCustomer(customer)
|
var counts = customer != null ? statisticsService.getJobCountsByStatusForCustomer(customer)
|
||||||
: statisticsService.getJobCountsByStatus();
|
: statisticsService.getJobCountsByStatus();
|
||||||
String title = customer != null
|
String title = customer != null ? String.format("**Auftragsübersicht für %s:**\n\n", customer)
|
||||||
? String.format("**Auftragsübersicht für %s:**\n\n", customer)
|
: "**Auftragsübersicht nach Status:**\n\n";
|
||||||
: "**Auftragsübersicht nach Status:**\n\n";
|
StringBuilder sb = new StringBuilder(title);
|
||||||
StringBuilder sb = new StringBuilder(title);
|
counts.forEach((status, count) -> {
|
||||||
counts.forEach((status, count) -> {
|
if (count > 0) {
|
||||||
if (count > 0) {
|
sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count));
|
||||||
sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count));
|
}
|
||||||
}
|
});
|
||||||
});
|
long total = customer != null ? statisticsService.getTotalJobCountForCustomer(customer)
|
||||||
long total = customer != null
|
: statisticsService.getTotalJobCount();
|
||||||
? statisticsService.getTotalJobCountForCustomer(customer)
|
sb.append(String.format("\n**Gesamt:** %d Aufträge", total));
|
||||||
: statisticsService.getTotalJobCount();
|
yield sb.toString();
|
||||||
sb.append(String.format("\n**Gesamt:** %d Aufträge", total));
|
}
|
||||||
|
case "revenue" -> {
|
||||||
|
if (customer != null) {
|
||||||
|
var revenue = statisticsService.getTotalRevenueForCustomer(customer);
|
||||||
|
var jobCount = statisticsService.getTotalJobCountForCustomer(customer);
|
||||||
|
yield String.format("**Umsatz für %s:**\n\n" + "- **Gesamtumsatz:** %.2f EUR\n" + "- **Aufträge:** %d",
|
||||||
|
customer, revenue, jobCount);
|
||||||
|
} else {
|
||||||
|
var topCustomers = statisticsService.getTopCustomersByRevenue(5);
|
||||||
|
StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n");
|
||||||
|
int rank = 1;
|
||||||
|
for (var entry : topCustomers) {
|
||||||
|
sb.append(String.format("%d. **%s:** %.2f EUR\n", rank++,
|
||||||
|
entry.getKey() != null ? entry.getKey() : "Unbekannt", entry.getValue()));
|
||||||
|
}
|
||||||
|
sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue()));
|
||||||
yield sb.toString();
|
yield sb.toString();
|
||||||
}
|
}
|
||||||
case "revenue" -> {
|
}
|
||||||
if (customer != null) {
|
case "trend" -> {
|
||||||
var revenue = statisticsService.getTotalRevenueForCustomer(customer);
|
int year = Year.now().getValue();
|
||||||
var jobCount = statisticsService.getTotalJobCountForCustomer(customer);
|
var monthly = customer != null ? statisticsService.getMonthlyJobCountsForCustomer(year, customer)
|
||||||
yield String.format("**Umsatz für %s:**\n\n" +
|
: statisticsService.getMonthlyJobCounts(year);
|
||||||
"- **Gesamtumsatz:** %.2f EUR\n" +
|
long total = monthly.values().stream().mapToLong(Long::longValue).sum();
|
||||||
"- **Aufträge:** %d",
|
String title = customer != null ? String.format("**Monatstrend %d für %s:**", year, customer)
|
||||||
customer, revenue, jobCount);
|
: String.format("**Monatstrend %d:**", year);
|
||||||
} else {
|
yield String.format(
|
||||||
var topCustomers = statisticsService.getTopCustomersByRevenue(5);
|
"%s\n\nInsgesamt wurden %d Aufträge erstellt. " + "Die Verteilung ist im Diagramm ersichtlich.",
|
||||||
StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n");
|
title, total);
|
||||||
int rank = 1;
|
}
|
||||||
for (var entry : topCustomers) {
|
case "tasks" -> {
|
||||||
sb.append(String.format("%d. **%s:** %.2f EUR\n",
|
var taskStats = statisticsService.getTaskCompletionStats();
|
||||||
rank++,
|
long total = taskStats.getOrDefault("total", 0L);
|
||||||
entry.getKey() != null ? entry.getKey() : "Unbekannt",
|
long completed = taskStats.getOrDefault("completed", 0L);
|
||||||
entry.getValue()));
|
double rate = total > 0 ? (double) completed / total * 100 : 0;
|
||||||
}
|
yield String.format("**Aufgabenstatistik:**\n\n" + "- **Gesamt:** %d Aufgaben\n"
|
||||||
sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue()));
|
+ "- **Erledigt:** %d (%.1f%%)\n" + "- **Ausstehend:** %d", total, completed, rate,
|
||||||
yield sb.toString();
|
taskStats.getOrDefault("pending", 0L));
|
||||||
}
|
}
|
||||||
}
|
default -> {
|
||||||
case "trend" -> {
|
if (customer != null) {
|
||||||
int year = Year.now().getValue();
|
yield String.format(
|
||||||
var monthly = customer != null
|
"**Übersicht für %s:**\n\n" + "- **Aufträge gesamt:** %d\n" + "- **Abschlussrate:** %.1f%%\n"
|
||||||
? statisticsService.getMonthlyJobCountsForCustomer(year, customer)
|
+ "- **Umsatz:** %.2f EUR",
|
||||||
: statisticsService.getMonthlyJobCounts(year);
|
customer, statisticsService.getTotalJobCountForCustomer(customer),
|
||||||
long total = monthly.values().stream().mapToLong(Long::longValue).sum();
|
statisticsService.getCompletionRateForCustomer(customer),
|
||||||
String title = customer != null
|
statisticsService.getTotalRevenueForCustomer(customer));
|
||||||
? String.format("**Monatstrend %d für %s:**", year, customer)
|
} else {
|
||||||
: String.format("**Monatstrend %d:**", year);
|
yield String.format(
|
||||||
yield String.format("%s\n\nInsgesamt wurden %d Aufträge erstellt. " +
|
"**Übersicht:**\n\n" + "- **Aufträge gesamt:** %d\n" + "- **Abschlussrate:** %.1f%%\n"
|
||||||
"Die Verteilung ist im Diagramm ersichtlich.", title, total);
|
+ "- **Gesamtumsatz:** %.2f EUR",
|
||||||
}
|
statisticsService.getTotalJobCount(), statisticsService.getCompletionRate(),
|
||||||
case "tasks" -> {
|
statisticsService.getTotalRevenue());
|
||||||
var taskStats = statisticsService.getTaskCompletionStats();
|
|
||||||
long total = taskStats.getOrDefault("total", 0L);
|
|
||||||
long completed = taskStats.getOrDefault("completed", 0L);
|
|
||||||
double rate = total > 0 ? (double) completed / total * 100 : 0;
|
|
||||||
yield String.format("**Aufgabenstatistik:**\n\n" +
|
|
||||||
"- **Gesamt:** %d Aufgaben\n" +
|
|
||||||
"- **Erledigt:** %d (%.1f%%)\n" +
|
|
||||||
"- **Ausstehend:** %d",
|
|
||||||
total, completed, rate, taskStats.getOrDefault("pending", 0L));
|
|
||||||
}
|
|
||||||
default -> {
|
|
||||||
if (customer != null) {
|
|
||||||
yield String.format("**Übersicht für %s:**\n\n" +
|
|
||||||
"- **Aufträge gesamt:** %d\n" +
|
|
||||||
"- **Abschlussrate:** %.1f%%\n" +
|
|
||||||
"- **Umsatz:** %.2f EUR",
|
|
||||||
customer,
|
|
||||||
statisticsService.getTotalJobCountForCustomer(customer),
|
|
||||||
statisticsService.getCompletionRateForCustomer(customer),
|
|
||||||
statisticsService.getTotalRevenueForCustomer(customer));
|
|
||||||
} else {
|
|
||||||
yield String.format("**Übersicht:**\n\n" +
|
|
||||||
"- **Aufträge gesamt:** %d\n" +
|
|
||||||
"- **Abschlussrate:** %.1f%%\n" +
|
|
||||||
"- **Gesamtumsatz:** %.2f EUR",
|
|
||||||
statisticsService.getTotalJobCount(),
|
|
||||||
statisticsService.getCompletionRate(),
|
|
||||||
statisticsService.getTotalRevenue());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Direct REST client for LM Studio API.
|
* Direct REST client for LM Studio API. Uses Spring WebClient like
|
||||||
* Uses Spring WebClient like aimailassistant - bypasses Spring AI.
|
* aimailassistant - bypasses Spring AI.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -24,14 +24,10 @@ public class LlmRestClient {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final String model;
|
private final String model;
|
||||||
|
|
||||||
public LlmRestClient(
|
public LlmRestClient(@Value("${spring.ai.openai.base-url:http://192.168.180.10:1234}") String baseUrl,
|
||||||
@Value("${spring.ai.openai.base-url:http://192.168.180.10:1234}") String baseUrl,
|
@Value("${spring.ai.openai.chat.options.model:local-model}") String model, ObjectMapper objectMapper) {
|
||||||
@Value("${spring.ai.openai.chat.options.model:local-model}") String model,
|
|
||||||
ObjectMapper objectMapper) {
|
|
||||||
|
|
||||||
this.webClient = WebClient.builder()
|
this.webClient = WebClient.builder().baseUrl(baseUrl + "/v1/chat/completions").build();
|
||||||
.baseUrl(baseUrl + "/v1/chat/completions")
|
|
||||||
.build();
|
|
||||||
this.model = model;
|
this.model = model;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
|
||||||
@@ -41,8 +37,10 @@ public class LlmRestClient {
|
|||||||
/**
|
/**
|
||||||
* Send a chat completion request to LM Studio.
|
* Send a chat completion request to LM Studio.
|
||||||
*
|
*
|
||||||
* @param systemPrompt System prompt for context
|
* @param systemPrompt
|
||||||
* @param userMessage User message/question
|
* System prompt for context
|
||||||
|
* @param userMessage
|
||||||
|
* User message/question
|
||||||
* @return LLM response text, or null on error
|
* @return LLM response text, or null on error
|
||||||
*/
|
*/
|
||||||
public String chat(String systemPrompt, String userMessage) {
|
public String chat(String systemPrompt, String userMessage) {
|
||||||
@@ -52,36 +50,29 @@ public class LlmRestClient {
|
|||||||
/**
|
/**
|
||||||
* Send a chat completion request to LM Studio with custom parameters.
|
* Send a chat completion request to LM Studio with custom parameters.
|
||||||
*
|
*
|
||||||
* @param systemPrompt System prompt for context
|
* @param systemPrompt
|
||||||
* @param userMessage User message/question
|
* System prompt for context
|
||||||
* @param temperature Temperature for response randomness (0.0-1.0)
|
* @param userMessage
|
||||||
* @param maxTokens Maximum tokens in response
|
* User message/question
|
||||||
|
* @param temperature
|
||||||
|
* Temperature for response randomness (0.0-1.0)
|
||||||
|
* @param maxTokens
|
||||||
|
* Maximum tokens in response
|
||||||
* @return LLM response text, or null on error
|
* @return LLM response text, or null on error
|
||||||
*/
|
*/
|
||||||
public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) {
|
public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) {
|
||||||
try {
|
try {
|
||||||
Map<String, Object> request = Map.of(
|
Map<String, Object> request = Map.of("model", model, "messages",
|
||||||
"model", model,
|
List.of(Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""),
|
||||||
"messages", List.of(
|
Map.of("role", "user", "content", userMessage)),
|
||||||
Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""),
|
"temperature", temperature, "max_tokens", maxTokens, "stream", false // WICHTIG: Kein Streaming!
|
||||||
Map.of("role", "user", "content", userMessage)
|
|
||||||
),
|
|
||||||
"temperature", temperature,
|
|
||||||
"max_tokens", maxTokens,
|
|
||||||
"stream", false // WICHTIG: Kein Streaming!
|
|
||||||
);
|
);
|
||||||
|
|
||||||
log.info("Sending request to LLM (model: {}, prompt length: {} chars)...",
|
log.info("Sending request to LLM (model: {}, prompt length: {} chars)...", model, userMessage.length());
|
||||||
model, userMessage.length());
|
|
||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
String response = webClient.post()
|
String response = webClient.post().contentType(MediaType.APPLICATION_JSON).bodyValue(request).retrieve()
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.bodyToMono(String.class).timeout(Duration.ofSeconds(120)).block();
|
||||||
.bodyValue(request)
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(String.class)
|
|
||||||
.timeout(Duration.ofSeconds(120))
|
|
||||||
.block();
|
|
||||||
|
|
||||||
long duration = System.currentTimeMillis() - startTime;
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
log.info("LLM response received in {}ms", duration);
|
log.info("LLM response received in {}ms", duration);
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
import org.springframework.context.annotation.Primary;
|
import org.springframework.context.annotation.Primary;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Jackson configuration for consistent JSON serialization across the application.
|
* Jackson configuration for consistent JSON serialization across the
|
||||||
* Ensures all date/time fields are serialized as ISO 8601 strings.
|
* application. Ensures all date/time fields are serialized as ISO 8601 strings.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
public class JacksonConfig {
|
public class JacksonConfig {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a configured ObjectMapper bean that serializes dates as ISO 8601 strings.
|
* Creates a configured ObjectMapper bean that serializes dates as ISO 8601
|
||||||
* This bean is used throughout the application for JSON serialization.
|
* strings. This bean is used throughout the application for JSON serialization.
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
@Primary
|
@Primary
|
||||||
@@ -28,4 +28,3 @@ public class JacksonConfig {
|
|||||||
return objectMapper;
|
return objectMapper;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ public class MongoConfig {
|
|||||||
if (source.containsKey("task_order")) {
|
if (source.containsKey("task_order")) {
|
||||||
task.setTaskOrder(source.getInteger("task_order", 0));
|
task.setTaskOrder(source.getInteger("task_order", 0));
|
||||||
}
|
}
|
||||||
|
if (source.containsKey("description")) {
|
||||||
|
task.setDescription(source.getString("description"));
|
||||||
|
}
|
||||||
if (source.containsKey("completed")) {
|
if (source.containsKey("completed")) {
|
||||||
task.setCompleted(source.getBoolean("completed", false));
|
task.setCompleted(source.getBoolean("completed", false));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,3 @@ public class PasswordEncoderConfig {
|
|||||||
return new BCryptPasswordEncoder();
|
return new BCryptPasswordEncoder();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REST API controller for message operations.
|
* REST API controller for message operations. Provides endpoints for sending
|
||||||
* Provides endpoints for sending messages, retrieving messages, and marking messages as read.
|
* messages, retrieving messages, and marking messages as read.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/messages")
|
@RequestMapping("/api/messages")
|
||||||
@@ -29,9 +29,8 @@ public class MessageApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a general message to a client
|
* Send a general message to a client POST /api/messages/send Body: { "content":
|
||||||
* POST /api/messages/send
|
* "message text", "receiver": "appUserId", "contentType": "TEXT|IMAGE" }
|
||||||
* Body: { "content": "message text", "receiver": "appUserId", "contentType": "TEXT|IMAGE" }
|
|
||||||
*/
|
*/
|
||||||
@PostMapping("/send")
|
@PostMapping("/send")
|
||||||
public ResponseEntity<Message> sendGeneralMessage(@RequestBody Map<String, String> request) {
|
public ResponseEntity<Message> sendGeneralMessage(@RequestBody Map<String, String> request) {
|
||||||
@@ -40,8 +39,7 @@ public class MessageApiController {
|
|||||||
String receiver = request.get("receiver");
|
String receiver = request.get("receiver");
|
||||||
MessageContentType contentType = resolveContentType(request.get("contentType"));
|
MessageContentType contentType = resolveContentType(request.get("contentType"));
|
||||||
|
|
||||||
if (content == null || content.isBlank() ||
|
if (content == null || content.isBlank() || receiver == null || receiver.isBlank()) {
|
||||||
receiver == null || receiver.isBlank()) {
|
|
||||||
log.warn("Invalid message request: missing required fields");
|
log.warn("Invalid message request: missing required fields");
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
@@ -60,10 +58,9 @@ public class MessageApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a job-related message to a client
|
* Send a job-related message to a client POST /api/messages/send-job-message
|
||||||
* POST /api/messages/send-job-message
|
* Body: { "content": "message text", "receiver": "appUserId", "jobId": "job
|
||||||
* Body: { "content": "message text", "receiver": "appUserId",
|
* id", "jobNumber": "job number", "contentType": "TEXT|IMAGE" }
|
||||||
* "jobId": "job id", "jobNumber": "job number", "contentType": "TEXT|IMAGE" }
|
|
||||||
*/
|
*/
|
||||||
@PostMapping("/send-job-message")
|
@PostMapping("/send-job-message")
|
||||||
public ResponseEntity<Message> sendJobMessage(@RequestBody Map<String, String> request) {
|
public ResponseEntity<Message> sendJobMessage(@RequestBody Map<String, String> request) {
|
||||||
@@ -74,9 +71,8 @@ public class MessageApiController {
|
|||||||
String jobNumber = request.get("jobNumber");
|
String jobNumber = request.get("jobNumber");
|
||||||
MessageContentType contentType = resolveContentType(request.get("contentType"));
|
MessageContentType contentType = resolveContentType(request.get("contentType"));
|
||||||
|
|
||||||
if (content == null || content.isBlank() ||
|
if (content == null || content.isBlank() || receiver == null || receiver.isBlank() || jobIdStr == null
|
||||||
receiver == null || receiver.isBlank() ||
|
|| jobIdStr.isBlank()) {
|
||||||
jobIdStr == null || jobIdStr.isBlank()) {
|
|
||||||
log.warn("Invalid job message request: missing required fields");
|
log.warn("Invalid job message request: missing required fields");
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
@@ -96,8 +92,8 @@ public class MessageApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all messages for a specific receiver
|
* Get all messages for a specific receiver GET
|
||||||
* GET /api/messages/receiver/{username}
|
* /api/messages/receiver/{username}
|
||||||
*/
|
*/
|
||||||
@GetMapping("/receiver/{username}")
|
@GetMapping("/receiver/{username}")
|
||||||
public ResponseEntity<List<Message>> getMessagesForReceiver(@PathVariable String username) {
|
public ResponseEntity<List<Message>> getMessagesForReceiver(@PathVariable String username) {
|
||||||
@@ -111,8 +107,8 @@ public class MessageApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all unread messages for a specific receiver
|
* Get all unread messages for a specific receiver GET
|
||||||
* GET /api/messages/receiver/{username}/unread
|
* /api/messages/receiver/{username}/unread
|
||||||
*/
|
*/
|
||||||
@GetMapping("/receiver/{username}/unread")
|
@GetMapping("/receiver/{username}/unread")
|
||||||
public ResponseEntity<List<Message>> getUnreadMessagesForReceiver(@PathVariable String username) {
|
public ResponseEntity<List<Message>> getUnreadMessagesForReceiver(@PathVariable String username) {
|
||||||
@@ -126,8 +122,8 @@ public class MessageApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get unread message count for a specific receiver
|
* Get unread message count for a specific receiver GET
|
||||||
* GET /api/messages/receiver/{username}/unread-count
|
* /api/messages/receiver/{username}/unread-count
|
||||||
*/
|
*/
|
||||||
@GetMapping("/receiver/{username}/unread-count")
|
@GetMapping("/receiver/{username}/unread-count")
|
||||||
public ResponseEntity<Map<String, Long>> getUnreadMessageCount(@PathVariable String username) {
|
public ResponseEntity<Map<String, Long>> getUnreadMessageCount(@PathVariable String username) {
|
||||||
@@ -141,8 +137,7 @@ public class MessageApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all messages related to a specific job
|
* Get all messages related to a specific job GET /api/messages/job/{jobId}
|
||||||
* GET /api/messages/job/{jobId}
|
|
||||||
*/
|
*/
|
||||||
@GetMapping("/job/{jobId}")
|
@GetMapping("/job/{jobId}")
|
||||||
public ResponseEntity<List<Message>> getMessagesForJob(@PathVariable String jobId) {
|
public ResponseEntity<List<Message>> getMessagesForJob(@PathVariable String jobId) {
|
||||||
@@ -160,8 +155,7 @@ public class MessageApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all messages (for admin/overview)
|
* Get all messages (for admin/overview) GET /api/messages/all
|
||||||
* GET /api/messages/all
|
|
||||||
*/
|
*/
|
||||||
@GetMapping("/all")
|
@GetMapping("/all")
|
||||||
public ResponseEntity<List<Message>> getAllMessages() {
|
public ResponseEntity<List<Message>> getAllMessages() {
|
||||||
@@ -175,8 +169,8 @@ public class MessageApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get messages by origin (incoming/outgoing/server)
|
* Get messages by origin (incoming/outgoing/server) GET
|
||||||
* GET /api/messages/origin/{origin}
|
* /api/messages/origin/{origin}
|
||||||
*/
|
*/
|
||||||
@GetMapping("/origin/{origin}")
|
@GetMapping("/origin/{origin}")
|
||||||
public ResponseEntity<List<Message>> getMessagesByOrigin(@PathVariable String origin) {
|
public ResponseEntity<List<Message>> getMessagesByOrigin(@PathVariable String origin) {
|
||||||
@@ -194,8 +188,7 @@ public class MessageApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark a message as read
|
* Mark a message as read PUT /api/messages/{messageId}/mark-read
|
||||||
* PUT /api/messages/{messageId}/mark-read
|
|
||||||
*/
|
*/
|
||||||
@PutMapping("/{messageId}/mark-read")
|
@PutMapping("/{messageId}/mark-read")
|
||||||
public ResponseEntity<Void> markMessageAsRead(@PathVariable String messageId) {
|
public ResponseEntity<Void> markMessageAsRead(@PathVariable String messageId) {
|
||||||
@@ -213,8 +206,7 @@ public class MessageApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a message
|
* Delete a message DELETE /api/messages/{messageId}
|
||||||
* DELETE /api/messages/{messageId}
|
|
||||||
*/
|
*/
|
||||||
@DeleteMapping("/{messageId}")
|
@DeleteMapping("/{messageId}")
|
||||||
public ResponseEntity<Void> deleteMessage(@PathVariable String messageId) {
|
public ResponseEntity<Void> deleteMessage(@PathVariable String messageId) {
|
||||||
|
|||||||
@@ -76,9 +76,9 @@ public class MessageController {
|
|||||||
public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository,
|
public MessageController(MqttPublisher mqttPublisher, AppUserRepository appUserRepository,
|
||||||
AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository,
|
AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository,
|
||||||
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
||||||
SignatureRepository signatureRepository, CommentRepository commentRepository, JobHistoryService jobHistoryService,
|
SignatureRepository signatureRepository, CommentRepository commentRepository,
|
||||||
EmailService emailService, MessageService messageService, ObjectMapper objectMapper,
|
JobHistoryService jobHistoryService, EmailService emailService, MessageService messageService,
|
||||||
ClientConnectionService clientConnectionService) {
|
ObjectMapper objectMapper, ClientConnectionService clientConnectionService) {
|
||||||
this.mqttPublisher = mqttPublisher;
|
this.mqttPublisher = mqttPublisher;
|
||||||
this.appUserRepository = appUserRepository;
|
this.appUserRepository = appUserRepository;
|
||||||
this.appUserService = appUserService;
|
this.appUserService = appUserService;
|
||||||
@@ -181,8 +181,8 @@ public class MessageController {
|
|||||||
List<Job> allJobs = jobRepository.findAll();
|
List<Job> allJobs = jobRepository.findAll();
|
||||||
log.info("DEBUG: Total jobs in database: {}", allJobs.size());
|
log.info("DEBUG: Total jobs in database: {}", allJobs.size());
|
||||||
for (Job job : allJobs) {
|
for (Job job : allJobs) {
|
||||||
log.info("DEBUG: Job {} (number: {}) has app_user='{}', digitalProcessing={}",
|
log.info("DEBUG: Job {} (number: {}) has app_user='{}', digitalProcessing={}", job.getIdAsString(),
|
||||||
job.getIdAsString(), job.getJobNumber(), job.getAppUser(), job.isDigitalProcessing());
|
job.getJobNumber(), job.getAppUser(), job.isDigitalProcessing());
|
||||||
}
|
}
|
||||||
|
|
||||||
// For each job, fetch related cargo items and tasks (ordered by task order)
|
// For each job, fetch related cargo items and tasks (ordered by task order)
|
||||||
@@ -304,7 +304,8 @@ public class MessageController {
|
|||||||
if (extra instanceof Map<?, ?> extraData) {
|
if (extra instanceof Map<?, ?> extraData) {
|
||||||
Object barcodesObj = extraData.get("barcodes");
|
Object barcodesObj = extraData.get("barcodes");
|
||||||
if (barcodesObj instanceof List<?> barcodesList) {
|
if (barcodesObj instanceof List<?> barcodesList) {
|
||||||
// Suppressing unchecked cast warning as extraData structure is validated from MQTT payload
|
// Suppressing unchecked cast warning as extraData structure is validated from
|
||||||
|
// MQTT payload
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
List<String> barcodes = (List<String>) barcodesList;
|
List<String> barcodes = (List<String>) barcodesList;
|
||||||
|
|
||||||
@@ -404,15 +405,15 @@ public class MessageController {
|
|||||||
if (extra instanceof Map<?, ?> extraData) {
|
if (extra instanceof Map<?, ?> extraData) {
|
||||||
Object photosObj = extraData.get("photos");
|
Object photosObj = extraData.get("photos");
|
||||||
if (photosObj instanceof List<?> photosList) {
|
if (photosObj instanceof List<?> photosList) {
|
||||||
// Suppressing unchecked cast warning as extraData structure is validated from MQTT payload
|
// Suppressing unchecked cast warning as extraData structure is validated from
|
||||||
|
// MQTT payload
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
List<String> photos = (List<String>) photosList;
|
List<String> photos = (List<String>) photosList;
|
||||||
|
|
||||||
if (!photos.isEmpty()) {
|
if (!photos.isEmpty()) {
|
||||||
for (String photoString : photos) {
|
for (String photoString : photos) {
|
||||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||||
Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString,
|
Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString, completedBy);
|
||||||
completedBy);
|
|
||||||
|
|
||||||
photoRepository.save(photoEntry);
|
photoRepository.save(photoEntry);
|
||||||
}
|
}
|
||||||
@@ -609,9 +610,8 @@ public class MessageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle pong response from a client.
|
* Handle pong response from a client. Client sends to /server/{clientId}/pong
|
||||||
* Client sends to /server/{clientId}/pong with payload { timestamp }.
|
* with payload { timestamp }. Used for connection monitoring.
|
||||||
* Used for connection monitoring.
|
|
||||||
*/
|
*/
|
||||||
public void handlePong(Map<String, Object> payload) {
|
public void handlePong(Map<String, Object> payload) {
|
||||||
String clientId = payload.get("clientId") != null ? payload.get("clientId").toString() : null;
|
String clientId = payload.get("clientId") != null ? payload.get("clientId").toString() : null;
|
||||||
@@ -625,14 +625,10 @@ public class MessageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming message from a client via MQTT.
|
* Handle incoming message from a client via MQTT. Client sends to
|
||||||
* Client sends to /server/{clientId}/message with payload:
|
* /server/{clientId}/message with payload: { "content": "message payload",
|
||||||
* {
|
* "contentType": "TEXT|IMAGE", "jobId": "optional job id", "jobNumber":
|
||||||
* "content": "message payload",
|
* "optional job number" }
|
||||||
* "contentType": "TEXT|IMAGE",
|
|
||||||
* "jobId": "optional job id",
|
|
||||||
* "jobNumber": "optional job number"
|
|
||||||
* }
|
|
||||||
*
|
*
|
||||||
* The clientId is extracted from the MQTT topic and represents the AppUser ID.
|
* The clientId is extracted from the MQTT topic and represents the AppUser ID.
|
||||||
* This clientId is stored as the receiver field in the message.
|
* This clientId is stored as the receiver field in the message.
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import org.bson.types.ObjectId;
|
|||||||
* Normalized payload for chat messages sent by mobile clients via MQTT.
|
* Normalized payload for chat messages sent by mobile clients via MQTT.
|
||||||
* receiver = AppUser ID (clientId) extracted from MQTT topic
|
* receiver = AppUser ID (clientId) extracted from MQTT topic
|
||||||
*/
|
*/
|
||||||
public record ChatMessageInboundPayload(String receiver, String content,
|
public record ChatMessageInboundPayload(String receiver, String content, MessageContentType contentType, ObjectId jobId,
|
||||||
MessageContentType contentType, ObjectId jobId, String jobNumber) {
|
String jobNumber) {
|
||||||
|
|
||||||
public ChatMessageInboundPayload {
|
public ChatMessageInboundPayload {
|
||||||
contentType = contentType != null ? contentType : MessageContentType.TEXT;
|
contentType = contentType != null ? contentType : MessageContentType.TEXT;
|
||||||
@@ -58,8 +58,7 @@ public record ChatMessageInboundPayload(String receiver, String content,
|
|||||||
try {
|
try {
|
||||||
return new ObjectId(candidate);
|
return new ObjectId(candidate);
|
||||||
} catch (IllegalArgumentException ex) {
|
} catch (IllegalArgumentException ex) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException("Field '%s' must be a valid MongoDB ObjectId".formatted(fieldName), ex);
|
||||||
"Field '%s' must be a valid MongoDB ObjectId".formatted(fieldName), ex);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,32 +7,16 @@ import de.assecutor.votianlt.model.MessageType;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outbound chat message payload published to MQTT subscribers.
|
* Outbound chat message payload published to MQTT subscribers. The receiver is
|
||||||
* The receiver is implicit from the MQTT topic (/client/{appUserId}/message)
|
* implicit from the MQTT topic (/client/{appUserId}/message)
|
||||||
*/
|
*/
|
||||||
public record ChatMessageOutboundPayload(
|
public record ChatMessageOutboundPayload(String messageId, String content, MessageContentType contentType,
|
||||||
String messageId,
|
MessageOrigin origin, MessageType messageType, LocalDateTime createdAt, String jobId, String jobNumber,
|
||||||
String content,
|
boolean read) {
|
||||||
MessageContentType contentType,
|
|
||||||
MessageOrigin origin,
|
|
||||||
MessageType messageType,
|
|
||||||
LocalDateTime createdAt,
|
|
||||||
String jobId,
|
|
||||||
String jobNumber,
|
|
||||||
boolean read
|
|
||||||
) {
|
|
||||||
|
|
||||||
public static ChatMessageOutboundPayload fromMessage(Message message) {
|
public static ChatMessageOutboundPayload fromMessage(Message message) {
|
||||||
return new ChatMessageOutboundPayload(
|
return new ChatMessageOutboundPayload(message.getIdAsString(), message.getContent(), message.getContentType(),
|
||||||
message.getIdAsString(),
|
message.getOrigin(), message.getMessageType(), message.getCreatedAt(), message.getJobIdAsString(),
|
||||||
message.getContent(),
|
message.getJobNumber(), message.isRead());
|
||||||
message.getContentType(),
|
|
||||||
message.getOrigin(),
|
|
||||||
message.getMessageType(),
|
|
||||||
message.getCreatedAt(),
|
|
||||||
message.getJobIdAsString(),
|
|
||||||
message.getJobNumber(),
|
|
||||||
message.isRead()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
|
|||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class ClientMessageSummary {
|
public class ClientMessageSummary {
|
||||||
|
|
||||||
private String clientId;
|
private String clientId;
|
||||||
private String clientName;
|
private String clientName;
|
||||||
private String clientEmail;
|
private String clientEmail;
|
||||||
|
|||||||
@@ -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,13 +3,12 @@ package de.assecutor.votianlt.event;
|
|||||||
import org.springframework.context.ApplicationEvent;
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event published when message read status changes (e.g., messages marked as read)
|
* Event published when message read status changes (e.g., messages marked as
|
||||||
* This allows UI components like the sidebar badge to update accordingly
|
* read) This allows UI components like the sidebar badge to update accordingly
|
||||||
*/
|
*/
|
||||||
public class MessageReadStatusChangedEvent extends ApplicationEvent {
|
public class MessageReadStatusChangedEvent extends ApplicationEvent {
|
||||||
|
|
||||||
public MessageReadStatusChangedEvent(Object source) {
|
public MessageReadStatusChangedEvent(Object source) {
|
||||||
super(source);
|
super(source);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import org.springframework.context.ApplicationEvent;
|
|||||||
* Event published when a new message is received from a client
|
* Event published when a new message is received from a client
|
||||||
*/
|
*/
|
||||||
public class MessageReceivedEvent extends ApplicationEvent {
|
public class MessageReceivedEvent extends ApplicationEvent {
|
||||||
|
|
||||||
private final Message message;
|
private final Message message;
|
||||||
|
|
||||||
public MessageReceivedEvent(Object source, Message message) {
|
public MessageReceivedEvent(Object source, Message message) {
|
||||||
super(source);
|
super(source);
|
||||||
this.message = message;
|
this.message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Message getMessage() {
|
public Message getMessage() {
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for the MCP (Model Context Protocol) server.
|
* Configuration for the MCP (Model Context Protocol) server. Registers all MCP
|
||||||
* Registers all MCP tools for job statistics and queries.
|
* tools for job statistics and queries.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ public class JobQueryTool {
|
|||||||
|
|
||||||
@Tool(description = "Query jobs with optional filters. Returns a list of jobs matching the criteria.")
|
@Tool(description = "Query jobs with optional filters. Returns a list of jobs matching the criteria.")
|
||||||
public List<JobQueryResult> queryJobs(
|
public List<JobQueryResult> queryJobs(
|
||||||
@ToolParam(description = "Optional: Job status filter (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)")
|
@ToolParam(description = "Optional: Job status filter (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)") String status,
|
||||||
String status,
|
|
||||||
@ToolParam(description = "Optional: Customer name filter") String customer,
|
@ToolParam(description = "Optional: Customer name filter") String customer,
|
||||||
@ToolParam(description = "Optional: Pickup city filter") String pickupCity,
|
@ToolParam(description = "Optional: Pickup city filter") String pickupCity,
|
||||||
@ToolParam(description = "Optional: Delivery city filter") String deliveryCity,
|
@ToolParam(description = "Optional: Delivery city filter") String deliveryCity,
|
||||||
@@ -52,10 +51,7 @@ public class JobQueryTool {
|
|||||||
jobs = statisticsService.getLatestJobs(actualLimit);
|
jobs = statisticsService.getLatestJobs(actualLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
return jobs.stream()
|
return jobs.stream().limit(actualLimit).map(this::toQueryResult).toList();
|
||||||
.limit(actualLimit)
|
|
||||||
.map(this::toQueryResult)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Tool(description = "Get detailed information about a specific job by its job number")
|
@Tool(description = "Get detailed information about a specific job by its job number")
|
||||||
@@ -71,13 +67,10 @@ public class JobQueryTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Tool(description = "Get jobs assigned to a specific mobile app user")
|
@Tool(description = "Get jobs assigned to a specific mobile app user")
|
||||||
public List<JobQueryResult> getJobsByAppUser(
|
public List<JobQueryResult> getJobsByAppUser(@ToolParam(description = "App user identifier") String appUser) {
|
||||||
@ToolParam(description = "App user identifier") String appUser) {
|
|
||||||
log.info("MCP Tool: Getting jobs for app user: {}", appUser);
|
log.info("MCP Tool: Getting jobs for app user: {}", appUser);
|
||||||
|
|
||||||
return statisticsService.getJobsByAppUser(appUser).stream()
|
return statisticsService.getJobsByAppUser(appUser).stream().map(this::toQueryResult).toList();
|
||||||
.map(this::toQueryResult)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Tool(description = "Get the most recent jobs, sorted by creation date descending")
|
@Tool(description = "Get the most recent jobs, sorted by creation date descending")
|
||||||
@@ -86,9 +79,7 @@ public class JobQueryTool {
|
|||||||
log.info("MCP Tool: Getting latest jobs, limit: {}", limit);
|
log.info("MCP Tool: Getting latest jobs, limit: {}", limit);
|
||||||
|
|
||||||
int actualLimit = limit != null ? limit : 10;
|
int actualLimit = limit != null ? limit : 10;
|
||||||
return statisticsService.getLatestJobs(actualLimit).stream()
|
return statisticsService.getLatestJobs(actualLimit).stream().map(this::toQueryResult).toList();
|
||||||
.map(this::toQueryResult)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Tool(description = "Get jobs created within a specific date range")
|
@Tool(description = "Get jobs created within a specific date range")
|
||||||
@@ -102,27 +93,17 @@ public class JobQueryTool {
|
|||||||
LocalDateTime end = LocalDateTime.parse(endDate);
|
LocalDateTime end = LocalDateTime.parse(endDate);
|
||||||
int actualLimit = limit != null ? limit : 100;
|
int actualLimit = limit != null ? limit : 100;
|
||||||
|
|
||||||
return statisticsService.getJobsByDateRange(start, end).stream()
|
return statisticsService.getJobsByDateRange(start, end).stream().limit(actualLimit).map(this::toQueryResult)
|
||||||
.limit(actualLimit)
|
|
||||||
.map(this::toQueryResult)
|
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private JobQueryResult toQueryResult(Job job) {
|
private JobQueryResult toQueryResult(Job job) {
|
||||||
return JobQueryResult.builder()
|
return JobQueryResult.builder().jobId(job.getIdAsString()).jobNumber(job.getJobNumber())
|
||||||
.jobId(job.getIdAsString())
|
|
||||||
.jobNumber(job.getJobNumber())
|
|
||||||
.status(job.getStatus() != null ? job.getStatus().name() : null)
|
.status(job.getStatus() != null ? job.getStatus().name() : null)
|
||||||
.statusDisplayName(job.getStatus() != null ? job.getStatus().getDisplayName() : null)
|
.statusDisplayName(job.getStatus() != null ? job.getStatus().getDisplayName() : null)
|
||||||
.customer(job.getCustomerSelection())
|
.customer(job.getCustomerSelection()).pickupCity(job.getPickupCity())
|
||||||
.pickupCity(job.getPickupCity())
|
.deliveryCity(job.getDeliveryCity()).pickupDate(job.getPickupDate()).deliveryDate(job.getDeliveryDate())
|
||||||
.deliveryCity(job.getDeliveryCity())
|
.price(job.getPrice()).createdAt(job.getCreatedAt()).assignedAppUser(job.getAppUser())
|
||||||
.pickupDate(job.getPickupDate())
|
.digitalProcessing(job.isDigitalProcessing()).build();
|
||||||
.deliveryDate(job.getDeliveryDate())
|
|
||||||
.price(job.getPrice())
|
|
||||||
.createdAt(job.getCreatedAt())
|
|
||||||
.assignedAppUser(job.getAppUser())
|
|
||||||
.digitalProcessing(job.isDigitalProcessing())
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import java.util.Map;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP Tool for job statistics queries.
|
* MCP Tool for job statistics queries. Provides various statistics and
|
||||||
* Provides various statistics and aggregations about jobs.
|
* aggregations about jobs.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -42,16 +42,10 @@ public class JobStatisticsTool {
|
|||||||
long cancelled = countsByStatus.getOrDefault(JobStatus.CANCELLED, 0L);
|
long cancelled = countsByStatus.getOrDefault(JobStatus.CANCELLED, 0L);
|
||||||
long inProgress = countsByStatus.getOrDefault(JobStatus.IN_PROGRESS, 0L);
|
long inProgress = countsByStatus.getOrDefault(JobStatus.IN_PROGRESS, 0L);
|
||||||
|
|
||||||
return JobStatisticsResult.builder()
|
return JobStatisticsResult.builder().countsByStatus(statusCounts)
|
||||||
.countsByStatus(statusCounts)
|
.totalJobs(statisticsService.getTotalJobCount()).completedJobs(completed).cancelledJobs(cancelled)
|
||||||
.totalJobs(statisticsService.getTotalJobCount())
|
.inProgressJobs(inProgress).completionRate(statisticsService.getCompletionRate())
|
||||||
.completedJobs(completed)
|
.totalRevenue(statisticsService.getTotalRevenue()).queryTimestamp(LocalDateTime.now()).build();
|
||||||
.cancelledJobs(cancelled)
|
|
||||||
.inProgressJobs(inProgress)
|
|
||||||
.completionRate(statisticsService.getCompletionRate())
|
|
||||||
.totalRevenue(statisticsService.getTotalRevenue())
|
|
||||||
.queryTimestamp(LocalDateTime.now())
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Tool(description = "Get job counts grouped by status (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)")
|
@Tool(description = "Get job counts grouped by status (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)")
|
||||||
@@ -59,10 +53,8 @@ public class JobStatisticsTool {
|
|||||||
log.info("MCP Tool: Getting job counts by status");
|
log.info("MCP Tool: Getting job counts by status");
|
||||||
|
|
||||||
Map<JobStatus, Long> counts = statisticsService.getJobCountsByStatus();
|
Map<JobStatus, Long> counts = statisticsService.getJobCountsByStatus();
|
||||||
return counts.entrySet().stream()
|
return counts.entrySet().stream().collect(Collectors
|
||||||
.collect(Collectors.toMap(
|
.toMap(e -> e.getKey().name() + " (" + e.getKey().getDisplayName() + ")", Map.Entry::getValue));
|
||||||
e -> e.getKey().name() + " (" + e.getKey().getDisplayName() + ")",
|
|
||||||
Map.Entry::getValue));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Tool(description = "Get the completion rate as a percentage (completed jobs / total jobs * 100)")
|
@Tool(description = "Get the completion rate as a percentage (completed jobs / total jobs * 100)")
|
||||||
@@ -79,17 +71,12 @@ public class JobStatisticsTool {
|
|||||||
log.info("MCP Tool: Getting revenue by customer, limit: {}", limit);
|
log.info("MCP Tool: Getting revenue by customer, limit: {}", limit);
|
||||||
|
|
||||||
int actualLimit = limit != null ? limit : 10;
|
int actualLimit = limit != null ? limit : 10;
|
||||||
return statisticsService.getTopCustomersByRevenue(actualLimit).stream()
|
return statisticsService.getTopCustomersByRevenue(actualLimit).stream().map(entry -> {
|
||||||
.map(entry -> {
|
String customer = entry.getKey();
|
||||||
String customer = entry.getKey();
|
long jobCount = statisticsService.getJobsByCustomer(customer).size();
|
||||||
long jobCount = statisticsService.getJobsByCustomer(customer).size();
|
return CustomerRevenueResult.builder().customer(customer).revenue(entry.getValue()).jobCount(jobCount)
|
||||||
return CustomerRevenueResult.builder()
|
.build();
|
||||||
.customer(customer)
|
}).toList();
|
||||||
.revenue(entry.getValue())
|
|
||||||
.jobCount(jobCount)
|
|
||||||
.build();
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Tool(description = "Get monthly job trend data for a specific year showing job counts per month")
|
@Tool(description = "Get monthly job trend data for a specific year showing job counts per month")
|
||||||
@@ -99,9 +86,7 @@ public class JobStatisticsTool {
|
|||||||
|
|
||||||
Map<Month, Long> monthlyData = statisticsService.getMonthlyJobCounts(year);
|
Map<Month, Long> monthlyData = statisticsService.getMonthlyJobCounts(year);
|
||||||
return monthlyData.entrySet().stream()
|
return monthlyData.entrySet().stream()
|
||||||
.collect(Collectors.toMap(
|
.collect(Collectors.toMap(e -> e.getKey().toString(), Map.Entry::getValue));
|
||||||
e -> e.getKey().toString(),
|
|
||||||
Map.Entry::getValue));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Tool(description = "Get total revenue from all jobs")
|
@Tool(description = "Get total revenue from all jobs")
|
||||||
|
|||||||
@@ -32,12 +32,8 @@ public class TaskCompletionTool {
|
|||||||
|
|
||||||
double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0;
|
double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0;
|
||||||
|
|
||||||
return TaskCompletionResult.builder()
|
return TaskCompletionResult.builder().totalTasks(total).completedTasks(completed).pendingTasks(pending)
|
||||||
.totalTasks(total)
|
.completionRate(completionRate).build();
|
||||||
.completedTasks(completed)
|
|
||||||
.pendingTasks(pending)
|
|
||||||
.completionRate(completionRate)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Tool(description = "Get a summary of task completion as a formatted string")
|
@Tool(description = "Get a summary of task completion as a formatted string")
|
||||||
@@ -51,8 +47,7 @@ public class TaskCompletionTool {
|
|||||||
|
|
||||||
double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0;
|
double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0;
|
||||||
|
|
||||||
return String.format(
|
return String.format("Task Statistics: %d total tasks, %d completed (%.1f%%), %d pending", total, completed,
|
||||||
"Task Statistics: %d total tasks, %d completed (%.1f%%), %d pending",
|
completionRate, pending);
|
||||||
total, completed, completionRate, pending);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for the plugin-based messaging system.
|
* Configuration for the plugin-based messaging system. Initializes the selected
|
||||||
* Initializes the selected plugin and sets up message routing.
|
* plugin and sets up message routing.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -54,8 +54,9 @@ public class PluginMessagingConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the messaging plugin after application startup.
|
* Initialize the messaging plugin after application startup. This method is
|
||||||
* This method is called after all beans are created, so we can safely access MessageDeliveryService.
|
* called after all beans are created, so we can safely access
|
||||||
|
* MessageDeliveryService.
|
||||||
*/
|
*/
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
public void initializePlugin(ApplicationReadyEvent event) {
|
public void initializePlugin(ApplicationReadyEvent event) {
|
||||||
@@ -66,9 +67,11 @@ public class PluginMessagingConfig {
|
|||||||
PluginConfig config = createPluginConfig(pluginType);
|
PluginConfig config = createPluginConfig(pluginType);
|
||||||
|
|
||||||
// Get beans from context (after all beans are created)
|
// Get beans from context (after all beans are created)
|
||||||
MessageDeliveryService deliveryService = event.getApplicationContext().getBean(MessageDeliveryService.class);
|
MessageDeliveryService deliveryService = event.getApplicationContext()
|
||||||
|
.getBean(MessageDeliveryService.class);
|
||||||
MessageController messageController = event.getApplicationContext().getBean(MessageController.class);
|
MessageController messageController = event.getApplicationContext().getBean(MessageController.class);
|
||||||
ClientConnectionService clientConnectionService = event.getApplicationContext().getBean(ClientConnectionService.class);
|
ClientConnectionService clientConnectionService = event.getApplicationContext()
|
||||||
|
.getBean(ClientConnectionService.class);
|
||||||
|
|
||||||
// Set up a listener to subscribe when connected
|
// Set up a listener to subscribe when connected
|
||||||
log.info("[PluginMessagingConfig] Adding state listener");
|
log.info("[PluginMessagingConfig] Adding state listener");
|
||||||
@@ -89,10 +92,12 @@ public class PluginMessagingConfig {
|
|||||||
});
|
});
|
||||||
log.info("[PluginMessagingConfig] State listener added");
|
log.info("[PluginMessagingConfig] State listener added");
|
||||||
|
|
||||||
// Activate plugin (this will trigger connection and eventually the listener above)
|
// Activate plugin (this will trigger connection and eventually the listener
|
||||||
|
// above)
|
||||||
pluginManager.activatePlugin(plugin, config);
|
pluginManager.activatePlugin(plugin, config);
|
||||||
|
|
||||||
log.info("[PluginMessagingConfig] Plugin activation initiated, subscriptions will be set up when connected");
|
log.info(
|
||||||
|
"[PluginMessagingConfig] Plugin activation initiated, subscriptions will be set up when connected");
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[PluginMessagingConfig] Failed to initialize plugin: {}", e.getMessage(), e);
|
log.error("[PluginMessagingConfig] Failed to initialize plugin: {}", e.getMessage(), e);
|
||||||
@@ -105,11 +110,11 @@ public class PluginMessagingConfig {
|
|||||||
*/
|
*/
|
||||||
private MessagingPlugin createPlugin(String type) {
|
private MessagingPlugin createPlugin(String type) {
|
||||||
return switch (type.toLowerCase()) {
|
return switch (type.toLowerCase()) {
|
||||||
case "mqtt" -> new MqttMessagingPlugin();
|
case "mqtt" -> new MqttMessagingPlugin();
|
||||||
// Add more plugin types here in the future
|
// Add more plugin types here in the future
|
||||||
// case "websocket" -> new WebSocketMessagingPlugin();
|
// case "websocket" -> new WebSocketMessagingPlugin();
|
||||||
// case "grpc" -> new GrpcMessagingPlugin();
|
// case "grpc" -> new GrpcMessagingPlugin();
|
||||||
default -> throw new IllegalArgumentException("Unknown plugin type: " + type);
|
default -> throw new IllegalArgumentException("Unknown plugin type: " + type);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,17 +125,17 @@ public class PluginMessagingConfig {
|
|||||||
PluginConfig config = new PluginConfig();
|
PluginConfig config = new PluginConfig();
|
||||||
|
|
||||||
switch (type.toLowerCase()) {
|
switch (type.toLowerCase()) {
|
||||||
case "mqtt" -> {
|
case "mqtt" -> {
|
||||||
config.setProperty("broker.host", mqttBrokerHost);
|
config.setProperty("broker.host", mqttBrokerHost);
|
||||||
config.setProperty("broker.port", mqttBrokerPort);
|
config.setProperty("broker.port", mqttBrokerPort);
|
||||||
config.setProperty("username", mqttUsername);
|
config.setProperty("username", mqttUsername);
|
||||||
config.setProperty("password", mqttPassword);
|
config.setProperty("password", mqttPassword);
|
||||||
config.setProperty("client.id", mqttClientId);
|
config.setProperty("client.id", mqttClientId);
|
||||||
config.setProperty("auto.reconnect", true);
|
config.setProperty("auto.reconnect", true);
|
||||||
config.setProperty("clean.start", true);
|
config.setProperty("clean.start", true);
|
||||||
}
|
}
|
||||||
// Add more plugin configurations here
|
// Add more plugin configurations here
|
||||||
default -> throw new IllegalArgumentException("Unknown plugin type: " + type);
|
default -> throw new IllegalArgumentException("Unknown plugin type: " + type);
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
@@ -139,9 +144,8 @@ public class PluginMessagingConfig {
|
|||||||
/**
|
/**
|
||||||
* Setup message subscriptions using the new plugin API.
|
* Setup message subscriptions using the new plugin API.
|
||||||
*/
|
*/
|
||||||
private void setupSubscriptions(MessageDeliveryService deliveryService,
|
private void setupSubscriptions(MessageDeliveryService deliveryService, MessageController messageController,
|
||||||
MessageController messageController,
|
ClientConnectionService clientConnectionService) {
|
||||||
ClientConnectionService clientConnectionService) {
|
|
||||||
log.info("[PluginMessagingConfig] Setting up message subscriptions");
|
log.info("[PluginMessagingConfig] Setting up message subscriptions");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -153,7 +157,8 @@ public class PluginMessagingConfig {
|
|||||||
|
|
||||||
// ACK messages are wrapped in MessageEnvelope
|
// ACK messages are wrapped in MessageEnvelope
|
||||||
MessageEnvelope envelope = objectMapper.readValue(json, MessageEnvelope.class);
|
MessageEnvelope envelope = objectMapper.readValue(json, MessageEnvelope.class);
|
||||||
AcknowledgmentMessage ack = objectMapper.convertValue(envelope.getPayload(), AcknowledgmentMessage.class);
|
AcknowledgmentMessage ack = objectMapper.convertValue(envelope.getPayload(),
|
||||||
|
AcknowledgmentMessage.class);
|
||||||
deliveryService.handleAcknowledgment(ack);
|
deliveryService.handleAcknowledgment(ack);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[PluginMessagingConfig] Error handling ACK message: {}", e.getMessage(), e);
|
log.error("[PluginMessagingConfig] Error handling ACK message: {}", e.getMessage(), e);
|
||||||
@@ -161,17 +166,12 @@ public class PluginMessagingConfig {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Register message handlers for different message types
|
// Register message handlers for different message types
|
||||||
String[] messageTypes = {
|
String[] messageTypes = { "task_completed", "jobs/assigned", "message", "login", "pong" };
|
||||||
"task_completed",
|
|
||||||
"jobs/assigned",
|
|
||||||
"message",
|
|
||||||
"login",
|
|
||||||
"pong"
|
|
||||||
};
|
|
||||||
|
|
||||||
for (String messageType : messageTypes) {
|
for (String messageType : messageTypes) {
|
||||||
pluginManager.registerMessageHandler(messageType, (clientId, payload) ->
|
pluginManager.registerMessageHandler(messageType,
|
||||||
handleEnvelopedMessage(clientId, payload, deliveryService, messageController, clientConnectionService));
|
(clientId, payload) -> handleEnvelopedMessage(clientId, payload, deliveryService,
|
||||||
|
messageController, clientConnectionService));
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("[PluginMessagingConfig] Message subscriptions initialized");
|
log.info("[PluginMessagingConfig] Message subscriptions initialized");
|
||||||
@@ -183,11 +183,11 @@ public class PluginMessagingConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming enveloped message.
|
* Handle incoming enveloped message. Supports both new envelope format and
|
||||||
* Supports both new envelope format and legacy format for backwards compatibility.
|
* legacy format for backwards compatibility.
|
||||||
*/
|
*/
|
||||||
private void handleEnvelopedMessage(String clientId, byte[] payload, MessageDeliveryService deliveryService,
|
private void handleEnvelopedMessage(String clientId, byte[] payload, MessageDeliveryService deliveryService,
|
||||||
MessageController messageController, ClientConnectionService clientConnectionService) {
|
MessageController messageController, ClientConnectionService clientConnectionService) {
|
||||||
try {
|
try {
|
||||||
String json = new String(payload, StandardCharsets.UTF_8);
|
String json = new String(payload, StandardCharsets.UTF_8);
|
||||||
log.info("[PluginMessagingConfig] Received JSON from client {}: {}", clientId, json);
|
log.info("[PluginMessagingConfig] Received JSON from client {}: {}", clientId, json);
|
||||||
@@ -214,12 +214,12 @@ public class PluginMessagingConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle legacy message format (without envelope wrapper).
|
* Handle legacy message format (without envelope wrapper). This supports older
|
||||||
* This supports older clients that don't use the envelope format.
|
* clients that don't use the envelope format.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private void handleLegacyMessage(String clientId, String json,
|
private void handleLegacyMessage(String clientId, String json, MessageController messageController,
|
||||||
MessageController messageController, ClientConnectionService clientConnectionService) {
|
ClientConnectionService clientConnectionService) {
|
||||||
try {
|
try {
|
||||||
Map<String, Object> payload = objectMapper.readValue(json, Map.class);
|
Map<String, Object> payload = objectMapper.readValue(json, Map.class);
|
||||||
log.info("[PluginMessagingConfig] Processing legacy message from client {}: {}", clientId, payload);
|
log.info("[PluginMessagingConfig] Processing legacy message from client {}: {}", clientId, payload);
|
||||||
@@ -263,8 +263,8 @@ public class PluginMessagingConfig {
|
|||||||
log.warn("[PluginMessagingConfig] Unknown legacy message format from client {}: {}", clientId, json);
|
log.warn("[PluginMessagingConfig] Unknown legacy message format from client {}: {}", clientId, json);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[PluginMessagingConfig] Error handling legacy message from client {}: {}", clientId, e.getMessage(), e);
|
log.error("[PluginMessagingConfig] Error handling legacy message from client {}: {}", clientId,
|
||||||
|
e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ public class AcknowledgmentHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route incoming message envelope to appropriate application handler.
|
* Route incoming message envelope to appropriate application handler. Unwraps
|
||||||
* Unwraps the envelope and delegates to MessageController.
|
* the envelope and delegates to MessageController.
|
||||||
*/
|
*/
|
||||||
public void routeIncomingMessage(MessageEnvelope envelope) {
|
public void routeIncomingMessage(MessageEnvelope envelope) {
|
||||||
try {
|
try {
|
||||||
@@ -39,8 +39,9 @@ public class AcknowledgmentHandler {
|
|||||||
log.debug("[AckHandler] Routing message {} on topic {}", envelope.getMessageId(), topic);
|
log.debug("[AckHandler] Routing message {} on topic {}", envelope.getMessageId(), topic);
|
||||||
|
|
||||||
// Convert payload to Map for routing
|
// Convert payload to Map for routing
|
||||||
Map<String, Object> payloadMap = objectMapper.convertValue(payload,
|
Map<String, Object> payloadMap = objectMapper.convertValue(payload,
|
||||||
new TypeReference<Map<String, Object>>() {});
|
new TypeReference<Map<String, Object>>() {
|
||||||
|
});
|
||||||
|
|
||||||
// Route based on topic pattern
|
// Route based on topic pattern
|
||||||
if (topic.matches("/server/.+/task_completed")) {
|
if (topic.matches("/server/.+/task_completed")) {
|
||||||
@@ -58,8 +59,7 @@ public class AcknowledgmentHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[AckHandler] Error routing message {}: {}",
|
log.error("[AckHandler] Error routing message {}: {}", envelope.getMessageId(), e.getMessage(), e);
|
||||||
envelope.getMessageId(), e.getMessage(), e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +96,7 @@ public class AcknowledgmentHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle login request
|
* Handle login request Topic: /server/login
|
||||||
* Topic: /server/login
|
|
||||||
*/
|
*/
|
||||||
private void handleLogin(Map<String, Object> payload) {
|
private void handleLogin(Map<String, Object> payload) {
|
||||||
try {
|
try {
|
||||||
@@ -146,4 +145,3 @@ public class AcknowledgmentHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,4 +59,3 @@ public class DeliveryConfig {
|
|||||||
*/
|
*/
|
||||||
private int acknowledgedRetentionDays = 7;
|
private int acknowledgedRetentionDays = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,28 +6,37 @@ import java.util.Optional;
|
|||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for reliable message delivery with acknowledgment tracking.
|
* Service for reliable message delivery with acknowledgment tracking. Provides
|
||||||
* Provides guaranteed delivery with retry mechanism and acknowledgment handling.
|
* guaranteed delivery with retry mechanism and acknowledgment handling.
|
||||||
*/
|
*/
|
||||||
public interface MessageDeliveryService {
|
public interface MessageDeliveryService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message to a specific client with delivery tracking and acknowledgment.
|
* Send a message to a specific client with delivery tracking and
|
||||||
|
* acknowledgment.
|
||||||
*
|
*
|
||||||
* @param clientId The target client identifier
|
* @param clientId
|
||||||
* @param messageType The type of message (e.g., "jobs", "message", "auth", "task")
|
* The target client identifier
|
||||||
* @param payload The message payload (will be serialized to JSON)
|
* @param messageType
|
||||||
* @param options Delivery options (retries, timeout, etc.)
|
* The type of message (e.g., "jobs", "message", "auth", "task")
|
||||||
|
* @param payload
|
||||||
|
* The message payload (will be serialized to JSON)
|
||||||
|
* @param options
|
||||||
|
* Delivery options (retries, timeout, etc.)
|
||||||
* @return CompletableFuture with delivery receipt
|
* @return CompletableFuture with delivery receipt
|
||||||
*/
|
*/
|
||||||
CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload, DeliveryOptions options);
|
CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload,
|
||||||
|
DeliveryOptions options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message to a specific client with default delivery options.
|
* Send a message to a specific client with default delivery options.
|
||||||
*
|
*
|
||||||
* @param clientId The target client identifier
|
* @param clientId
|
||||||
* @param messageType The type of message
|
* The target client identifier
|
||||||
* @param payload The message payload
|
* @param messageType
|
||||||
|
* The type of message
|
||||||
|
* @param payload
|
||||||
|
* The message payload
|
||||||
* @return CompletableFuture with delivery receipt
|
* @return CompletableFuture with delivery receipt
|
||||||
*/
|
*/
|
||||||
default CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload) {
|
default CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload) {
|
||||||
@@ -36,11 +45,17 @@ public interface MessageDeliveryService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message with delivery tracking and acknowledgment.
|
* Send a message with delivery tracking and acknowledgment.
|
||||||
* @deprecated Use {@link #sendToClient(String, String, Object, DeliveryOptions)} instead
|
*
|
||||||
|
* @deprecated Use
|
||||||
|
* {@link #sendToClient(String, String, Object, DeliveryOptions)}
|
||||||
|
* instead
|
||||||
*
|
*
|
||||||
* @param topic The destination topic
|
* @param topic
|
||||||
* @param payload The message payload (will be serialized to JSON)
|
* The destination topic
|
||||||
* @param options Delivery options (retries, timeout, etc.)
|
* @param payload
|
||||||
|
* The message payload (will be serialized to JSON)
|
||||||
|
* @param options
|
||||||
|
* Delivery options (retries, timeout, etc.)
|
||||||
* @return CompletableFuture with delivery receipt
|
* @return CompletableFuture with delivery receipt
|
||||||
*/
|
*/
|
||||||
@Deprecated
|
@Deprecated
|
||||||
@@ -48,10 +63,13 @@ public interface MessageDeliveryService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message with default delivery options.
|
* Send a message with default delivery options.
|
||||||
|
*
|
||||||
* @deprecated Use {@link #sendToClient(String, String, Object)} instead
|
* @deprecated Use {@link #sendToClient(String, String, Object)} instead
|
||||||
*
|
*
|
||||||
* @param topic The destination topic
|
* @param topic
|
||||||
* @param payload The message payload
|
* The destination topic
|
||||||
|
* @param payload
|
||||||
|
* The message payload
|
||||||
* @return CompletableFuture with delivery receipt
|
* @return CompletableFuture with delivery receipt
|
||||||
*/
|
*/
|
||||||
@Deprecated
|
@Deprecated
|
||||||
@@ -60,25 +78,28 @@ public interface MessageDeliveryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming message envelope from transport layer.
|
* Handle incoming message envelope from transport layer. Extracts payload and
|
||||||
* Extracts payload and routes to application layer.
|
* routes to application layer.
|
||||||
*
|
*
|
||||||
* @param envelope The received message envelope
|
* @param envelope
|
||||||
|
* The received message envelope
|
||||||
*/
|
*/
|
||||||
void handleIncomingMessage(MessageEnvelope envelope);
|
void handleIncomingMessage(MessageEnvelope envelope);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle acknowledgment from client.
|
* Handle acknowledgment from client. Updates delivery status and removes from
|
||||||
* Updates delivery status and removes from pending queue.
|
* pending queue.
|
||||||
*
|
*
|
||||||
* @param ack The acknowledgment message
|
* @param ack
|
||||||
|
* The acknowledgment message
|
||||||
*/
|
*/
|
||||||
void handleAcknowledgment(AcknowledgmentMessage ack);
|
void handleAcknowledgment(AcknowledgmentMessage ack);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current delivery status for a message.
|
* Get the current delivery status for a message.
|
||||||
*
|
*
|
||||||
* @param messageId The message ID
|
* @param messageId
|
||||||
|
* The message ID
|
||||||
* @return Optional containing the delivery status, or empty if not found
|
* @return Optional containing the delivery status, or empty if not found
|
||||||
*/
|
*/
|
||||||
Optional<DeliveryStatus> getDeliveryStatus(String messageId);
|
Optional<DeliveryStatus> getDeliveryStatus(String messageId);
|
||||||
@@ -86,29 +107,29 @@ public interface MessageDeliveryService {
|
|||||||
/**
|
/**
|
||||||
* Get detailed pending delivery information.
|
* Get detailed pending delivery information.
|
||||||
*
|
*
|
||||||
* @param messageId The message ID
|
* @param messageId
|
||||||
|
* The message ID
|
||||||
* @return Optional containing the pending delivery, or empty if not found
|
* @return Optional containing the pending delivery, or empty if not found
|
||||||
*/
|
*/
|
||||||
Optional<PendingDelivery> getPendingDelivery(String messageId);
|
Optional<PendingDelivery> getPendingDelivery(String messageId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retry all pending deliveries that are ready for retry.
|
* Retry all pending deliveries that are ready for retry. Called by scheduled
|
||||||
* Called by scheduled task.
|
* task.
|
||||||
*/
|
*/
|
||||||
void retryPendingDeliveries();
|
void retryPendingDeliveries();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retry pending deliveries for a specific client.
|
* Retry pending deliveries for a specific client. Called when a client
|
||||||
* Called when a client reconnects.
|
* reconnects.
|
||||||
*
|
*
|
||||||
* @param clientId The client identifier
|
* @param clientId
|
||||||
|
* The client identifier
|
||||||
*/
|
*/
|
||||||
void retryPendingDeliveriesForClient(String clientId);
|
void retryPendingDeliveriesForClient(String clientId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up expired and completed deliveries.
|
* Clean up expired and completed deliveries. Called by scheduled task.
|
||||||
* Called by scheduled task.
|
|
||||||
*/
|
*/
|
||||||
void cleanupOldDeliveries();
|
void cleanupOldDeliveries();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,12 +45,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
|||||||
|
|
||||||
private ScheduledExecutorService retryScheduler;
|
private ScheduledExecutorService retryScheduler;
|
||||||
|
|
||||||
public MessageDeliveryServiceImpl(
|
public MessageDeliveryServiceImpl(PluginManager pluginManager, PendingDeliveryRepository pendingDeliveryRepository,
|
||||||
PluginManager pluginManager,
|
AcknowledgmentHandler acknowledgmentHandler, DeliveryConfig config, ObjectMapper objectMapper,
|
||||||
PendingDeliveryRepository pendingDeliveryRepository,
|
|
||||||
AcknowledgmentHandler acknowledgmentHandler,
|
|
||||||
DeliveryConfig config,
|
|
||||||
ObjectMapper objectMapper,
|
|
||||||
ClientConnectionService clientConnectionService) {
|
ClientConnectionService clientConnectionService) {
|
||||||
this.pluginManager = pluginManager;
|
this.pluginManager = pluginManager;
|
||||||
this.pendingDeliveryRepository = pendingDeliveryRepository;
|
this.pendingDeliveryRepository = pendingDeliveryRepository;
|
||||||
@@ -67,14 +63,10 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
|||||||
t.setDaemon(true);
|
t.setDaemon(true);
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
retryScheduler.scheduleAtFixedRate(
|
retryScheduler.scheduleAtFixedRate(this::retryPendingDeliveries, ackRetryIntervalSeconds,
|
||||||
this::retryPendingDeliveries,
|
ackRetryIntervalSeconds, TimeUnit.SECONDS);
|
||||||
ackRetryIntervalSeconds,
|
log.info("[MessageDelivery] Started retry scheduler (interval: {}s, max retries: {})", ackRetryIntervalSeconds,
|
||||||
ackRetryIntervalSeconds,
|
ackMaxRetries);
|
||||||
TimeUnit.SECONDS
|
|
||||||
);
|
|
||||||
log.info("[MessageDelivery] Started retry scheduler (interval: {}s, max retries: {})",
|
|
||||||
ackRetryIntervalSeconds, ackMaxRetries);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
@@ -94,7 +86,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload, DeliveryOptions options) {
|
public CompletableFuture<DeliveryReceipt> sendToClient(String clientId, String messageType, Object payload,
|
||||||
|
DeliveryOptions options) {
|
||||||
try {
|
try {
|
||||||
String destination = clientId + "/" + messageType;
|
String destination = clientId + "/" + messageType;
|
||||||
final LocalDateTime expiresAt = options.calculateExpiryTime();
|
final LocalDateTime expiresAt = options.calculateExpiryTime();
|
||||||
@@ -105,19 +98,12 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
|||||||
byte[] envelopeData = json.getBytes(StandardCharsets.UTF_8);
|
byte[] envelopeData = json.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
if (options.isRequiresAck()) {
|
if (options.isRequiresAck()) {
|
||||||
PendingDelivery pending = new PendingDelivery(
|
PendingDelivery pending = new PendingDelivery(messageId, destination, envelopeData,
|
||||||
messageId,
|
options.getMaxRetries(), expiresAt);
|
||||||
destination,
|
|
||||||
envelopeData,
|
|
||||||
options.getMaxRetries(),
|
|
||||||
expiresAt
|
|
||||||
);
|
|
||||||
pendingDeliveryRepository.save(pending);
|
pendingDeliveryRepository.save(pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
SendOptions sendOptions = SendOptions.builder()
|
SendOptions sendOptions = SendOptions.builder().qos(options.getQos()).retained(options.isRetained())
|
||||||
.qos(options.getQos())
|
|
||||||
.retained(options.isRetained())
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
final boolean requiresAck = options.isRequiresAck();
|
final boolean requiresAck = options.isRequiresAck();
|
||||||
@@ -125,20 +111,18 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
|||||||
|
|
||||||
log.info("[MessageDelivery] Sending message {} to client {} (type: {})", messageId, clientId, messageType);
|
log.info("[MessageDelivery] Sending message {} to client {} (type: {})", messageId, clientId, messageType);
|
||||||
|
|
||||||
return pluginManager.sendToClient(clientId, messageType, envelopeData, sendOptions)
|
return pluginManager.sendToClient(clientId, messageType, envelopeData, sendOptions).thenApply(v -> {
|
||||||
.thenApply(v -> {
|
if (requiresAck) {
|
||||||
if (requiresAck) {
|
updatePendingDeliveryAfterSend(messageId, ackTimeout);
|
||||||
updatePendingDeliveryAfterSend(messageId, ackTimeout);
|
}
|
||||||
}
|
return DeliveryReceipt.submitted(messageId, destination, expiresAt);
|
||||||
return DeliveryReceipt.submitted(messageId, destination, expiresAt);
|
}).exceptionally(ex -> {
|
||||||
})
|
log.error("[MessageDelivery] Failed to send message {}: {}", messageId, ex.getMessage());
|
||||||
.exceptionally(ex -> {
|
if (requiresAck) {
|
||||||
log.error("[MessageDelivery] Failed to send message {}: {}", messageId, ex.getMessage());
|
markPendingDeliveryFailed(messageId, ex.getMessage());
|
||||||
if (requiresAck) {
|
}
|
||||||
markPendingDeliveryFailed(messageId, ex.getMessage());
|
return DeliveryReceipt.failed(messageId, destination);
|
||||||
}
|
});
|
||||||
return DeliveryReceipt.failed(messageId, destination);
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[MessageDelivery] Error creating message for client {}: {}", clientId, e.getMessage());
|
log.error("[MessageDelivery] Error creating message for client {}: {}", clientId, e.getMessage());
|
||||||
@@ -162,8 +146,7 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
|||||||
@Override
|
@Override
|
||||||
public void handleIncomingMessage(MessageEnvelope envelope) {
|
public void handleIncomingMessage(MessageEnvelope envelope) {
|
||||||
try {
|
try {
|
||||||
log.info("[MessageDelivery] Received message {} on topic {}",
|
log.info("[MessageDelivery] Received message {} on topic {}", envelope.getMessageId(), envelope.getTopic());
|
||||||
envelope.getMessageId(), envelope.getTopic());
|
|
||||||
|
|
||||||
if (envelope.isRequiresAck()) {
|
if (envelope.isRequiresAck()) {
|
||||||
sendAcknowledgment(envelope);
|
sendAcknowledgment(envelope);
|
||||||
@@ -172,16 +155,15 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
|||||||
acknowledgmentHandler.routeIncomingMessage(envelope);
|
acknowledgmentHandler.routeIncomingMessage(envelope);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[MessageDelivery] Error handling incoming message {}: {}",
|
log.error("[MessageDelivery] Error handling incoming message {}: {}", envelope.getMessageId(),
|
||||||
envelope.getMessageId(), e.getMessage());
|
e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void handleAcknowledgment(AcknowledgmentMessage ack) {
|
public void handleAcknowledgment(AcknowledgmentMessage ack) {
|
||||||
try {
|
try {
|
||||||
log.info("[MessageDelivery] Received ACK for message {} (status: {})",
|
log.info("[MessageDelivery] Received ACK for message {} (status: {})", ack.getMessageId(), ack.getStatus());
|
||||||
ack.getMessageId(), ack.getStatus());
|
|
||||||
|
|
||||||
Optional<PendingDelivery> pendingOpt = pendingDeliveryRepository.findByMessageId(ack.getMessageId());
|
Optional<PendingDelivery> pendingOpt = pendingDeliveryRepository.findByMessageId(ack.getMessageId());
|
||||||
|
|
||||||
@@ -192,27 +174,25 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
|||||||
PendingDelivery pending = pendingOpt.get();
|
PendingDelivery pending = pendingOpt.get();
|
||||||
|
|
||||||
switch (ack.getStatus()) {
|
switch (ack.getStatus()) {
|
||||||
case RECEIVED, PROCESSED -> {
|
case RECEIVED, PROCESSED -> {
|
||||||
pendingDeliveryRepository.delete(pending);
|
pendingDeliveryRepository.delete(pending);
|
||||||
}
|
}
|
||||||
case FAILED -> {
|
case FAILED -> {
|
||||||
pending.markAsFailed(ack.getErrorMessage());
|
pending.markAsFailed(ack.getErrorMessage());
|
||||||
pendingDeliveryRepository.save(pending);
|
pendingDeliveryRepository.save(pending);
|
||||||
log.warn("[MessageDelivery] Message {} failed on client: {}",
|
log.warn("[MessageDelivery] Message {} failed on client: {}", ack.getMessageId(),
|
||||||
ack.getMessageId(), ack.getErrorMessage());
|
ack.getErrorMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[MessageDelivery] Error handling ACK for message {}: {}",
|
log.error("[MessageDelivery] Error handling ACK for message {}: {}", ack.getMessageId(), e.getMessage());
|
||||||
ack.getMessageId(), e.getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<DeliveryStatus> getDeliveryStatus(String messageId) {
|
public Optional<DeliveryStatus> getDeliveryStatus(String messageId) {
|
||||||
return pendingDeliveryRepository.findByMessageId(messageId)
|
return pendingDeliveryRepository.findByMessageId(messageId).map(PendingDelivery::getStatus);
|
||||||
.map(PendingDelivery::getStatus);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -272,11 +252,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
|||||||
pendingDeliveryRepository.deleteAll(oldAcknowledged);
|
pendingDeliveryRepository.deleteAll(oldAcknowledged);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<PendingDelivery> expired = pendingDeliveryRepository
|
List<PendingDelivery> expired = pendingDeliveryRepository.findByStatusInAndExpiresAtBefore(
|
||||||
.findByStatusInAndExpiresAtBefore(
|
List.of(DeliveryStatus.PENDING, DeliveryStatus.SENT), LocalDateTime.now());
|
||||||
List.of(DeliveryStatus.PENDING, DeliveryStatus.SENT),
|
|
||||||
LocalDateTime.now()
|
|
||||||
);
|
|
||||||
|
|
||||||
for (PendingDelivery pending : expired) {
|
for (PendingDelivery pending : expired) {
|
||||||
pending.markAsExpired();
|
pending.markAsExpired();
|
||||||
@@ -352,17 +329,15 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
|||||||
pending.incrementRetryCount();
|
pending.incrementRetryCount();
|
||||||
|
|
||||||
SendOptions options = SendOptions.reliable();
|
SendOptions options = SendOptions.reliable();
|
||||||
pluginManager.sendToClient(clientId, messageType, pending.getEnvelopeData(), options)
|
pluginManager.sendToClient(clientId, messageType, pending.getEnvelopeData(), options).thenAccept(v -> {
|
||||||
.thenAccept(v -> {
|
pending.markAsSent(nextRetry);
|
||||||
pending.markAsSent(nextRetry);
|
pendingDeliveryRepository.save(pending);
|
||||||
pendingDeliveryRepository.save(pending);
|
}).exceptionally(ex -> {
|
||||||
})
|
log.error("[MessageDelivery] Retry failed for message {}: {}", pending.getMessageId(), ex.getMessage());
|
||||||
.exceptionally(ex -> {
|
pending.markAsFailed(ex.getMessage());
|
||||||
log.error("[MessageDelivery] Retry failed for message {}: {}", pending.getMessageId(), ex.getMessage());
|
pendingDeliveryRepository.save(pending);
|
||||||
pending.markAsFailed(ex.getMessage());
|
return null;
|
||||||
pendingDeliveryRepository.save(pending);
|
});
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[MessageDelivery] Error retrying delivery {}: {}", pending.getMessageId(), e.getMessage());
|
log.error("[MessageDelivery] Error retrying delivery {}: {}", pending.getMessageId(), e.getMessage());
|
||||||
@@ -376,11 +351,8 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AcknowledgmentMessage ack = new AcknowledgmentMessage(
|
AcknowledgmentMessage ack = new AcknowledgmentMessage(envelope.getMessageId(), AckStatus.RECEIVED,
|
||||||
envelope.getMessageId(),
|
"server");
|
||||||
AckStatus.RECEIVED,
|
|
||||||
"server"
|
|
||||||
);
|
|
||||||
|
|
||||||
String ackJson = objectMapper.writeValueAsString(ack);
|
String ackJson = objectMapper.writeValueAsString(ack);
|
||||||
byte[] ackData = ackJson.getBytes(StandardCharsets.UTF_8);
|
byte[] ackData = ackJson.getBytes(StandardCharsets.UTF_8);
|
||||||
@@ -389,12 +361,14 @@ public class MessageDeliveryServiceImpl implements MessageDeliveryService {
|
|||||||
|
|
||||||
pluginManager.sendAckToClient(clientId, envelope.getMessageId(), ackData, SendOptions.fireAndForget())
|
pluginManager.sendAckToClient(clientId, envelope.getMessageId(), ackData, SendOptions.fireAndForget())
|
||||||
.exceptionally(ex -> {
|
.exceptionally(ex -> {
|
||||||
log.error("[MessageDelivery] Failed to send ACK for message {}: {}", envelope.getMessageId(), ex.getMessage());
|
log.error("[MessageDelivery] Failed to send ACK for message {}: {}", envelope.getMessageId(),
|
||||||
|
ex.getMessage());
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[MessageDelivery] Error sending ACK for message {}: {}", envelope.getMessageId(), e.getMessage());
|
log.error("[MessageDelivery] Error sending ACK for message {}: {}", envelope.getMessageId(),
|
||||||
|
e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,4 +43,3 @@ public class RetryScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,14 @@ public enum AckStatus {
|
|||||||
* Message was received by the client
|
* Message was received by the client
|
||||||
*/
|
*/
|
||||||
RECEIVED,
|
RECEIVED,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message was successfully processed by the client
|
* Message was successfully processed by the client
|
||||||
*/
|
*/
|
||||||
PROCESSED,
|
PROCESSED,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message processing failed on the client side
|
* Message processing failed on the client side
|
||||||
*/
|
*/
|
||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,4 +60,3 @@ public class AcknowledgmentMessage {
|
|||||||
this.errorMessage = errorMessage;
|
this.errorMessage = errorMessage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,22 +71,14 @@ public class DeliveryOptions {
|
|||||||
* Options for fire-and-forget messages (no acknowledgment required)
|
* Options for fire-and-forget messages (no acknowledgment required)
|
||||||
*/
|
*/
|
||||||
public static DeliveryOptions fireAndForget() {
|
public static DeliveryOptions fireAndForget() {
|
||||||
return DeliveryOptions.builder()
|
return DeliveryOptions.builder().requiresAck(false).maxRetries(0).build();
|
||||||
.requiresAck(false)
|
|
||||||
.maxRetries(0)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for critical messages with extended retry
|
* Options for critical messages with extended retry
|
||||||
*/
|
*/
|
||||||
public static DeliveryOptions critical() {
|
public static DeliveryOptions critical() {
|
||||||
return DeliveryOptions.builder()
|
return DeliveryOptions.builder().requiresAck(true).maxRetries(5).ackTimeout(Duration.ofMinutes(2))
|
||||||
.requiresAck(true)
|
.expiryDuration(Duration.ofDays(7)).build();
|
||||||
.maxRetries(5)
|
|
||||||
.ackTimeout(Duration.ofMinutes(2))
|
|
||||||
.expiryDuration(Duration.ofDays(7))
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,26 +43,13 @@ public class DeliveryReceipt {
|
|||||||
* Create a receipt for a successfully submitted message
|
* Create a receipt for a successfully submitted message
|
||||||
*/
|
*/
|
||||||
public static DeliveryReceipt submitted(String messageId, String topic, LocalDateTime expiresAt) {
|
public static DeliveryReceipt submitted(String messageId, String topic, LocalDateTime expiresAt) {
|
||||||
return new DeliveryReceipt(
|
return new DeliveryReceipt(messageId, topic, LocalDateTime.now(), DeliveryStatus.PENDING, expiresAt);
|
||||||
messageId,
|
|
||||||
topic,
|
|
||||||
LocalDateTime.now(),
|
|
||||||
DeliveryStatus.PENDING,
|
|
||||||
expiresAt
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a receipt for a failed submission
|
* Create a receipt for a failed submission
|
||||||
*/
|
*/
|
||||||
public static DeliveryReceipt failed(String messageId, String topic) {
|
public static DeliveryReceipt failed(String messageId, String topic) {
|
||||||
return new DeliveryReceipt(
|
return new DeliveryReceipt(messageId, topic, LocalDateTime.now(), DeliveryStatus.FAILED, null);
|
||||||
messageId,
|
|
||||||
topic,
|
|
||||||
LocalDateTime.now(),
|
|
||||||
DeliveryStatus.FAILED,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,25 +8,24 @@ public enum DeliveryStatus {
|
|||||||
* Message is queued but not yet sent
|
* Message is queued but not yet sent
|
||||||
*/
|
*/
|
||||||
PENDING,
|
PENDING,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message has been sent to the transport layer
|
* Message has been sent to the transport layer
|
||||||
*/
|
*/
|
||||||
SENT,
|
SENT,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client has acknowledged receipt of the message
|
* Client has acknowledged receipt of the message
|
||||||
*/
|
*/
|
||||||
ACKNOWLEDGED,
|
ACKNOWLEDGED,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delivery failed after all retry attempts
|
* Delivery failed after all retry attempts
|
||||||
*/
|
*/
|
||||||
FAILED,
|
FAILED,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message expired before delivery could be confirmed
|
* Message expired before delivery could be confirmed
|
||||||
*/
|
*/
|
||||||
EXPIRED
|
EXPIRED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import java.util.Map;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Envelope that wraps all messages sent through the messaging system.
|
* Envelope that wraps all messages sent through the messaging system. Contains
|
||||||
* Contains metadata for delivery tracking and acknowledgment.
|
* metadata for delivery tracking and acknowledgment. This is a DTO class - not
|
||||||
* This is a DTO class - not persisted to MongoDB.
|
* persisted to MongoDB.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import org.springframework.data.mongodb.core.mapping.Field;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a message delivery that is pending acknowledgment.
|
* Represents a message delivery that is pending acknowledgment. Stored in
|
||||||
* Stored in MongoDB for retry and tracking purposes.
|
* MongoDB for retry and tracking purposes.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@@ -112,8 +112,8 @@ public class PendingDelivery {
|
|||||||
/**
|
/**
|
||||||
* Constructor for new pending delivery
|
* Constructor for new pending delivery
|
||||||
*/
|
*/
|
||||||
public PendingDelivery(String messageId, String topic, byte[] envelopeData,
|
public PendingDelivery(String messageId, String topic, byte[] envelopeData, int maxRetries,
|
||||||
int maxRetries, LocalDateTime expiresAt) {
|
LocalDateTime expiresAt) {
|
||||||
this.messageId = messageId;
|
this.messageId = messageId;
|
||||||
this.topic = topic;
|
this.topic = topic;
|
||||||
this.envelopeData = envelopeData;
|
this.envelopeData = envelopeData;
|
||||||
@@ -183,11 +183,8 @@ public class PendingDelivery {
|
|||||||
* Check if ready for retry
|
* Check if ready for retry
|
||||||
*/
|
*/
|
||||||
public boolean isReadyForRetry() {
|
public boolean isReadyForRetry() {
|
||||||
return status == DeliveryStatus.SENT
|
return status == DeliveryStatus.SENT && nextRetryAt != null && LocalDateTime.now().isAfter(nextRetryAt)
|
||||||
&& nextRetryAt != null
|
&& !hasReachedMaxRetries() && !isExpired();
|
||||||
&& LocalDateTime.now().isAfter(nextRetryAt)
|
|
||||||
&& !hasReachedMaxRetries()
|
|
||||||
&& !isExpired();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,4 +208,3 @@ public class PendingDelivery {
|
|||||||
return id != null ? id.toString() : null;
|
return id != null ? id.toString() : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,4 +105,3 @@ public class ConnectionStateEvent {
|
|||||||
return state == ConnectionState.ERROR;
|
return state == ConnectionState.ERROR;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package de.assecutor.votianlt.messaging.plugin;
|
|||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for messaging transport plugins.
|
* Interface for messaging transport plugins. Plugins implement specific
|
||||||
* Plugins implement specific transport protocols (MQTT, WebSocket, gRPC, etc.)
|
* transport protocols (MQTT, WebSocket, gRPC, etc.) and provide a unified
|
||||||
* and provide a unified interface for the messaging layer.
|
* interface for the messaging layer.
|
||||||
*
|
*
|
||||||
* The plugin is responsible for managing the internal topic/channel structure.
|
* The plugin is responsible for managing the internal topic/channel structure.
|
||||||
* The messaging layer only uses clientId and messageType as identifiers.
|
* The messaging layer only uses clientId and messageType as identifiers.
|
||||||
@@ -13,73 +13,95 @@ import java.util.concurrent.CompletableFuture;
|
|||||||
public interface MessagingPlugin {
|
public interface MessagingPlugin {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the plugin with configuration.
|
* Initialize the plugin with configuration. Called once during application
|
||||||
* Called once during application startup.
|
* startup.
|
||||||
*
|
*
|
||||||
* @param config Plugin-specific configuration
|
* @param config
|
||||||
* @throws PluginException if initialization fails
|
* Plugin-specific configuration
|
||||||
|
* @throws PluginException
|
||||||
|
* if initialization fails
|
||||||
*/
|
*/
|
||||||
void init(PluginConfig config) throws PluginException;
|
void init(PluginConfig config) throws PluginException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shutdown the plugin and release resources.
|
* Shutdown the plugin and release resources. Called during application
|
||||||
* Called during application shutdown.
|
* shutdown.
|
||||||
*
|
*
|
||||||
* @throws PluginException if shutdown fails
|
* @throws PluginException
|
||||||
|
* if shutdown fails
|
||||||
*/
|
*/
|
||||||
void exit() throws PluginException;
|
void exit() throws PluginException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback when connection state changes.
|
* Callback when connection state changes. The plugin should call this method
|
||||||
* The plugin should call this method when the underlying transport
|
* when the underlying transport connection state changes (connected,
|
||||||
* connection state changes (connected, disconnected, error).
|
* disconnected, error).
|
||||||
*
|
*
|
||||||
* @param listener Connection state listener
|
* @param listener
|
||||||
|
* Connection state listener
|
||||||
*/
|
*/
|
||||||
void setConnectionListener(ConnectionStateListener listener);
|
void setConnectionListener(ConnectionStateListener listener);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message to a specific client.
|
* Send a message to a specific client. The plugin is responsible for
|
||||||
* The plugin is responsible for determining the correct topic/channel based on the messageType.
|
* determining the correct topic/channel based on the messageType.
|
||||||
*
|
*
|
||||||
* @param clientId Target client identifier
|
* @param clientId
|
||||||
* @param messageType Type of message (e.g., "jobs", "message", "auth", "task")
|
* Target client identifier
|
||||||
* @param payload Message payload as byte array
|
* @param messageType
|
||||||
* @param options Transport-specific options
|
* Type of message (e.g., "jobs", "message", "auth", "task")
|
||||||
|
* @param payload
|
||||||
|
* Message payload as byte array
|
||||||
|
* @param options
|
||||||
|
* Transport-specific options
|
||||||
* @return CompletableFuture that completes when message is sent
|
* @return CompletableFuture that completes when message is sent
|
||||||
* @throws PluginException if sending fails
|
* @throws PluginException
|
||||||
|
* if sending fails
|
||||||
*/
|
*/
|
||||||
CompletableFuture<Void> sendToClient(String clientId, String messageType, byte[] payload, SendOptions options) throws PluginException;
|
CompletableFuture<Void> sendToClient(String clientId, String messageType, byte[] payload, SendOptions options)
|
||||||
|
throws PluginException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an acknowledgment to a specific client.
|
* Send an acknowledgment to a specific client. The plugin is responsible for
|
||||||
* The plugin is responsible for determining the correct ACK topic/channel.
|
* determining the correct ACK topic/channel.
|
||||||
*
|
*
|
||||||
* @param clientId Target client identifier
|
* @param clientId
|
||||||
* @param messageId Message ID being acknowledged
|
* Target client identifier
|
||||||
* @param payload ACK payload as byte array
|
* @param messageId
|
||||||
* @param options Transport-specific options
|
* Message ID being acknowledged
|
||||||
|
* @param payload
|
||||||
|
* ACK payload as byte array
|
||||||
|
* @param options
|
||||||
|
* Transport-specific options
|
||||||
* @return CompletableFuture that completes when ACK is sent
|
* @return CompletableFuture that completes when ACK is sent
|
||||||
* @throws PluginException if sending fails
|
* @throws PluginException
|
||||||
|
* if sending fails
|
||||||
*/
|
*/
|
||||||
CompletableFuture<Void> sendAckToClient(String clientId, String messageId, byte[] payload, SendOptions options) throws PluginException;
|
CompletableFuture<Void> sendAckToClient(String clientId, String messageId, byte[] payload, SendOptions options)
|
||||||
|
throws PluginException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a handler for incoming messages of a specific type from clients.
|
* Register a handler for incoming messages of a specific type from clients. The
|
||||||
* The plugin is responsible for subscribing to the appropriate topics/channels.
|
* plugin is responsible for subscribing to the appropriate topics/channels.
|
||||||
*
|
*
|
||||||
* @param messageType Type of message to handle (e.g., "task_completed", "message", "jobs/assigned", "login")
|
* @param messageType
|
||||||
* @param handler Message handler to be called when a message is received
|
* Type of message to handle (e.g., "task_completed", "message",
|
||||||
* @throws PluginException if registration fails
|
* "jobs/assigned", "login")
|
||||||
|
* @param handler
|
||||||
|
* Message handler to be called when a message is received
|
||||||
|
* @throws PluginException
|
||||||
|
* if registration fails
|
||||||
*/
|
*/
|
||||||
void registerMessageHandler(String messageType, ClientMessageHandler handler) throws PluginException;
|
void registerMessageHandler(String messageType, ClientMessageHandler handler) throws PluginException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a handler for incoming acknowledgments from clients.
|
* Register a handler for incoming acknowledgments from clients. The plugin is
|
||||||
* The plugin is responsible for subscribing to the appropriate ACK topics/channels.
|
* responsible for subscribing to the appropriate ACK topics/channels.
|
||||||
*
|
*
|
||||||
* @param handler ACK handler to be called when an ACK is received
|
* @param handler
|
||||||
* @throws PluginException if registration fails
|
* ACK handler to be called when an ACK is received
|
||||||
|
* @throws PluginException
|
||||||
|
* if registration fails
|
||||||
*/
|
*/
|
||||||
void registerAckHandler(AckHandler handler) throws PluginException;
|
void registerAckHandler(AckHandler handler) throws PluginException;
|
||||||
|
|
||||||
@@ -119,22 +141,25 @@ public interface MessagingPlugin {
|
|||||||
/**
|
/**
|
||||||
* Called when connection state changes.
|
* Called when connection state changes.
|
||||||
*
|
*
|
||||||
* @param event Connection state event
|
* @param event
|
||||||
|
* Connection state event
|
||||||
*/
|
*/
|
||||||
void onConnectionStateChanged(ConnectionStateEvent event);
|
void onConnectionStateChanged(ConnectionStateEvent event);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler for received messages from clients.
|
* Handler for received messages from clients. Includes the clientId extracted
|
||||||
* Includes the clientId extracted from the topic/channel.
|
* from the topic/channel.
|
||||||
*/
|
*/
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
interface ClientMessageHandler {
|
interface ClientMessageHandler {
|
||||||
/**
|
/**
|
||||||
* Called when a message is received from a client.
|
* Called when a message is received from a client.
|
||||||
*
|
*
|
||||||
* @param clientId Client identifier extracted from the topic/channel
|
* @param clientId
|
||||||
* @param payload Message payload as byte array
|
* Client identifier extracted from the topic/channel
|
||||||
|
* @param payload
|
||||||
|
* Message payload as byte array
|
||||||
*/
|
*/
|
||||||
void onMessageReceived(String clientId, byte[] payload);
|
void onMessageReceived(String clientId, byte[] payload);
|
||||||
}
|
}
|
||||||
@@ -147,10 +172,11 @@ public interface MessagingPlugin {
|
|||||||
/**
|
/**
|
||||||
* Called when an ACK is received from a client.
|
* Called when an ACK is received from a client.
|
||||||
*
|
*
|
||||||
* @param messageId Message ID being acknowledged
|
* @param messageId
|
||||||
* @param payload ACK payload as byte array
|
* Message ID being acknowledged
|
||||||
|
* @param payload
|
||||||
|
* ACK payload as byte array
|
||||||
*/
|
*/
|
||||||
void onAckReceived(String messageId, byte[] payload);
|
void onAckReceived(String messageId, byte[] payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import java.util.HashMap;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for messaging plugins.
|
* Configuration for messaging plugins. Provides a flexible key-value store for
|
||||||
* Provides a flexible key-value store for plugin-specific settings.
|
* plugin-specific settings.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -27,7 +27,8 @@ public class PluginConfig {
|
|||||||
/**
|
/**
|
||||||
* Get a string property.
|
* Get a string property.
|
||||||
*
|
*
|
||||||
* @param key Property key
|
* @param key
|
||||||
|
* Property key
|
||||||
* @return Property value or null if not found
|
* @return Property value or null if not found
|
||||||
*/
|
*/
|
||||||
public String getString(String key) {
|
public String getString(String key) {
|
||||||
@@ -38,8 +39,10 @@ public class PluginConfig {
|
|||||||
/**
|
/**
|
||||||
* Get a string property with default value.
|
* Get a string property with default value.
|
||||||
*
|
*
|
||||||
* @param key Property key
|
* @param key
|
||||||
* @param defaultValue Default value if property not found
|
* Property key
|
||||||
|
* @param defaultValue
|
||||||
|
* Default value if property not found
|
||||||
* @return Property value or default
|
* @return Property value or default
|
||||||
*/
|
*/
|
||||||
public String getString(String key, String defaultValue) {
|
public String getString(String key, String defaultValue) {
|
||||||
@@ -50,7 +53,8 @@ public class PluginConfig {
|
|||||||
/**
|
/**
|
||||||
* Get an integer property.
|
* Get an integer property.
|
||||||
*
|
*
|
||||||
* @param key Property key
|
* @param key
|
||||||
|
* Property key
|
||||||
* @return Property value or null if not found
|
* @return Property value or null if not found
|
||||||
*/
|
*/
|
||||||
public Integer getInt(String key) {
|
public Integer getInt(String key) {
|
||||||
@@ -70,8 +74,10 @@ public class PluginConfig {
|
|||||||
/**
|
/**
|
||||||
* Get an integer property with default value.
|
* Get an integer property with default value.
|
||||||
*
|
*
|
||||||
* @param key Property key
|
* @param key
|
||||||
* @param defaultValue Default value if property not found
|
* Property key
|
||||||
|
* @param defaultValue
|
||||||
|
* Default value if property not found
|
||||||
* @return Property value or default
|
* @return Property value or default
|
||||||
*/
|
*/
|
||||||
public int getInt(String key, int defaultValue) {
|
public int getInt(String key, int defaultValue) {
|
||||||
@@ -82,7 +88,8 @@ public class PluginConfig {
|
|||||||
/**
|
/**
|
||||||
* Get a boolean property.
|
* Get a boolean property.
|
||||||
*
|
*
|
||||||
* @param key Property key
|
* @param key
|
||||||
|
* Property key
|
||||||
* @return Property value or null if not found
|
* @return Property value or null if not found
|
||||||
*/
|
*/
|
||||||
public Boolean getBoolean(String key) {
|
public Boolean getBoolean(String key) {
|
||||||
@@ -98,8 +105,10 @@ public class PluginConfig {
|
|||||||
/**
|
/**
|
||||||
* Get a boolean property with default value.
|
* Get a boolean property with default value.
|
||||||
*
|
*
|
||||||
* @param key Property key
|
* @param key
|
||||||
* @param defaultValue Default value if property not found
|
* Property key
|
||||||
|
* @param defaultValue
|
||||||
|
* Default value if property not found
|
||||||
* @return Property value or default
|
* @return Property value or default
|
||||||
*/
|
*/
|
||||||
public boolean getBoolean(String key, boolean defaultValue) {
|
public boolean getBoolean(String key, boolean defaultValue) {
|
||||||
@@ -110,8 +119,10 @@ public class PluginConfig {
|
|||||||
/**
|
/**
|
||||||
* Set a property.
|
* Set a property.
|
||||||
*
|
*
|
||||||
* @param key Property key
|
* @param key
|
||||||
* @param value Property value
|
* Property key
|
||||||
|
* @param value
|
||||||
|
* Property value
|
||||||
*/
|
*/
|
||||||
public void setProperty(String key, Object value) {
|
public void setProperty(String key, Object value) {
|
||||||
properties.put(key, value);
|
properties.put(key, value);
|
||||||
@@ -120,11 +131,11 @@ public class PluginConfig {
|
|||||||
/**
|
/**
|
||||||
* Check if a property exists.
|
* Check if a property exists.
|
||||||
*
|
*
|
||||||
* @param key Property key
|
* @param key
|
||||||
|
* Property key
|
||||||
* @return true if property exists
|
* @return true if property exists
|
||||||
*/
|
*/
|
||||||
public boolean hasProperty(String key) {
|
public boolean hasProperty(String key) {
|
||||||
return properties.containsKey(key);
|
return properties.containsKey(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,4 +17,3 @@ public class PluginException extends Exception {
|
|||||||
super(cause);
|
super(cause);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import java.util.Optional;
|
|||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manager for messaging plugins.
|
* Manager for messaging plugins. Handles plugin lifecycle, registration, and
|
||||||
* Handles plugin lifecycle, registration, and delegation.
|
* delegation.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -24,9 +24,12 @@ public class PluginManager {
|
|||||||
/**
|
/**
|
||||||
* Initialize and activate a plugin.
|
* Initialize and activate a plugin.
|
||||||
*
|
*
|
||||||
* @param plugin Plugin to activate
|
* @param plugin
|
||||||
* @param config Plugin configuration
|
* Plugin to activate
|
||||||
* @throws PluginException if initialization fails
|
* @param config
|
||||||
|
* Plugin configuration
|
||||||
|
* @throws PluginException
|
||||||
|
* if initialization fails
|
||||||
*/
|
*/
|
||||||
public void activatePlugin(MessagingPlugin plugin, PluginConfig config) throws PluginException {
|
public void activatePlugin(MessagingPlugin plugin, PluginConfig config) throws PluginException {
|
||||||
log.info("[PluginManager] Activating plugin: {}", plugin.getPluginName());
|
log.info("[PluginManager] Activating plugin: {}", plugin.getPluginName());
|
||||||
@@ -43,11 +46,8 @@ public class PluginManager {
|
|||||||
|
|
||||||
// Set connection listener
|
// Set connection listener
|
||||||
plugin.setConnectionListener(event -> {
|
plugin.setConnectionListener(event -> {
|
||||||
String previousState = event.getPreviousState() != null
|
String previousState = event.getPreviousState() != null ? event.getPreviousState().toString() : "NONE";
|
||||||
? event.getPreviousState().toString()
|
log.info("[PluginManager] Connection state changed: {} -> {}", previousState, event.getState());
|
||||||
: "NONE";
|
|
||||||
log.info("[PluginManager] Connection state changed: {} -> {}",
|
|
||||||
previousState, event.getState());
|
|
||||||
connectionHistory.add(event);
|
connectionHistory.add(event);
|
||||||
notifyStateListeners(event);
|
notifyStateListeners(event);
|
||||||
});
|
});
|
||||||
@@ -56,8 +56,7 @@ public class PluginManager {
|
|||||||
plugin.init(config);
|
plugin.init(config);
|
||||||
activePlugin = plugin;
|
activePlugin = plugin;
|
||||||
|
|
||||||
log.info("[PluginManager] Plugin activated: {} v{}",
|
log.info("[PluginManager] Plugin activated: {} v{}", plugin.getPluginName(), plugin.getPluginVersion());
|
||||||
plugin.getPluginName(), plugin.getPluginVersion());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,14 +71,20 @@ public class PluginManager {
|
|||||||
/**
|
/**
|
||||||
* Send a message to a specific client via the active plugin.
|
* Send a message to a specific client via the active plugin.
|
||||||
*
|
*
|
||||||
* @param clientId Target client identifier
|
* @param clientId
|
||||||
* @param messageType Type of message (e.g., "jobs", "message", "auth", "task")
|
* Target client identifier
|
||||||
* @param payload Message payload
|
* @param messageType
|
||||||
* @param options Send options
|
* Type of message (e.g., "jobs", "message", "auth", "task")
|
||||||
|
* @param payload
|
||||||
|
* Message payload
|
||||||
|
* @param options
|
||||||
|
* Send options
|
||||||
* @return CompletableFuture that completes when message is sent
|
* @return CompletableFuture that completes when message is sent
|
||||||
* @throws PluginException if no plugin is active or sending fails
|
* @throws PluginException
|
||||||
|
* if no plugin is active or sending fails
|
||||||
*/
|
*/
|
||||||
public CompletableFuture<Void> sendToClient(String clientId, String messageType, byte[] payload, SendOptions options) throws PluginException {
|
public CompletableFuture<Void> sendToClient(String clientId, String messageType, byte[] payload,
|
||||||
|
SendOptions options) throws PluginException {
|
||||||
if (activePlugin == null) {
|
if (activePlugin == null) {
|
||||||
return CompletableFuture.failedFuture(new PluginException("No active plugin"));
|
return CompletableFuture.failedFuture(new PluginException("No active plugin"));
|
||||||
}
|
}
|
||||||
@@ -94,14 +99,20 @@ public class PluginManager {
|
|||||||
/**
|
/**
|
||||||
* Send an acknowledgment to a specific client via the active plugin.
|
* Send an acknowledgment to a specific client via the active plugin.
|
||||||
*
|
*
|
||||||
* @param clientId Target client identifier
|
* @param clientId
|
||||||
* @param messageId Message ID being acknowledged
|
* Target client identifier
|
||||||
* @param payload ACK payload
|
* @param messageId
|
||||||
* @param options Send options
|
* Message ID being acknowledged
|
||||||
|
* @param payload
|
||||||
|
* ACK payload
|
||||||
|
* @param options
|
||||||
|
* Send options
|
||||||
* @return CompletableFuture that completes when ACK is sent
|
* @return CompletableFuture that completes when ACK is sent
|
||||||
* @throws PluginException if no plugin is active or sending fails
|
* @throws PluginException
|
||||||
|
* if no plugin is active or sending fails
|
||||||
*/
|
*/
|
||||||
public CompletableFuture<Void> sendAckToClient(String clientId, String messageId, byte[] payload, SendOptions options) throws PluginException {
|
public CompletableFuture<Void> sendAckToClient(String clientId, String messageId, byte[] payload,
|
||||||
|
SendOptions options) throws PluginException {
|
||||||
if (activePlugin == null) {
|
if (activePlugin == null) {
|
||||||
return CompletableFuture.failedFuture(new PluginException("No active plugin"));
|
return CompletableFuture.failedFuture(new PluginException("No active plugin"));
|
||||||
}
|
}
|
||||||
@@ -116,11 +127,15 @@ public class PluginManager {
|
|||||||
/**
|
/**
|
||||||
* Register a handler for incoming messages of a specific type from clients.
|
* Register a handler for incoming messages of a specific type from clients.
|
||||||
*
|
*
|
||||||
* @param messageType Type of message to handle
|
* @param messageType
|
||||||
* @param handler Message handler
|
* Type of message to handle
|
||||||
* @throws PluginException if no plugin is active or registration fails
|
* @param handler
|
||||||
|
* Message handler
|
||||||
|
* @throws PluginException
|
||||||
|
* if no plugin is active or registration fails
|
||||||
*/
|
*/
|
||||||
public void registerMessageHandler(String messageType, MessagingPlugin.ClientMessageHandler handler) throws PluginException {
|
public void registerMessageHandler(String messageType, MessagingPlugin.ClientMessageHandler handler)
|
||||||
|
throws PluginException {
|
||||||
if (activePlugin == null) {
|
if (activePlugin == null) {
|
||||||
throw new PluginException("No active plugin");
|
throw new PluginException("No active plugin");
|
||||||
}
|
}
|
||||||
@@ -131,8 +146,10 @@ public class PluginManager {
|
|||||||
/**
|
/**
|
||||||
* Register a handler for incoming acknowledgments from clients.
|
* Register a handler for incoming acknowledgments from clients.
|
||||||
*
|
*
|
||||||
* @param handler ACK handler
|
* @param handler
|
||||||
* @throws PluginException if no plugin is active or registration fails
|
* ACK handler
|
||||||
|
* @throws PluginException
|
||||||
|
* if no plugin is active or registration fails
|
||||||
*/
|
*/
|
||||||
public void registerAckHandler(MessagingPlugin.AckHandler handler) throws PluginException {
|
public void registerAckHandler(MessagingPlugin.AckHandler handler) throws PluginException {
|
||||||
if (activePlugin == null) {
|
if (activePlugin == null) {
|
||||||
@@ -184,7 +201,8 @@ public class PluginManager {
|
|||||||
/**
|
/**
|
||||||
* Add a plugin state listener.
|
* Add a plugin state listener.
|
||||||
*
|
*
|
||||||
* @param listener State listener
|
* @param listener
|
||||||
|
* State listener
|
||||||
*/
|
*/
|
||||||
public void addStateListener(PluginStateListener listener) {
|
public void addStateListener(PluginStateListener listener) {
|
||||||
stateListeners.add(listener);
|
stateListeners.add(listener);
|
||||||
@@ -193,7 +211,8 @@ public class PluginManager {
|
|||||||
/**
|
/**
|
||||||
* Remove a plugin state listener.
|
* Remove a plugin state listener.
|
||||||
*
|
*
|
||||||
* @param listener State listener
|
* @param listener
|
||||||
|
* State listener
|
||||||
*/
|
*/
|
||||||
public void removeStateListener(PluginStateListener listener) {
|
public void removeStateListener(PluginStateListener listener) {
|
||||||
stateListeners.remove(listener);
|
stateListeners.remove(listener);
|
||||||
@@ -202,7 +221,8 @@ public class PluginManager {
|
|||||||
/**
|
/**
|
||||||
* Notify all state listeners of a connection state change.
|
* Notify all state listeners of a connection state change.
|
||||||
*
|
*
|
||||||
* @param event Connection state event
|
* @param event
|
||||||
|
* Connection state event
|
||||||
*/
|
*/
|
||||||
private void notifyStateListeners(ConnectionStateEvent event) {
|
private void notifyStateListeners(ConnectionStateEvent event) {
|
||||||
for (PluginStateListener listener : stateListeners) {
|
for (PluginStateListener listener : stateListeners) {
|
||||||
@@ -243,9 +263,9 @@ public class PluginManager {
|
|||||||
/**
|
/**
|
||||||
* Called when plugin connection state changes.
|
* Called when plugin connection state changes.
|
||||||
*
|
*
|
||||||
* @param event Connection state event
|
* @param event
|
||||||
|
* Connection state event
|
||||||
*/
|
*/
|
||||||
void onConnectionStateChanged(ConnectionStateEvent event);
|
void onConnectionStateChanged(ConnectionStateEvent event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ public class PluginMetadata {
|
|||||||
/**
|
/**
|
||||||
* Check if a feature is supported.
|
* Check if a feature is supported.
|
||||||
*
|
*
|
||||||
* @param feature Feature name
|
* @param feature
|
||||||
|
* Feature name
|
||||||
* @return true if supported
|
* @return true if supported
|
||||||
*/
|
*/
|
||||||
public boolean supportsFeature(String feature) {
|
public boolean supportsFeature(String feature) {
|
||||||
@@ -80,7 +81,8 @@ public class PluginMetadata {
|
|||||||
/**
|
/**
|
||||||
* Add a supported feature.
|
* Add a supported feature.
|
||||||
*
|
*
|
||||||
* @param feature Feature name
|
* @param feature
|
||||||
|
* Feature name
|
||||||
*/
|
*/
|
||||||
public void addSupportedFeature(String feature) {
|
public void addSupportedFeature(String feature) {
|
||||||
if (!supportedFeatures.contains(feature)) {
|
if (!supportedFeatures.contains(feature)) {
|
||||||
@@ -88,4 +90,3 @@ public class PluginMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ public class ReceivedMessage {
|
|||||||
/**
|
/**
|
||||||
* Get metadata value.
|
* Get metadata value.
|
||||||
*
|
*
|
||||||
* @param key Metadata key
|
* @param key
|
||||||
|
* Metadata key
|
||||||
* @return Metadata value or null
|
* @return Metadata value or null
|
||||||
*/
|
*/
|
||||||
public Object getMetadata(String key) {
|
public Object getMetadata(String key) {
|
||||||
@@ -63,8 +64,10 @@ public class ReceivedMessage {
|
|||||||
/**
|
/**
|
||||||
* Set metadata value.
|
* Set metadata value.
|
||||||
*
|
*
|
||||||
* @param key Metadata key
|
* @param key
|
||||||
* @param value Metadata value
|
* Metadata key
|
||||||
|
* @param value
|
||||||
|
* Metadata value
|
||||||
*/
|
*/
|
||||||
public void setMetadata(String key, Object value) {
|
public void setMetadata(String key, Object value) {
|
||||||
metadata.put(key, value);
|
metadata.put(key, value);
|
||||||
@@ -79,4 +82,3 @@ public class ReceivedMessage {
|
|||||||
return payload != null ? new String(payload, java.nio.charset.StandardCharsets.UTF_8) : null;
|
return payload != null ? new String(payload, java.nio.charset.StandardCharsets.UTF_8) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import java.util.HashMap;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for sending messages via plugins.
|
* Options for sending messages via plugins. Provides transport-agnostic options
|
||||||
* Provides transport-agnostic options with extensibility for plugin-specific settings.
|
* with extensibility for plugin-specific settings.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -50,7 +50,8 @@ public class SendOptions {
|
|||||||
/**
|
/**
|
||||||
* Get an additional option.
|
* Get an additional option.
|
||||||
*
|
*
|
||||||
* @param key Option key
|
* @param key
|
||||||
|
* Option key
|
||||||
* @return Option value or null
|
* @return Option value or null
|
||||||
*/
|
*/
|
||||||
public Object getAdditionalOption(String key) {
|
public Object getAdditionalOption(String key) {
|
||||||
@@ -60,8 +61,10 @@ public class SendOptions {
|
|||||||
/**
|
/**
|
||||||
* Set an additional option.
|
* Set an additional option.
|
||||||
*
|
*
|
||||||
* @param key Option key
|
* @param key
|
||||||
* @param value Option value
|
* Option key
|
||||||
|
* @param value
|
||||||
|
* Option value
|
||||||
*/
|
*/
|
||||||
public void setAdditionalOption(String key, Object value) {
|
public void setAdditionalOption(String key, Object value) {
|
||||||
additionalOptions.put(key, value);
|
additionalOptions.put(key, value);
|
||||||
@@ -82,10 +85,7 @@ public class SendOptions {
|
|||||||
* @return Fire-and-forget options
|
* @return Fire-and-forget options
|
||||||
*/
|
*/
|
||||||
public static SendOptions fireAndForget() {
|
public static SendOptions fireAndForget() {
|
||||||
return SendOptions.builder()
|
return SendOptions.builder().qos(0).retained(false).build();
|
||||||
.qos(0)
|
|
||||||
.retained(false)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,10 +94,6 @@ public class SendOptions {
|
|||||||
* @return Reliable delivery options
|
* @return Reliable delivery options
|
||||||
*/
|
*/
|
||||||
public static SendOptions reliable() {
|
public static SendOptions reliable() {
|
||||||
return SendOptions.builder()
|
return SendOptions.builder().qos(2).retained(false).build();
|
||||||
.qos(2)
|
|
||||||
.retained(false)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ import java.util.concurrent.CompletableFuture;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MQTT implementation of the MessagingPlugin interface.
|
* MQTT implementation of the MessagingPlugin interface. Uses HiveMQ MQTT 5
|
||||||
* Uses HiveMQ MQTT 5 client for communication.
|
* client for communication.
|
||||||
*
|
*
|
||||||
* Topic Structure (managed internally):
|
* Topic Structure (managed internally): - Server -> Client:
|
||||||
* - Server -> Client: /client/{clientId}/{messageType}
|
* /client/{clientId}/{messageType} - Client -> Server:
|
||||||
* - Client -> Server: /server/{clientId}/{messageType}
|
* /server/{clientId}/{messageType} - ACK Server -> Client:
|
||||||
* - ACK Server -> Client: /client/{clientId}/ack (messageId in payload)
|
* /client/{clientId}/ack (messageId in payload) - ACK Client -> Server:
|
||||||
* - ACK Client -> Server: /server/{clientId}/ack (messageId in payload)
|
* /server/{clientId}/ack (messageId in payload)
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class MqttMessagingPlugin implements MessagingPlugin {
|
public class MqttMessagingPlugin implements MessagingPlugin {
|
||||||
@@ -31,12 +31,12 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
private static final String PLUGIN_VERSION = "2.0.0";
|
private static final String PLUGIN_VERSION = "2.0.0";
|
||||||
|
|
||||||
// Topic templates
|
// Topic templates
|
||||||
private static final String TOPIC_TO_CLIENT = "/client/%s/%s"; // /client/{clientId}/{messageType}
|
private static final String TOPIC_TO_CLIENT = "/client/%s/%s"; // /client/{clientId}/{messageType}
|
||||||
private static final String TOPIC_ACK_TO_CLIENT = "/client/%s/ack"; // /client/{clientId}/ack (messageId in payload)
|
private static final String TOPIC_ACK_TO_CLIENT = "/client/%s/ack"; // /client/{clientId}/ack (messageId in payload)
|
||||||
|
|
||||||
// Subscription patterns
|
// Subscription patterns
|
||||||
private static final String PATTERN_FROM_CLIENT = "/server/+/%s"; // /server/+/{messageType}
|
private static final String PATTERN_FROM_CLIENT = "/server/+/%s"; // /server/+/{messageType}
|
||||||
private static final String PATTERN_ACK_FROM_CLIENT = "/server/+/ack"; // /server/+/ack
|
private static final String PATTERN_ACK_FROM_CLIENT = "/server/+/ack"; // /server/+/ack
|
||||||
|
|
||||||
private Mqtt5AsyncClient mqttClient;
|
private Mqtt5AsyncClient mqttClient;
|
||||||
private ConnectionStateListener connectionListener;
|
private ConnectionStateListener connectionListener;
|
||||||
@@ -71,31 +71,22 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
int connectionTimeout = config.getInt(CONFIG_CONNECTION_TIMEOUT, 60);
|
int connectionTimeout = config.getInt(CONFIG_CONNECTION_TIMEOUT, 60);
|
||||||
int keepAlive = config.getInt(CONFIG_KEEP_ALIVE, 60);
|
int keepAlive = config.getInt(CONFIG_KEEP_ALIVE, 60);
|
||||||
|
|
||||||
log.info("[MqttPlugin] Connecting to {}:{} with clientId: {} (timeout: {}s, keepAlive: {}s)",
|
log.info("[MqttPlugin] Connecting to {}:{} with clientId: {} (timeout: {}s, keepAlive: {}s)", brokerHost,
|
||||||
brokerHost, brokerPort, clientId, connectionTimeout, keepAlive);
|
brokerPort, clientId, connectionTimeout, keepAlive);
|
||||||
|
|
||||||
// Build MQTT client
|
// Build MQTT client
|
||||||
var clientBuilder = MqttClient.builder()
|
var clientBuilder = MqttClient.builder().useMqttVersion5().identifier(clientId).serverHost(brokerHost)
|
||||||
.useMqttVersion5()
|
.serverPort(brokerPort).automaticReconnect().initialDelay(1, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
.identifier(clientId)
|
.maxDelay(30, java.util.concurrent.TimeUnit.SECONDS).applyAutomaticReconnect();
|
||||||
.serverHost(brokerHost)
|
|
||||||
.serverPort(brokerPort)
|
|
||||||
.automaticReconnect()
|
|
||||||
.initialDelay(1, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.maxDelay(30, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.applyAutomaticReconnect();
|
|
||||||
|
|
||||||
mqttClient = clientBuilder.buildAsync();
|
mqttClient = clientBuilder.buildAsync();
|
||||||
|
|
||||||
// Build connect options
|
// Build connect options
|
||||||
var connectBuilder = com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5Connect.builder()
|
var connectBuilder = com.hivemq.client.mqtt.mqtt5.message.connect.Mqtt5Connect.builder()
|
||||||
.cleanStart(cleanStart)
|
.cleanStart(cleanStart).keepAlive(keepAlive);
|
||||||
.keepAlive(keepAlive);
|
|
||||||
|
|
||||||
if (username != null && password != null) {
|
if (username != null && password != null) {
|
||||||
connectBuilder.simpleAuth()
|
connectBuilder.simpleAuth().username(username).password(password.getBytes(StandardCharsets.UTF_8))
|
||||||
.username(username)
|
|
||||||
.password(password.getBytes(StandardCharsets.UTF_8))
|
|
||||||
.applySimpleAuth();
|
.applySimpleAuth();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,17 +99,19 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
.orTimeout(connectionTimeout, java.util.concurrent.TimeUnit.SECONDS)
|
.orTimeout(connectionTimeout, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
.whenComplete((connAck, throwable) -> {
|
.whenComplete((connAck, throwable) -> {
|
||||||
if (throwable != null) {
|
if (throwable != null) {
|
||||||
String errorMsg = String.format("Connection to %s:%d failed: %s",
|
String errorMsg = String.format("Connection to %s:%d failed: %s", brokerHost, brokerPort,
|
||||||
brokerHost, brokerPort, throwable.getMessage());
|
throwable.getMessage());
|
||||||
log.error("[MqttPlugin] {}", errorMsg, throwable);
|
log.error("[MqttPlugin] {}", errorMsg, throwable);
|
||||||
|
|
||||||
// Check for specific error types
|
// Check for specific error types
|
||||||
if (throwable instanceof java.util.concurrent.TimeoutException) {
|
if (throwable instanceof java.util.concurrent.TimeoutException) {
|
||||||
log.error("[MqttPlugin] Connection timeout - broker may be unreachable or firewall blocking connection");
|
log.error(
|
||||||
|
"[MqttPlugin] Connection timeout - broker may be unreachable or firewall blocking connection");
|
||||||
} else if (throwable.getCause() instanceof java.net.UnknownHostException) {
|
} else if (throwable.getCause() instanceof java.net.UnknownHostException) {
|
||||||
log.error("[MqttPlugin] Unknown host - DNS resolution failed for {}", brokerHost);
|
log.error("[MqttPlugin] Unknown host - DNS resolution failed for {}", brokerHost);
|
||||||
} else if (throwable.getCause() instanceof java.net.ConnectException) {
|
} else if (throwable.getCause() instanceof java.net.ConnectException) {
|
||||||
log.error("[MqttPlugin] Connection refused - broker may be down or port {} is blocked", brokerPort);
|
log.error("[MqttPlugin] Connection refused - broker may be down or port {} is blocked",
|
||||||
|
brokerPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
connected = false;
|
connected = false;
|
||||||
@@ -185,7 +178,8 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CompletableFuture<Void> sendToClient(String clientId, String messageType, byte[] payload, SendOptions options) throws PluginException {
|
public CompletableFuture<Void> sendToClient(String clientId, String messageType, byte[] payload,
|
||||||
|
SendOptions options) throws PluginException {
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
return CompletableFuture.failedFuture(new PluginException("MQTT client is not connected"));
|
return CompletableFuture.failedFuture(new PluginException("MQTT client is not connected"));
|
||||||
}
|
}
|
||||||
@@ -197,7 +191,8 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CompletableFuture<Void> sendAckToClient(String clientId, String messageId, byte[] payload, SendOptions options) throws PluginException {
|
public CompletableFuture<Void> sendAckToClient(String clientId, String messageId, byte[] payload,
|
||||||
|
SendOptions options) throws PluginException {
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
return CompletableFuture.failedFuture(new PluginException("MQTT client is not connected"));
|
return CompletableFuture.failedFuture(new PluginException("MQTT client is not connected"));
|
||||||
}
|
}
|
||||||
@@ -221,10 +216,7 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
String loginTopic = "/server/login";
|
String loginTopic = "/server/login";
|
||||||
log.info("[MqttPlugin] Registering handler for message type '{}' with topic: {}", messageType, loginTopic);
|
log.info("[MqttPlugin] Registering handler for message type '{}' with topic: {}", messageType, loginTopic);
|
||||||
|
|
||||||
mqttClient.subscribeWith()
|
mqttClient.subscribeWith().topicFilter(loginTopic).qos(MqttQos.EXACTLY_ONCE).send()
|
||||||
.topicFilter(loginTopic)
|
|
||||||
.qos(MqttQos.EXACTLY_ONCE)
|
|
||||||
.send()
|
|
||||||
.whenComplete((subAck, throwable) -> {
|
.whenComplete((subAck, throwable) -> {
|
||||||
if (throwable != null) {
|
if (throwable != null) {
|
||||||
log.error("[MqttPlugin] Subscription to {} failed: {}", loginTopic, throwable.getMessage());
|
log.error("[MqttPlugin] Subscription to {} failed: {}", loginTopic, throwable.getMessage());
|
||||||
@@ -236,15 +228,14 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
} else {
|
} else {
|
||||||
// Standard pattern: /server/+/{messageType}
|
// Standard pattern: /server/+/{messageType}
|
||||||
String topicPattern = String.format(PATTERN_FROM_CLIENT, messageType);
|
String topicPattern = String.format(PATTERN_FROM_CLIENT, messageType);
|
||||||
log.info("[MqttPlugin] Registering handler for message type '{}' with pattern: {}", messageType, topicPattern);
|
log.info("[MqttPlugin] Registering handler for message type '{}' with pattern: {}", messageType,
|
||||||
|
topicPattern);
|
||||||
|
|
||||||
mqttClient.subscribeWith()
|
mqttClient.subscribeWith().topicFilter(topicPattern).qos(MqttQos.EXACTLY_ONCE).send()
|
||||||
.topicFilter(topicPattern)
|
|
||||||
.qos(MqttQos.EXACTLY_ONCE)
|
|
||||||
.send()
|
|
||||||
.whenComplete((subAck, throwable) -> {
|
.whenComplete((subAck, throwable) -> {
|
||||||
if (throwable != null) {
|
if (throwable != null) {
|
||||||
log.error("[MqttPlugin] Subscription to {} failed: {}", topicPattern, throwable.getMessage());
|
log.error("[MqttPlugin] Subscription to {} failed: {}", topicPattern,
|
||||||
|
throwable.getMessage());
|
||||||
messageHandlers.remove(messageType);
|
messageHandlers.remove(messageType);
|
||||||
} else {
|
} else {
|
||||||
log.info("[MqttPlugin] Successfully subscribed to: {}", topicPattern);
|
log.info("[MqttPlugin] Successfully subscribed to: {}", topicPattern);
|
||||||
@@ -264,13 +255,11 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
this.ackHandler = handler;
|
this.ackHandler = handler;
|
||||||
|
|
||||||
// Subscribe to ACK topic pattern
|
// Subscribe to ACK topic pattern
|
||||||
mqttClient.subscribeWith()
|
mqttClient.subscribeWith().topicFilter(PATTERN_ACK_FROM_CLIENT).qos(MqttQos.EXACTLY_ONCE).send()
|
||||||
.topicFilter(PATTERN_ACK_FROM_CLIENT)
|
|
||||||
.qos(MqttQos.EXACTLY_ONCE)
|
|
||||||
.send()
|
|
||||||
.whenComplete((subAck, throwable) -> {
|
.whenComplete((subAck, throwable) -> {
|
||||||
if (throwable != null) {
|
if (throwable != null) {
|
||||||
log.error("[MqttPlugin] Subscription to {} failed: {}", PATTERN_ACK_FROM_CLIENT, throwable.getMessage());
|
log.error("[MqttPlugin] Subscription to {} failed: {}", PATTERN_ACK_FROM_CLIENT,
|
||||||
|
throwable.getMessage());
|
||||||
this.ackHandler = null;
|
this.ackHandler = null;
|
||||||
} else {
|
} else {
|
||||||
log.info("[MqttPlugin] Successfully subscribed to: {}", PATTERN_ACK_FROM_CLIENT);
|
log.info("[MqttPlugin] Successfully subscribed to: {}", PATTERN_ACK_FROM_CLIENT);
|
||||||
@@ -295,19 +284,14 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PluginMetadata getMetadata() {
|
public PluginMetadata getMetadata() {
|
||||||
return PluginMetadata.builder()
|
return PluginMetadata.builder().name(PLUGIN_NAME).version(PLUGIN_VERSION)
|
||||||
.name(PLUGIN_NAME)
|
.description("MQTT v5 messaging plugin using HiveMQ client").supportsWildcards(true)
|
||||||
.version(PLUGIN_VERSION)
|
.supportsRetainedMessages(true).supportsQos(true).maxQosLevel(2).build();
|
||||||
.description("MQTT v5 messaging plugin using HiveMQ client")
|
|
||||||
.supportsWildcards(true)
|
|
||||||
.supportsRetainedMessages(true)
|
|
||||||
.supportsQos(true)
|
|
||||||
.maxQosLevel(2)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup global message handler to route incoming messages to registered handlers.
|
* Setup global message handler to route incoming messages to registered
|
||||||
|
* handlers.
|
||||||
*/
|
*/
|
||||||
private void setupGlobalMessageHandler() {
|
private void setupGlobalMessageHandler() {
|
||||||
mqttClient.publishes(com.hivemq.client.mqtt.MqttGlobalPublishFilter.ALL, publish -> {
|
mqttClient.publishes(com.hivemq.client.mqtt.MqttGlobalPublishFilter.ALL, publish -> {
|
||||||
@@ -334,8 +318,7 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
// Check if it's a client message
|
// Check if it's a client message
|
||||||
else if (topic.startsWith("/server/")) {
|
else if (topic.startsWith("/server/")) {
|
||||||
handleClientMessage(topic, payload);
|
handleClientMessage(topic, payload);
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
log.warn("[MqttPlugin] Received message on unexpected topic: {}", topic);
|
log.warn("[MqttPlugin] Received message on unexpected topic: {}", topic);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -343,12 +326,9 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle ACK message from client.
|
* Handle ACK message from client. Topic format: /server/{clientId}/ack
|
||||||
* Topic format: /server/{clientId}/ack (messageId in payload)
|
* (messageId in payload)
|
||||||
*/
|
*/
|
||||||
private void handleAckMessage(String topic, byte[] payload) {
|
private void handleAckMessage(String topic, byte[] payload) {
|
||||||
if (ackHandler == null) {
|
if (ackHandler == null) {
|
||||||
@@ -359,7 +339,7 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
// Extract clientId from topic: /server/{clientId}/ack
|
// Extract clientId from topic: /server/{clientId}/ack
|
||||||
String[] parts = topic.split("/");
|
String[] parts = topic.split("/");
|
||||||
if (parts.length >= 4) {
|
if (parts.length >= 4) {
|
||||||
String clientId = parts[2]; // clientId is at index 2
|
String clientId = parts[2]; // clientId is at index 2
|
||||||
|
|
||||||
// Extract messageId from payload
|
// Extract messageId from payload
|
||||||
String payloadStr = new String(payload, StandardCharsets.UTF_8);
|
String payloadStr = new String(payload, StandardCharsets.UTF_8);
|
||||||
@@ -377,9 +357,8 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract messageId from ACK payload.
|
* Extract messageId from ACK payload. Expected payload format: JSON with
|
||||||
* Expected payload format: JSON with "messageId" field, e.g., {"messageId": "abc-123"}
|
* "messageId" field, e.g., {"messageId": "abc-123"} or plain messageId string.
|
||||||
* or plain messageId string.
|
|
||||||
*/
|
*/
|
||||||
private String extractMessageIdFromPayload(String payload) {
|
private String extractMessageIdFromPayload(String payload) {
|
||||||
if (payload == null || payload.isBlank()) {
|
if (payload == null || payload.isBlank()) {
|
||||||
@@ -418,9 +397,9 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle client message.
|
* Handle client message. Topic format: /server/{clientId}/{messageType} or
|
||||||
* Topic format: /server/{clientId}/{messageType} or /server/{messageType} (for login)
|
* /server/{messageType} (for login) messageType can contain slashes, e.g.,
|
||||||
* messageType can contain slashes, e.g., "jobs/assigned"
|
* "jobs/assigned"
|
||||||
*/
|
*/
|
||||||
private void handleClientMessage(String topic, byte[] payload) {
|
private void handleClientMessage(String topic, byte[] payload) {
|
||||||
// Extract clientId and messageType from topic
|
// Extract clientId and messageType from topic
|
||||||
@@ -463,17 +442,13 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
*/
|
*/
|
||||||
private CompletableFuture<Void> sendToTopic(String topic, byte[] payload, SendOptions options) {
|
private CompletableFuture<Void> sendToTopic(String topic, byte[] payload, SendOptions options) {
|
||||||
try {
|
try {
|
||||||
var publishBuilder = Mqtt5Publish.builder()
|
var publishBuilder = Mqtt5Publish.builder().topic(topic).payload(payload).qos(mapQos(options.getQos()))
|
||||||
.topic(topic)
|
|
||||||
.payload(payload)
|
|
||||||
.qos(mapQos(options.getQos()))
|
|
||||||
.retain(options.isRetained());
|
.retain(options.isRetained());
|
||||||
|
|
||||||
return mqttClient.publish(publishBuilder.build())
|
return mqttClient.publish(publishBuilder.build()).thenApply(publishResult -> {
|
||||||
.thenApply(publishResult -> {
|
log.debug("[MqttPlugin] Message published to topic: {}", topic);
|
||||||
log.debug("[MqttPlugin] Message published to topic: {}", topic);
|
return null;
|
||||||
return null;
|
});
|
||||||
});
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[MqttPlugin] Failed to publish to topic {}: {}", topic, e.getMessage(), e);
|
log.error("[MqttPlugin] Failed to publish to topic {}: {}", topic, e.getMessage(), e);
|
||||||
return CompletableFuture.failedFuture(new PluginException("Failed to publish message", e));
|
return CompletableFuture.failedFuture(new PluginException("Failed to publish message", e));
|
||||||
@@ -485,10 +460,10 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
*/
|
*/
|
||||||
private MqttQos mapQos(int qos) {
|
private MqttQos mapQos(int qos) {
|
||||||
return switch (qos) {
|
return switch (qos) {
|
||||||
case 0 -> MqttQos.AT_MOST_ONCE;
|
case 0 -> MqttQos.AT_MOST_ONCE;
|
||||||
case 1 -> MqttQos.AT_LEAST_ONCE;
|
case 1 -> MqttQos.AT_LEAST_ONCE;
|
||||||
case 2 -> MqttQos.EXACTLY_ONCE;
|
case 2 -> MqttQos.EXACTLY_ONCE;
|
||||||
default -> MqttQos.AT_LEAST_ONCE;
|
default -> MqttQos.AT_LEAST_ONCE;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,14 +471,11 @@ public class MqttMessagingPlugin implements MessagingPlugin {
|
|||||||
* Notify connection state listener.
|
* Notify connection state listener.
|
||||||
*/
|
*/
|
||||||
private void notifyConnectionState(ConnectionState state, String message) {
|
private void notifyConnectionState(ConnectionState state, String message) {
|
||||||
log.debug("[MqttPlugin] notifyConnectionState called: state={}, listener={}", state, connectionListener != null ? "present" : "null");
|
log.debug("[MqttPlugin] notifyConnectionState called: state={}, listener={}", state,
|
||||||
|
connectionListener != null ? "present" : "null");
|
||||||
if (connectionListener != null) {
|
if (connectionListener != null) {
|
||||||
ConnectionStateEvent event = ConnectionStateEvent.builder()
|
ConnectionStateEvent event = ConnectionStateEvent.builder().state(state).previousState(null)
|
||||||
.state(state)
|
.errorMessage(message).pluginName(PLUGIN_NAME).build();
|
||||||
.previousState(null)
|
|
||||||
.errorMessage(message)
|
|
||||||
.pluginName(PLUGIN_NAME)
|
|
||||||
.build();
|
|
||||||
try {
|
try {
|
||||||
log.debug("[MqttPlugin] Calling connectionListener.onConnectionStateChanged");
|
log.debug("[MqttPlugin] Calling connectionListener.onConnectionStateChanged");
|
||||||
connectionListener.onConnectionStateChanged(event);
|
connectionListener.onConnectionStateChanged(event);
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ public class AppUser {
|
|||||||
@Field("geraet")
|
@Field("geraet")
|
||||||
private String geraet;
|
private String geraet;
|
||||||
|
|
||||||
|
|
||||||
@Field("owner")
|
@Field("owner")
|
||||||
private ObjectId owner;
|
private ObjectId owner;
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ public class Message {
|
|||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Origin of the message: INCOMING (from client), OUTGOING (to client), or SERVER (from server)
|
* Origin of the message: INCOMING (from client), OUTGOING (to client), or
|
||||||
|
* SERVER (from server)
|
||||||
*/
|
*/
|
||||||
@Field("origin")
|
@Field("origin")
|
||||||
private MessageOrigin origin;
|
private MessageOrigin origin;
|
||||||
@@ -94,8 +95,7 @@ public class Message {
|
|||||||
/**
|
/**
|
||||||
* Constructor for general messages with explicit content type
|
* Constructor for general messages with explicit content type
|
||||||
*/
|
*/
|
||||||
public Message(String content, String receiver, MessageOrigin origin,
|
public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType) {
|
||||||
MessageContentType contentType) {
|
|
||||||
initializeBaseFields(content, receiver, origin, contentType);
|
initializeBaseFields(content, receiver, origin, contentType);
|
||||||
this.messageType = MessageType.GENERAL;
|
this.messageType = MessageType.GENERAL;
|
||||||
}
|
}
|
||||||
@@ -103,16 +103,15 @@ public class Message {
|
|||||||
/**
|
/**
|
||||||
* Constructor for job-related messages
|
* Constructor for job-related messages
|
||||||
*/
|
*/
|
||||||
public Message(String content, String receiver, MessageOrigin origin,
|
public Message(String content, String receiver, MessageOrigin origin, ObjectId jobId, String jobNumber) {
|
||||||
ObjectId jobId, String jobNumber) {
|
|
||||||
this(content, receiver, origin, MessageContentType.TEXT, jobId, jobNumber);
|
this(content, receiver, origin, MessageContentType.TEXT, jobId, jobNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor for job-related messages with explicit content type
|
* Constructor for job-related messages with explicit content type
|
||||||
*/
|
*/
|
||||||
public Message(String content, String receiver, MessageOrigin origin,
|
public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType,
|
||||||
MessageContentType contentType, ObjectId jobId, String jobNumber) {
|
ObjectId jobId, String jobNumber) {
|
||||||
initializeBaseFields(content, receiver, origin, contentType);
|
initializeBaseFields(content, receiver, origin, contentType);
|
||||||
this.messageType = MessageType.JOB_RELATED;
|
this.messageType = MessageType.JOB_RELATED;
|
||||||
this.jobId = jobId;
|
this.jobId = jobId;
|
||||||
|
|||||||
@@ -4,6 +4,5 @@ package de.assecutor.votianlt.model;
|
|||||||
* Supported content variants for chat messages.
|
* Supported content variants for chat messages.
|
||||||
*/
|
*/
|
||||||
public enum MessageContentType {
|
public enum MessageContentType {
|
||||||
TEXT,
|
TEXT, IMAGE
|
||||||
IMAGE
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public enum MessageOrigin {
|
|||||||
* Message received from a client (app user)
|
* Message received from a client (app user)
|
||||||
*/
|
*/
|
||||||
CLIENT,
|
CLIENT,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message sent from the server
|
* Message sent from the server
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public enum MessageType {
|
|||||||
* General message not related to a specific job
|
* General message not related to a specific job
|
||||||
*/
|
*/
|
||||||
GENERAL,
|
GENERAL,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message related to a specific job
|
* Message related to a specific job
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ public class PriceTable {
|
|||||||
public PriceTable() {
|
public PriceTable() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation, String statisticalEvaluation) {
|
public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation,
|
||||||
|
String statisticalEvaluation) {
|
||||||
this.monthlyBasePackage = monthlyBasePackage;
|
this.monthlyBasePackage = monthlyBasePackage;
|
||||||
this.appUsageLicense = appUsageLicense;
|
this.appUsageLicense = appUsageLicense;
|
||||||
this.revenueParticipation = revenueParticipation;
|
this.revenueParticipation = revenueParticipation;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public class CustomerInvoice {
|
|||||||
private String id;
|
private String id;
|
||||||
|
|
||||||
// Pflichtangaben nach §14 UStG (German VAT law)
|
// Pflichtangaben nach §14 UStG (German VAT law)
|
||||||
private String invoiceNumber; // Fortlaufende Rechnungsnummer
|
private String invoiceNumber; // Fortlaufende Rechnungsnummer
|
||||||
private LocalDate invoiceDate; // Rechnungsdatum
|
private LocalDate invoiceDate; // Rechnungsdatum
|
||||||
private LocalDate deliveryDate; // Leistungsdatum
|
private LocalDate deliveryDate; // Leistungsdatum
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ public class CustomerInvoice {
|
|||||||
private String senderCity;
|
private String senderCity;
|
||||||
private String senderCountry;
|
private String senderCountry;
|
||||||
private String senderTaxNumber; // Steuernummer
|
private String senderTaxNumber; // Steuernummer
|
||||||
private String senderVatId; // USt-IdNr.
|
private String senderVatId; // USt-IdNr.
|
||||||
private String senderPhone;
|
private String senderPhone;
|
||||||
private String senderEmail;
|
private String senderEmail;
|
||||||
private String senderWebsite;
|
private String senderWebsite;
|
||||||
@@ -43,20 +43,20 @@ public class CustomerInvoice {
|
|||||||
private List<CustomerInvoiceItem> items;
|
private List<CustomerInvoiceItem> items;
|
||||||
|
|
||||||
// Beträge
|
// Beträge
|
||||||
private BigDecimal netAmount; // Nettobetrag
|
private BigDecimal netAmount; // Nettobetrag
|
||||||
private BigDecimal vatRate; // Steuersatz (z.B. 19% = 0.19)
|
private BigDecimal vatRate; // Steuersatz (z.B. 19% = 0.19)
|
||||||
private BigDecimal vatAmount; // Steuerbetrag
|
private BigDecimal vatAmount; // Steuerbetrag
|
||||||
private BigDecimal totalAmount; // Bruttobetrag
|
private BigDecimal totalAmount; // Bruttobetrag
|
||||||
|
|
||||||
// Zahlungsdetails
|
// Zahlungsdetails
|
||||||
private String paymentTerms; // Zahlungsbedingungen
|
private String paymentTerms; // Zahlungsbedingungen
|
||||||
private LocalDate paymentDueDate; // Fälligkeitsdatum
|
private LocalDate paymentDueDate; // Fälligkeitsdatum
|
||||||
private String bankAccount; // Bankverbindung
|
private String bankAccount; // Bankverbindung
|
||||||
private String iban;
|
private String iban;
|
||||||
private String bic;
|
private String bic;
|
||||||
|
|
||||||
// Zusätzliche rechtliche Angaben
|
// Zusätzliche rechtliche Angaben
|
||||||
private String legalNotes; // Rechtliche Hinweise
|
private String legalNotes; // Rechtliche Hinweise
|
||||||
private String reverseChargeNote; // Hinweis auf Reverse Charge (falls zutreffend)
|
private String reverseChargeNote; // Hinweis auf Reverse Charge (falls zutreffend)
|
||||||
|
|
||||||
// Constructors
|
// Constructors
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ public class CustomerInvoiceData {
|
|||||||
// Number formatter for German locale
|
// Number formatter for German locale
|
||||||
private static final NumberFormat CURRENCY_FORMAT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
|
private static final NumberFormat CURRENCY_FORMAT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
|
||||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||||
|
|
||||||
// Constructors
|
// Constructors
|
||||||
public CustomerInvoiceData() {
|
public CustomerInvoiceData() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ import java.math.BigDecimal;
|
|||||||
public class CustomerInvoiceItem {
|
public class CustomerInvoiceItem {
|
||||||
|
|
||||||
private BigDecimal quantity;
|
private BigDecimal quantity;
|
||||||
private String unit; // Einheit (Stk., h, kg, etc.)
|
private String unit; // Einheit (Stk., h, kg, etc.)
|
||||||
private String description;
|
private String description;
|
||||||
private BigDecimal unitPrice; // Einzelpreis netto
|
private BigDecimal unitPrice; // Einzelpreis netto
|
||||||
private BigDecimal netTotal; // Gesamtpreis netto
|
private BigDecimal netTotal; // Gesamtpreis netto
|
||||||
private BigDecimal vatRate; // Steuersatz
|
private BigDecimal vatRate; // Steuersatz
|
||||||
private BigDecimal vatAmount; // Steuerbetrag
|
private BigDecimal vatAmount; // Steuerbetrag
|
||||||
private BigDecimal grossTotal; // Gesamtpreis brutto
|
private BigDecimal grossTotal; // Gesamtpreis brutto
|
||||||
|
|
||||||
// Constructors
|
// Constructors
|
||||||
public CustomerInvoiceItem() {
|
public CustomerInvoiceItem() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public CustomerInvoiceItem(BigDecimal quantity, String unit, String description, BigDecimal unitPrice, BigDecimal vatRate) {
|
public CustomerInvoiceItem(BigDecimal quantity, String unit, String description, BigDecimal unitPrice,
|
||||||
|
BigDecimal vatRate) {
|
||||||
this.quantity = quantity;
|
this.quantity = quantity;
|
||||||
this.unit = unit;
|
this.unit = unit;
|
||||||
this.description = description;
|
this.description = description;
|
||||||
|
|||||||
@@ -30,79 +30,194 @@ public class SystemInvoiceData {
|
|||||||
|
|
||||||
private String paymentTerms = "Zahlungsbedingungen: Gesamtbetrag bis spätestens zum 10. Werktag nach Rechnungserhalt auf unser u. g. Konto.";
|
private String paymentTerms = "Zahlungsbedingungen: Gesamtbetrag bis spätestens zum 10. Werktag nach Rechnungserhalt auf unser u. g. Konto.";
|
||||||
|
|
||||||
private String footerText = "Geschäftsführer: Carsten Annacker, Halstenbek · Gunnar Timm, Geesthacht<br>" +
|
private String footerText = "Geschäftsführer: Carsten Annacker, Halstenbek · Gunnar Timm, Geesthacht<br>"
|
||||||
"Steuernummer: 22 294 53099 · USt-IdNr.: DE261094748 · Sitz: Geesthacht · Handelsregister: Lübeck HRB 8595<br>" +
|
+ "Steuernummer: 22 294 53099 · USt-IdNr.: DE261094748 · Sitz: Geesthacht · Handelsregister: Lübeck HRB 8595<br>"
|
||||||
"Bankverbindung: Hamburger Sparkasse · IBAN DE67200505501217139888 · BIC HASPDEHHXXX";
|
+ "Bankverbindung: Hamburger Sparkasse · IBAN DE67200505501217139888 · BIC HASPDEHHXXX";
|
||||||
|
|
||||||
public SystemInvoiceData() {
|
public SystemInvoiceData() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getCompanyName() { return companyName; }
|
public String getCompanyName() {
|
||||||
public void setCompanyName(String companyName) { this.companyName = companyName; }
|
return companyName;
|
||||||
|
}
|
||||||
|
|
||||||
public String getCompanySubtitle() { return companySubtitle; }
|
public void setCompanyName(String companyName) {
|
||||||
public void setCompanySubtitle(String companySubtitle) { this.companySubtitle = companySubtitle; }
|
this.companyName = companyName;
|
||||||
|
}
|
||||||
|
|
||||||
public String getCompanyStreet() { return companyStreet; }
|
public String getCompanySubtitle() {
|
||||||
public void setCompanyStreet(String companyStreet) { this.companyStreet = companyStreet; }
|
return companySubtitle;
|
||||||
|
}
|
||||||
|
|
||||||
public String getCompanyCity() { return companyCity; }
|
public void setCompanySubtitle(String companySubtitle) {
|
||||||
public void setCompanyCity(String companyCity) { this.companyCity = companyCity; }
|
this.companySubtitle = companySubtitle;
|
||||||
|
}
|
||||||
|
|
||||||
public String getCompanyPhone() { return companyPhone; }
|
public String getCompanyStreet() {
|
||||||
public void setCompanyPhone(String companyPhone) { this.companyPhone = companyPhone; }
|
return companyStreet;
|
||||||
|
}
|
||||||
|
|
||||||
public String getCompanyFax() { return companyFax; }
|
public void setCompanyStreet(String companyStreet) {
|
||||||
public void setCompanyFax(String companyFax) { this.companyFax = companyFax; }
|
this.companyStreet = companyStreet;
|
||||||
|
}
|
||||||
|
|
||||||
public String getCompanyEmail() { return companyEmail; }
|
public String getCompanyCity() {
|
||||||
public void setCompanyEmail(String companyEmail) { this.companyEmail = companyEmail; }
|
return companyCity;
|
||||||
|
}
|
||||||
|
|
||||||
public String getCompanyWebsite() { return companyWebsite; }
|
public void setCompanyCity(String companyCity) {
|
||||||
public void setCompanyWebsite(String companyWebsite) { this.companyWebsite = companyWebsite; }
|
this.companyCity = companyCity;
|
||||||
|
}
|
||||||
|
|
||||||
public String getInvoiceNumber() { return invoiceNumber; }
|
public String getCompanyPhone() {
|
||||||
public void setInvoiceNumber(String invoiceNumber) { this.invoiceNumber = invoiceNumber; }
|
return companyPhone;
|
||||||
|
}
|
||||||
|
|
||||||
public String getInvoiceDate() { return invoiceDate; }
|
public void setCompanyPhone(String companyPhone) {
|
||||||
public void setInvoiceDate(String invoiceDate) { this.invoiceDate = invoiceDate; }
|
this.companyPhone = companyPhone;
|
||||||
|
}
|
||||||
|
|
||||||
public String getInvoiceText() { return invoiceText; }
|
public String getCompanyFax() {
|
||||||
public void setInvoiceText(String invoiceText) { this.invoiceText = invoiceText; }
|
return companyFax;
|
||||||
|
}
|
||||||
|
|
||||||
public String getSenderLine() { return senderLine; }
|
public void setCompanyFax(String companyFax) {
|
||||||
public void setSenderLine(String senderLine) { this.senderLine = senderLine; }
|
this.companyFax = companyFax;
|
||||||
|
}
|
||||||
|
|
||||||
public String getRecipientName() { return recipientName; }
|
public String getCompanyEmail() {
|
||||||
public void setRecipientName(String recipientName) { this.recipientName = recipientName; }
|
return companyEmail;
|
||||||
|
}
|
||||||
|
|
||||||
public String getRecipientDepartment() { return recipientDepartment; }
|
public void setCompanyEmail(String companyEmail) {
|
||||||
public void setRecipientDepartment(String recipientDepartment) { this.recipientDepartment = recipientDepartment; }
|
this.companyEmail = companyEmail;
|
||||||
|
}
|
||||||
|
|
||||||
public String getRecipientStreet() { return recipientStreet; }
|
public String getCompanyWebsite() {
|
||||||
public void setRecipientStreet(String recipientStreet) { this.recipientStreet = recipientStreet; }
|
return companyWebsite;
|
||||||
|
}
|
||||||
|
|
||||||
public String getRecipientCity() { return recipientCity; }
|
public void setCompanyWebsite(String companyWebsite) {
|
||||||
public void setRecipientCity(String recipientCity) { this.recipientCity = recipientCity; }
|
this.companyWebsite = companyWebsite;
|
||||||
|
}
|
||||||
|
|
||||||
public List<SystemInvoiceItem> getInvoiceItems() { return systemInvoiceItems; }
|
public String getInvoiceNumber() {
|
||||||
public void setInvoiceItems(List<SystemInvoiceItem> systemInvoiceItems) { this.systemInvoiceItems = systemInvoiceItems; }
|
return invoiceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
public String getNetAmount() { return netAmount; }
|
public void setInvoiceNumber(String invoiceNumber) {
|
||||||
public void setNetAmount(String netAmount) { this.netAmount = netAmount; }
|
this.invoiceNumber = invoiceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
public String getVatRate() { return vatRate; }
|
public String getInvoiceDate() {
|
||||||
public void setVatRate(String vatRate) { this.vatRate = vatRate; }
|
return invoiceDate;
|
||||||
|
}
|
||||||
|
|
||||||
public String getVatAmount() { return vatAmount; }
|
public void setInvoiceDate(String invoiceDate) {
|
||||||
public void setVatAmount(String vatAmount) { this.vatAmount = vatAmount; }
|
this.invoiceDate = invoiceDate;
|
||||||
|
}
|
||||||
|
|
||||||
public String getTotalAmount() { return totalAmount; }
|
public String getInvoiceText() {
|
||||||
public void setTotalAmount(String totalAmount) { this.totalAmount = totalAmount; }
|
return invoiceText;
|
||||||
|
}
|
||||||
|
|
||||||
public String getPaymentTerms() { return paymentTerms; }
|
public void setInvoiceText(String invoiceText) {
|
||||||
public void setPaymentTerms(String paymentTerms) { this.paymentTerms = paymentTerms; }
|
this.invoiceText = invoiceText;
|
||||||
|
}
|
||||||
|
|
||||||
public String getFooterText() { return footerText; }
|
public String getSenderLine() {
|
||||||
public void setFooterText(String footerText) { this.footerText = footerText; }
|
return senderLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSenderLine(String senderLine) {
|
||||||
|
this.senderLine = senderLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRecipientName() {
|
||||||
|
return recipientName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRecipientName(String recipientName) {
|
||||||
|
this.recipientName = recipientName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRecipientDepartment() {
|
||||||
|
return recipientDepartment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRecipientDepartment(String recipientDepartment) {
|
||||||
|
this.recipientDepartment = recipientDepartment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRecipientStreet() {
|
||||||
|
return recipientStreet;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRecipientStreet(String recipientStreet) {
|
||||||
|
this.recipientStreet = recipientStreet;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRecipientCity() {
|
||||||
|
return recipientCity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRecipientCity(String recipientCity) {
|
||||||
|
this.recipientCity = recipientCity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SystemInvoiceItem> getInvoiceItems() {
|
||||||
|
return systemInvoiceItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setInvoiceItems(List<SystemInvoiceItem> systemInvoiceItems) {
|
||||||
|
this.systemInvoiceItems = systemInvoiceItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNetAmount() {
|
||||||
|
return netAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNetAmount(String netAmount) {
|
||||||
|
this.netAmount = netAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVatRate() {
|
||||||
|
return vatRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVatRate(String vatRate) {
|
||||||
|
this.vatRate = vatRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVatAmount() {
|
||||||
|
return vatAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVatAmount(String vatAmount) {
|
||||||
|
this.vatAmount = vatAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTotalAmount() {
|
||||||
|
return totalAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalAmount(String totalAmount) {
|
||||||
|
this.totalAmount = totalAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPaymentTerms() {
|
||||||
|
return paymentTerms;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPaymentTerms(String paymentTerms) {
|
||||||
|
this.paymentTerms = paymentTerms;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFooterText() {
|
||||||
|
return footerText;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFooterText(String footerText) {
|
||||||
|
this.footerText = footerText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
package de.assecutor.votianlt.model.task;
|
package de.assecutor.votianlt.model.task;
|
||||||
|
|
||||||
public enum TaskType {
|
public enum TaskType {
|
||||||
CONFIRMATION("Bestätigung"), SIGNATURE("Unterschrift"), TODOLIST("To-Do Liste"), PHOTO("Foto"), BARCODE("Barcode"), COMMENT("Kommentar");
|
CONFIRMATION("Bestätigung"),
|
||||||
|
SIGNATURE("Unterschrift"),
|
||||||
|
TODOLIST("To-Do Liste"),
|
||||||
|
PHOTO("Foto"),
|
||||||
|
BARCODE("Barcode"),
|
||||||
|
COMMENT("Kommentar");
|
||||||
|
|
||||||
private final String displayName;
|
private final String displayName;
|
||||||
|
|
||||||
|
|||||||
@@ -54,33 +54,28 @@ class MqttPublisherImpl implements MqttPublisher {
|
|||||||
String messageType = parts[3];
|
String messageType = parts[3];
|
||||||
|
|
||||||
// Use MessageDeliveryService for reliable delivery
|
// Use MessageDeliveryService for reliable delivery
|
||||||
DeliveryOptions options = DeliveryOptions.builder()
|
DeliveryOptions options = DeliveryOptions.builder().requiresAck(true).retained(retained).build();
|
||||||
.requiresAck(true)
|
|
||||||
.retained(retained)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
deliveryService.sendToClient(clientId, messageType, payload, options)
|
deliveryService.sendToClient(clientId, messageType, payload, options).thenAccept(receipt -> {
|
||||||
.thenAccept(receipt -> {
|
log.info("=== MESSAGE DELIVERY SUBMITTED ===");
|
||||||
log.info("=== MESSAGE DELIVERY SUBMITTED ===");
|
log.info("Topic: {}", topic);
|
||||||
log.info("Topic: {}", topic);
|
log.info("Message ID: {}", receipt.getMessageId());
|
||||||
log.info("Message ID: {}", receipt.getMessageId());
|
log.info("Status: {}", receipt.getStatus());
|
||||||
log.info("Status: {}", receipt.getStatus());
|
log.info("Retained: {}", retained);
|
||||||
log.info("Retained: {}", retained);
|
|
||||||
|
|
||||||
// Log payload for debugging
|
// Log payload for debugging
|
||||||
try {
|
try {
|
||||||
String json = (payload instanceof String s) ? s : objectMapper.writeValueAsString(payload);
|
String json = (payload instanceof String s) ? s : objectMapper.writeValueAsString(payload);
|
||||||
log.info("Payload: {}", json);
|
log.info("Payload: {}", json);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("Could not serialize payload for logging: {}", e.getMessage());
|
log.debug("Could not serialize payload for logging: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("=== END MESSAGE DELIVERY ===");
|
log.info("=== END MESSAGE DELIVERY ===");
|
||||||
})
|
}).exceptionally(ex -> {
|
||||||
.exceptionally(ex -> {
|
log.error("Failed to submit message for delivery to topic {}: {}", topic, ex.getMessage(), ex);
|
||||||
log.error("Failed to submit message for delivery to topic {}: {}", topic, ex.getMessage(), ex);
|
return null;
|
||||||
return null;
|
});
|
||||||
});
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to publish message for topic {}: {}", topic, e.getMessage(), e);
|
log.error("Failed to publish message for topic {}: {}", topic, e.getMessage(), e);
|
||||||
|
|||||||
@@ -79,16 +79,19 @@ public final class AdminLayout extends AppLayout {
|
|||||||
SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD));
|
SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD));
|
||||||
SideNavItem pdfTest = new SideNavItem("PDF Test", "pdf-test", new Icon(VaadinIcon.FILE_TEXT_O));
|
SideNavItem pdfTest = new SideNavItem("PDF Test", "pdf-test", new Icon(VaadinIcon.FILE_TEXT_O));
|
||||||
SideNavItem priceTable = new SideNavItem("Preis-Tabelle", "admin-price-table", new Icon(VaadinIcon.COG));
|
SideNavItem priceTable = new SideNavItem("Preis-Tabelle", "admin-price-table", new Icon(VaadinIcon.COG));
|
||||||
//SideNavItem systemSettings = new SideNavItem("Systemeinstellungen", "admin-settings", new Icon(VaadinIcon.COG));
|
// SideNavItem systemSettings = new SideNavItem("Systemeinstellungen",
|
||||||
//SideNavItem userManagement = new SideNavItem("Benutzerverwaltung", "admin-users", new Icon(VaadinIcon.USERS));
|
// "admin-settings", new Icon(VaadinIcon.COG));
|
||||||
//SideNavItem systemLogs = new SideNavItem("System-Logs", "admin-logs", new Icon(VaadinIcon.FILE_TEXT));
|
// SideNavItem userManagement = new SideNavItem("Benutzerverwaltung",
|
||||||
|
// "admin-users", new Icon(VaadinIcon.USERS));
|
||||||
|
// SideNavItem systemLogs = new SideNavItem("System-Logs", "admin-logs", new
|
||||||
|
// Icon(VaadinIcon.FILE_TEXT));
|
||||||
|
|
||||||
nav.addItem(dashboard);
|
nav.addItem(dashboard);
|
||||||
nav.addItem(pdfTest);
|
nav.addItem(pdfTest);
|
||||||
nav.addItem(priceTable);
|
nav.addItem(priceTable);
|
||||||
//nav.addItem(systemSettings);
|
// nav.addItem(systemSettings);
|
||||||
//nav.addItem(userManagement);
|
// nav.addItem(userManagement);
|
||||||
//nav.addItem(systemLogs);
|
// nav.addItem(systemLogs);
|
||||||
|
|
||||||
// Create a vertical layout to hold menu items
|
// Create a vertical layout to hold menu items
|
||||||
VerticalLayout navContainer = new VerticalLayout();
|
VerticalLayout navContainer = new VerticalLayout();
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import de.assecutor.votianlt.security.SecurityService;
|
|||||||
import de.assecutor.votianlt.repository.CargoItemRepository;
|
import de.assecutor.votianlt.repository.CargoItemRepository;
|
||||||
import de.assecutor.votianlt.service.JobHistoryService;
|
import de.assecutor.votianlt.service.JobHistoryService;
|
||||||
import de.assecutor.votianlt.service.EmailService;
|
import de.assecutor.votianlt.service.EmailService;
|
||||||
|
import de.assecutor.votianlt.event.JobCreatedEvent;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -32,6 +34,7 @@ public class AddJobService {
|
|||||||
private final SecurityService securityService;
|
private final SecurityService securityService;
|
||||||
private final JobHistoryService jobHistoryService;
|
private final JobHistoryService jobHistoryService;
|
||||||
private final EmailService emailService;
|
private final EmailService emailService;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Speichert einen neuen Auftrag samt CargoItems und Tasks
|
* Speichert einen neuen Auftrag samt CargoItems und Tasks
|
||||||
@@ -118,6 +121,14 @@ public class AddJobService {
|
|||||||
e.getMessage());
|
e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish job created event for real-time UI updates
|
||||||
|
try {
|
||||||
|
eventPublisher.publishEvent(new JobCreatedEvent(this, savedJob));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to publish job created event for job {}: {}", savedJob.getIdAsString(),
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
log.info("Auftrag erfolgreich gespeichert: {}", savedJob.getJobNumber());
|
log.info("Auftrag erfolgreich gespeichert: {}", savedJob.getJobNumber());
|
||||||
return savedJob;
|
return savedJob;
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ public class UserInvoiceDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public UserInvoiceData createOrUpdate(ObjectId userId, boolean billingEnabled, String prefix, String ustId,
|
public UserInvoiceData createOrUpdate(ObjectId userId, boolean billingEnabled, String prefix, String ustId,
|
||||||
String taxNumber, String bankName, String iban, String taxRate,
|
String taxNumber, String bankName, String iban, String taxRate, String introText, String paymentTerms) {
|
||||||
String introText, String paymentTerms) {
|
|
||||||
// If billing is disabled, delete any existing record and return null
|
// If billing is disabled, delete any existing record and return null
|
||||||
if (!billingEnabled) {
|
if (!billingEnabled) {
|
||||||
deleteByUserId(userId);
|
deleteByUserId(userId);
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ public class AddAppUserView extends VerticalLayout {
|
|||||||
phoneField.setRequiredIndicatorVisible(true);
|
phoneField.setRequiredIndicatorVisible(true);
|
||||||
phoneField.addBlurListener(e -> validateField(phoneField, "Telefonnummer ist ein Pflichtfeld"));
|
phoneField.addBlurListener(e -> validateField(phoneField, "Telefonnummer ist ein Pflichtfeld"));
|
||||||
|
|
||||||
|
|
||||||
emailField.setWidthFull();
|
emailField.setWidthFull();
|
||||||
emailField.setRequiredIndicatorVisible(true);
|
emailField.setRequiredIndicatorVisible(true);
|
||||||
emailField.addBlurListener(e -> validateEmailField());
|
emailField.addBlurListener(e -> validateEmailField());
|
||||||
@@ -122,7 +121,6 @@ public class AddAppUserView extends VerticalLayout {
|
|||||||
confirmPasswordField.setRequiredIndicatorVisible(true);
|
confirmPasswordField.setRequiredIndicatorVisible(true);
|
||||||
confirmPasswordField.addBlurListener(e -> validateConfirmPasswordField());
|
confirmPasswordField.addBlurListener(e -> validateConfirmPasswordField());
|
||||||
|
|
||||||
|
|
||||||
// Add fields to form
|
// Add fields to form
|
||||||
formLayout.add(designationField);
|
formLayout.add(designationField);
|
||||||
formLayout.add(nameLayout);
|
formLayout.add(nameLayout);
|
||||||
@@ -200,10 +198,12 @@ public class AddAppUserView extends VerticalLayout {
|
|||||||
emailField.setInvalid(true);
|
emailField.setInvalid(true);
|
||||||
emailField.setErrorMessage("E-Mail-Adresse bereits vorhanden");
|
emailField.setErrorMessage("E-Mail-Adresse bereits vorhanden");
|
||||||
} else {
|
} else {
|
||||||
Notification.show("Ein Fehler ist aufgetreten: Doppelter Wert gefunden", 5000, Notification.Position.MIDDLE);
|
Notification.show("Ein Fehler ist aufgetreten: Doppelter Wert gefunden", 5000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Notification.show("Fehler beim Anlegen des App-Nutzers: " + e.getMessage(), 5000, Notification.Position.MIDDLE);
|
Notification.show("Fehler beim Anlegen des App-Nutzers: " + e.getMessage(), 5000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +269,6 @@ public class AddAppUserView extends VerticalLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private boolean validateAllFields() {
|
private boolean validateAllFields() {
|
||||||
validateField(designationField, "Kennung ist ein Pflichtfeld");
|
validateField(designationField, "Kennung ist ein Pflichtfeld");
|
||||||
validateField(firstnameField, "Vorname ist ein Pflichtfeld");
|
validateField(firstnameField, "Vorname ist ein Pflichtfeld");
|
||||||
@@ -280,7 +279,7 @@ public class AddAppUserView extends VerticalLayout {
|
|||||||
validateConfirmPasswordField();
|
validateConfirmPasswordField();
|
||||||
|
|
||||||
return !designationField.isInvalid() && !firstnameField.isInvalid() && !lastnameField.isInvalid()
|
return !designationField.isInvalid() && !firstnameField.isInvalid() && !lastnameField.isInvalid()
|
||||||
&& !phoneField.isInvalid() && !emailField.isInvalid()
|
&& !phoneField.isInvalid() && !emailField.isInvalid() && !passwordField.isInvalid()
|
||||||
&& !passwordField.isInvalid() && !confirmPasswordField.isInvalid();
|
&& !confirmPasswordField.isInvalid();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,8 +264,8 @@ public class AddCustomerView extends Main {
|
|||||||
validateField(city);
|
validateField(city);
|
||||||
validateEmail();
|
validateEmail();
|
||||||
|
|
||||||
return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid()
|
return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid()
|
||||||
&& !telephone.isInvalid() && !mail.isInvalid() && !street.isInvalid()
|
&& !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid()
|
||||||
&& !houseNumber.isInvalid() && !zip.isInvalid() && !city.isInvalid();
|
&& !city.isInvalid();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ public class AddJobView extends Main {
|
|||||||
private Span cargoError;
|
private Span cargoError;
|
||||||
private VerticalLayout cargoList;
|
private VerticalLayout cargoList;
|
||||||
private VerticalLayout tasksList;
|
private VerticalLayout tasksList;
|
||||||
|
private ComboBox<TaskTemplate> templateComboBox;
|
||||||
private TextArea remarkArea;
|
private TextArea remarkArea;
|
||||||
private VerticalLayout pickupSection;
|
private VerticalLayout pickupSection;
|
||||||
private VerticalLayout deliverySection;
|
private VerticalLayout deliverySection;
|
||||||
@@ -145,8 +146,8 @@ public class AddJobView extends Main {
|
|||||||
private List<AppUser> availableAppUsers;
|
private List<AppUser> availableAppUsers;
|
||||||
|
|
||||||
public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService,
|
public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService,
|
||||||
CustomerService customerService, AppUserService appUserService,
|
CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService,
|
||||||
TaskTemplateService taskTemplateService, SecurityService securityService) {
|
SecurityService securityService) {
|
||||||
this.addJobService = addJobService;
|
this.addJobService = addJobService;
|
||||||
this.addCustomerService = addCustomerService;
|
this.addCustomerService = addCustomerService;
|
||||||
this.customerService = customerService;
|
this.customerService = customerService;
|
||||||
@@ -367,28 +368,22 @@ public class AddJobView extends Main {
|
|||||||
pickupDate = new DatePicker("Datum");
|
pickupDate = new DatePicker("Datum");
|
||||||
pickupDate.setRequiredIndicatorVisible(true);
|
pickupDate.setRequiredIndicatorVisible(true);
|
||||||
pickupDate.setLocale(java.util.Locale.GERMANY); // Monday as first day of week
|
pickupDate.setLocale(java.util.Locale.GERMANY); // Monday as first day of week
|
||||||
pickupDate.setI18n(new DatePicker.DatePickerI18n()
|
pickupDate.setI18n(new DatePicker.DatePickerI18n().setFirstDayOfWeek(1) // 1 = Monday
|
||||||
.setFirstDayOfWeek(1) // 1 = Monday
|
.setMonthNames(java.util.Arrays.asList("Januar", "Februar", "März", "April", "Mai", "Juni", "Juli",
|
||||||
.setMonthNames(java.util.Arrays.asList(
|
"August", "September", "Oktober", "November", "Dezember"))
|
||||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
.setWeekdays(java.util.Arrays.asList("Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag",
|
||||||
"Juli", "August", "September", "Oktober", "November", "Dezember"))
|
"Freitag", "Samstag"))
|
||||||
.setWeekdays(java.util.Arrays.asList(
|
.setWeekdaysShort(java.util.Arrays.asList("So", "Mo", "Di", "Mi", "Do", "Fr", "Sa")));
|
||||||
"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"))
|
|
||||||
.setWeekdaysShort(java.util.Arrays.asList(
|
|
||||||
"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa")));
|
|
||||||
|
|
||||||
deliveryDate = new DatePicker("Datum");
|
deliveryDate = new DatePicker("Datum");
|
||||||
deliveryDate.setRequiredIndicatorVisible(true);
|
deliveryDate.setRequiredIndicatorVisible(true);
|
||||||
deliveryDate.setLocale(java.util.Locale.GERMANY); // Monday as first day of week
|
deliveryDate.setLocale(java.util.Locale.GERMANY); // Monday as first day of week
|
||||||
deliveryDate.setI18n(new DatePicker.DatePickerI18n()
|
deliveryDate.setI18n(new DatePicker.DatePickerI18n().setFirstDayOfWeek(1) // 1 = Monday
|
||||||
.setFirstDayOfWeek(1) // 1 = Monday
|
.setMonthNames(java.util.Arrays.asList("Januar", "Februar", "März", "April", "Mai", "Juni", "Juli",
|
||||||
.setMonthNames(java.util.Arrays.asList(
|
"August", "September", "Oktober", "November", "Dezember"))
|
||||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
.setWeekdays(java.util.Arrays.asList("Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag",
|
||||||
"Juli", "August", "September", "Oktober", "November", "Dezember"))
|
"Freitag", "Samstag"))
|
||||||
.setWeekdays(java.util.Arrays.asList(
|
.setWeekdaysShort(java.util.Arrays.asList("So", "Mo", "Di", "Mi", "Do", "Fr", "Sa")));
|
||||||
"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"))
|
|
||||||
.setWeekdaysShort(java.util.Arrays.asList(
|
|
||||||
"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa")));
|
|
||||||
|
|
||||||
// Submit button - initially disabled until all required fields are valid
|
// Submit button - initially disabled until all required fields are valid
|
||||||
submitButton = new Button("Auftrag anlegen", event -> submit());
|
submitButton = new Button("Auftrag anlegen", event -> submit());
|
||||||
@@ -423,6 +418,15 @@ public class AddJobView extends Main {
|
|||||||
// Tab 4: Tasks
|
// Tab 4: Tasks
|
||||||
tasksTab = tabSheet.add("Aufgaben", createTasksTab());
|
tasksTab = tabSheet.add("Aufgaben", createTasksTab());
|
||||||
|
|
||||||
|
// Disable tasks tab initially if digital processing is off
|
||||||
|
if (!Boolean.TRUE.equals(digitalProcessing.getValue())) {
|
||||||
|
tasksTab.setEnabled(false);
|
||||||
|
tasksState.clear();
|
||||||
|
if (tasksList != null) {
|
||||||
|
tasksList.removeAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tab 5: Price & Submit
|
// Tab 5: Price & Submit
|
||||||
priceTab = tabSheet.add("Preis & Abschluss", createPriceAndSubmitTab());
|
priceTab = tabSheet.add("Preis & Abschluss", createPriceAndSubmitTab());
|
||||||
|
|
||||||
@@ -853,8 +857,7 @@ public class AddJobView extends Main {
|
|||||||
binder.bind(deliveryPhone, Job::getDeliveryPhone, Job::setDeliveryPhone);
|
binder.bind(deliveryPhone, Job::getDeliveryPhone, Job::setDeliveryPhone);
|
||||||
binder.bind(deliveryAddressAddition, Job::getDeliveryAddressAddition, Job::setDeliveryAddressAddition);
|
binder.bind(deliveryAddressAddition, Job::getDeliveryAddressAddition, Job::setDeliveryAddressAddition);
|
||||||
|
|
||||||
binder.forField(digitalProcessing).bind(
|
binder.forField(digitalProcessing).bind(Job::isDigitalProcessing,
|
||||||
Job::isDigitalProcessing,
|
|
||||||
(job, value) -> job.setDigitalProcessing(Boolean.TRUE.equals(value)));
|
(job, value) -> job.setDigitalProcessing(Boolean.TRUE.equals(value)));
|
||||||
|
|
||||||
// Bind appUser with converter: AppUser object <-> String ID
|
// Bind appUser with converter: AppUser object <-> String ID
|
||||||
@@ -876,10 +879,31 @@ public class AddJobView extends Main {
|
|||||||
}, "Bitte App-Nutzer auswählen, wenn Digitale Abwicklung aktiv ist")
|
}, "Bitte App-Nutzer auswählen, wenn Digitale Abwicklung aktiv ist")
|
||||||
.bind(Job::getAppUser, Job::setAppUser);
|
.bind(Job::getAppUser, Job::setAppUser);
|
||||||
|
|
||||||
// Toggle required indicator for App-Nutzer based on digitalProcessing
|
// Toggle required indicator and visibility for App-Nutzer based on
|
||||||
|
// digitalProcessing
|
||||||
digitalProcessing.addValueChangeListener(e -> {
|
digitalProcessing.addValueChangeListener(e -> {
|
||||||
boolean required = Boolean.TRUE.equals(e.getValue());
|
boolean required = Boolean.TRUE.equals(e.getValue());
|
||||||
appUser.setRequiredIndicatorVisible(required);
|
appUser.setRequiredIndicatorVisible(required);
|
||||||
|
appUser.setVisible(required);
|
||||||
|
|
||||||
|
// Enable/disable tasks tab based on digital processing
|
||||||
|
if (tasksTab != null) {
|
||||||
|
tasksTab.setEnabled(required);
|
||||||
|
}
|
||||||
|
if (!required) {
|
||||||
|
// Clear app user and all tasks when digital processing is disabled
|
||||||
|
appUser.clear();
|
||||||
|
tasksState.clear();
|
||||||
|
if (tasksList != null) {
|
||||||
|
tasksList.removeAll();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add an empty task row when digital processing is re-enabled
|
||||||
|
if (tasksState.isEmpty() && tasksList != null) {
|
||||||
|
createTaskRow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
triggerValidation();
|
triggerValidation();
|
||||||
updateTabLabels();
|
updateTabLabels();
|
||||||
});
|
});
|
||||||
@@ -888,8 +912,10 @@ public class AddJobView extends Main {
|
|||||||
triggerValidation();
|
triggerValidation();
|
||||||
updateTabLabels();
|
updateTabLabels();
|
||||||
});
|
});
|
||||||
// Initialize required indicator state
|
// Initialize required indicator and visibility state
|
||||||
appUser.setRequiredIndicatorVisible(Boolean.TRUE.equals(digitalProcessing.getValue()));
|
boolean digitalInitial = Boolean.TRUE.equals(digitalProcessing.getValue());
|
||||||
|
appUser.setRequiredIndicatorVisible(digitalInitial);
|
||||||
|
appUser.setVisible(digitalInitial);
|
||||||
|
|
||||||
// Set up validation triggers and visual styling
|
// Set up validation triggers and visual styling
|
||||||
setupValidationTriggers();
|
setupValidationTriggers();
|
||||||
@@ -956,11 +982,8 @@ public class AddJobView extends Main {
|
|||||||
|
|
||||||
// Update submit button state based on all validation checks
|
// Update submit button state based on all validation checks
|
||||||
if (submitButton != null) {
|
if (submitButton != null) {
|
||||||
boolean hasErrors = hasAddressValidationErrors()
|
boolean hasErrors = hasAddressValidationErrors() || hasAppointmentValidationErrors()
|
||||||
|| hasAppointmentValidationErrors()
|
|| hasCargoValidationErrors() || hasPriceValidationErrors() || hasTasksValidationErrors();
|
||||||
|| hasCargoValidationErrors()
|
|
||||||
|| hasPriceValidationErrors()
|
|
||||||
|| hasTasksValidationErrors();
|
|
||||||
submitButton.setEnabled(!hasErrors);
|
submitButton.setEnabled(!hasErrors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1073,8 +1096,7 @@ public class AddJobView extends Main {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Check if at least one todo item is non-empty
|
// Check if at least one todo item is non-empty
|
||||||
boolean hasValidTodoItem = todoItems.stream()
|
boolean hasValidTodoItem = todoItems.stream().anyMatch(item -> item != null && !item.trim().isEmpty());
|
||||||
.anyMatch(item -> item != null && !item.trim().isEmpty());
|
|
||||||
if (!hasValidTodoItem) {
|
if (!hasValidTodoItem) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1164,9 +1186,10 @@ public class AddJobView extends Main {
|
|||||||
addCustomerService.addCustomer(deliveryCustomer);
|
addCustomerService.addCustomer(deliveryCustomer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// All validations passed, save the job with cargo items and tasks (tasks may be
|
// All validations passed, save the job with cargo items and tasks
|
||||||
// empty)
|
// If digital processing is disabled, don't save any tasks
|
||||||
Job savedJob = addJobService.addJobWithCargo(job, cargoFilled, tasksState);
|
List<BaseTask> tasksToSave = job.isDigitalProcessing() ? tasksState : List.of();
|
||||||
|
Job savedJob = addJobService.addJobWithCargo(job, cargoFilled, tasksToSave);
|
||||||
|
|
||||||
// Erfolgsmeldung und Navigation zur Zusammenfassung
|
// Erfolgsmeldung und Navigation zur Zusammenfassung
|
||||||
Notification successNotification = Notification
|
Notification successNotification = Notification
|
||||||
@@ -1426,7 +1449,7 @@ public class AddJobView extends Main {
|
|||||||
tasksTitle.getStyle().set("margin", "0");
|
tasksTitle.getStyle().set("margin", "0");
|
||||||
tasksTitle.getStyle().set("white-space", "nowrap");
|
tasksTitle.getStyle().set("white-space", "nowrap");
|
||||||
|
|
||||||
ComboBox<TaskTemplate> templateComboBox = new ComboBox<>();
|
templateComboBox = new ComboBox<>();
|
||||||
templateComboBox.setPlaceholder("Template auswählen...");
|
templateComboBox.setPlaceholder("Template auswählen...");
|
||||||
templateComboBox.setItemLabelGenerator(TaskTemplate::getTemplateName);
|
templateComboBox.setItemLabelGenerator(TaskTemplate::getTemplateName);
|
||||||
templateComboBox.setClearButtonVisible(true);
|
templateComboBox.setClearButtonVisible(true);
|
||||||
@@ -1688,24 +1711,31 @@ public class AddJobView extends Main {
|
|||||||
BaseTask newTask = createTaskByType(selectedType);
|
BaseTask newTask = createTaskByType(selectedType);
|
||||||
BaseTask oldTask = currentTask[0];
|
BaseTask oldTask = currentTask[0];
|
||||||
|
|
||||||
|
newTask.setDescription(oldTask.getDescription());
|
||||||
newTask.setCompleted(oldTask.isCompleted());
|
newTask.setCompleted(oldTask.isCompleted());
|
||||||
newTask.setCompletedAt(oldTask.getCompletedAt());
|
newTask.setCompletedAt(oldTask.getCompletedAt());
|
||||||
newTask.setCompletedBy(oldTask.getCompletedBy());
|
newTask.setCompletedBy(oldTask.getCompletedBy());
|
||||||
|
|
||||||
// Preserve task-specific properties
|
// Preserve task-specific properties
|
||||||
switch (oldTask) {
|
switch (oldTask) {
|
||||||
case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask -> newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText());
|
case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask ->
|
||||||
case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask -> newTodoTask.setTodoItems(oldTodoTask.getTodoItems());
|
newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText());
|
||||||
case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> {
|
case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask ->
|
||||||
newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount());
|
newTodoTask.setTodoItems(oldTodoTask.getTodoItems());
|
||||||
newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount());
|
case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> {
|
||||||
}
|
newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount());
|
||||||
case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> {
|
newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount());
|
||||||
newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount());
|
}
|
||||||
newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount());
|
case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> {
|
||||||
}
|
newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount());
|
||||||
default -> {
|
newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount());
|
||||||
}
|
}
|
||||||
|
case CommentTask oldCommentTask when newTask instanceof CommentTask newCommentTask -> {
|
||||||
|
newCommentTask.setCommentText(oldCommentTask.getCommentText());
|
||||||
|
newCommentTask.setRequired(oldCommentTask.isRequired());
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace in state and preserve order
|
// Replace in state and preserve order
|
||||||
@@ -1974,6 +2004,27 @@ public class AddJobView extends Main {
|
|||||||
configContainer.add(barcodeLayout);
|
configContainer.add(barcodeLayout);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case COMMENT:
|
||||||
|
CommentTask commentTask = (CommentTask) task;
|
||||||
|
|
||||||
|
TextField commentTextField = new TextField("Kommentar-Platzhalter");
|
||||||
|
commentTextField.setPlaceholder("Hinweis für den Benutzer...");
|
||||||
|
commentTextField.setWidthFull();
|
||||||
|
commentTextField.setValue(commentTask.getCommentText() != null ? commentTask.getCommentText() : "");
|
||||||
|
commentTextField.addValueChangeListener(ev -> {
|
||||||
|
commentTask.setCommentText(ev.getValue());
|
||||||
|
});
|
||||||
|
|
||||||
|
com.vaadin.flow.component.checkbox.Checkbox requiredCheckbox = new com.vaadin.flow.component.checkbox.Checkbox(
|
||||||
|
"Pflichtfeld");
|
||||||
|
requiredCheckbox.setValue(commentTask.isRequired());
|
||||||
|
requiredCheckbox.addValueChangeListener(ev -> {
|
||||||
|
commentTask.setRequired(ev.getValue());
|
||||||
|
});
|
||||||
|
|
||||||
|
configContainer.add(commentTextField, requiredCheckbox);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new IllegalArgumentException("Unbekannter TaskType: " + taskType);
|
throw new IllegalArgumentException("Unbekannter TaskType: " + taskType);
|
||||||
}
|
}
|
||||||
@@ -2028,7 +2079,8 @@ public class AddJobView extends Main {
|
|||||||
saveButton.addClickListener(e -> {
|
saveButton.addClickListener(e -> {
|
||||||
String templateName = templateNameField.getValue();
|
String templateName = templateNameField.getValue();
|
||||||
if (templateName == null || templateName.trim().isEmpty()) {
|
if (templateName == null || templateName.trim().isEmpty()) {
|
||||||
Notification.show("Bitte geben Sie einen Template-Namen ein", 3000, Notification.Position.BOTTOM_END);
|
Notification.show("Bitte geben Sie einen Template-Namen ein", 3000,
|
||||||
|
Notification.Position.BOTTOM_END);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2042,20 +2094,20 @@ public class AddJobView extends Main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save template with task type information and specific data
|
// Save template with task type information and specific data
|
||||||
taskTemplateService.createTemplate(
|
taskTemplateService.createTemplate(securityService.getCurrentDatabaseUser().getId(),
|
||||||
securityService.getCurrentDatabaseUser().getId(),
|
templateName.trim(), tasksCopy);
|
||||||
templateName.trim(),
|
|
||||||
tasksCopy
|
|
||||||
);
|
|
||||||
|
|
||||||
dialog.close();
|
dialog.close();
|
||||||
Notification.show("Template '" + templateName + "' erfolgreich gespeichert", 3000, Notification.Position.BOTTOM_END);
|
loadTemplatesIntoComboBox(templateComboBox);
|
||||||
|
Notification.show("Template '" + templateName + "' erfolgreich gespeichert", 3000,
|
||||||
|
Notification.Position.BOTTOM_END);
|
||||||
|
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
Notification.show(ex.getMessage(), 4000, Notification.Position.MIDDLE);
|
Notification.show(ex.getMessage(), 4000, Notification.Position.MIDDLE);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.error("Error saving task template", ex);
|
log.error("Error saving task template", ex);
|
||||||
Notification.show("Fehler beim Speichern des Templates: " + ex.getMessage(), 4000, Notification.Position.MIDDLE);
|
Notification.show("Fehler beim Speichern des Templates: " + ex.getMessage(), 4000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2076,8 +2128,8 @@ public class AddJobView extends Main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a deep copy of a task to avoid reference issues in templates
|
* Creates a deep copy of a task to avoid reference issues in templates Saves
|
||||||
* Saves all task-specific data including type and specific properties
|
* all task-specific data including type and specific properties
|
||||||
*/
|
*/
|
||||||
private BaseTask createTaskCopy(BaseTask original) {
|
private BaseTask createTaskCopy(BaseTask original) {
|
||||||
BaseTask copy = null;
|
BaseTask copy = null;
|
||||||
@@ -2102,28 +2154,23 @@ public class AddJobView extends Main {
|
|||||||
} else if (original instanceof PhotoTask) {
|
} else if (original instanceof PhotoTask) {
|
||||||
PhotoTask origTask = (PhotoTask) original;
|
PhotoTask origTask = (PhotoTask) original;
|
||||||
// Copy with all photo-specific parameters
|
// Copy with all photo-specific parameters
|
||||||
copy = new PhotoTask(
|
copy = new PhotoTask(origTask.getMinPhotoCount() != null ? origTask.getMinPhotoCount() : 1,
|
||||||
origTask.getMinPhotoCount() != null ? origTask.getMinPhotoCount() : 1,
|
origTask.getMaxPhotoCount() != null ? origTask.getMaxPhotoCount() : 10);
|
||||||
origTask.getMaxPhotoCount() != null ? origTask.getMaxPhotoCount() : 10
|
|
||||||
);
|
|
||||||
} else if (original instanceof BarcodeTask) {
|
} else if (original instanceof BarcodeTask) {
|
||||||
BarcodeTask origTask = (BarcodeTask) original;
|
BarcodeTask origTask = (BarcodeTask) original;
|
||||||
// Copy with all barcode-specific parameters
|
// Copy with all barcode-specific parameters
|
||||||
copy = new BarcodeTask(
|
copy = new BarcodeTask(origTask.getMinBarcodeCount() != null ? origTask.getMinBarcodeCount() : 1,
|
||||||
origTask.getMinBarcodeCount() != null ? origTask.getMinBarcodeCount() : 1,
|
origTask.getMaxBarcodeCount() != null ? origTask.getMaxBarcodeCount() : 10);
|
||||||
origTask.getMaxBarcodeCount() != null ? origTask.getMaxBarcodeCount() : 10
|
|
||||||
);
|
|
||||||
} else if (original instanceof CommentTask) {
|
} else if (original instanceof CommentTask) {
|
||||||
CommentTask origTask = (CommentTask) original;
|
CommentTask origTask = (CommentTask) original;
|
||||||
// Copy with all comment-specific parameters
|
// Copy with all comment-specific parameters
|
||||||
copy = new CommentTask(
|
copy = new CommentTask(origTask.getCommentText() != null ? origTask.getCommentText() : "",
|
||||||
origTask.getCommentText() != null ? origTask.getCommentText() : "",
|
origTask.isRequired());
|
||||||
origTask.isRequired()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copy != null) {
|
if (copy != null) {
|
||||||
// Copy all base task properties
|
// Copy all base task properties
|
||||||
|
copy.setDescription(original.getDescription());
|
||||||
copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0);
|
copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0);
|
||||||
copy.setCompleted(original.isCompleted());
|
copy.setCompleted(original.isCompleted());
|
||||||
copy.setCompletedAt(original.getCompletedAt());
|
copy.setCompletedAt(original.getCompletedAt());
|
||||||
@@ -2138,9 +2185,8 @@ public class AddJobView extends Main {
|
|||||||
*/
|
*/
|
||||||
private void loadTemplatesIntoComboBox(ComboBox<TaskTemplate> templateComboBox) {
|
private void loadTemplatesIntoComboBox(ComboBox<TaskTemplate> templateComboBox) {
|
||||||
try {
|
try {
|
||||||
List<TaskTemplate> templates = taskTemplateService.findByUserId(
|
List<TaskTemplate> templates = taskTemplateService
|
||||||
securityService.getCurrentDatabaseUser().getId()
|
.findByUserId(securityService.getCurrentDatabaseUser().getId());
|
||||||
);
|
|
||||||
templateComboBox.setItems(templates);
|
templateComboBox.setItems(templates);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error loading templates", e);
|
log.error("Error loading templates", e);
|
||||||
@@ -2154,8 +2200,8 @@ public class AddJobView extends Main {
|
|||||||
private void loadTasksFromTemplate(TaskTemplate template, ComboBox<TaskTemplate> templateComboBox) {
|
private void loadTasksFromTemplate(TaskTemplate template, ComboBox<TaskTemplate> templateComboBox) {
|
||||||
ConfirmDialog confirmDialog = new ConfirmDialog();
|
ConfirmDialog confirmDialog = new ConfirmDialog();
|
||||||
confirmDialog.setHeader("Template laden");
|
confirmDialog.setHeader("Template laden");
|
||||||
confirmDialog.setText("Möchten Sie wirklich das Template '" + template.getTemplateName() +
|
confirmDialog.setText("Möchten Sie wirklich das Template '" + template.getTemplateName()
|
||||||
"' laden? Alle aktuellen Aufgaben werden ersetzt.");
|
+ "' laden? Alle aktuellen Aufgaben werden ersetzt.");
|
||||||
confirmDialog.setCancelable(true);
|
confirmDialog.setCancelable(true);
|
||||||
confirmDialog.setCancelText("Abbrechen");
|
confirmDialog.setCancelText("Abbrechen");
|
||||||
confirmDialog.setConfirmText("Laden");
|
confirmDialog.setConfirmText("Laden");
|
||||||
@@ -2181,13 +2227,17 @@ public class AddJobView extends Main {
|
|||||||
// Clear the combobox selection
|
// Clear the combobox selection
|
||||||
templateComboBox.clear();
|
templateComboBox.clear();
|
||||||
|
|
||||||
Notification.show("Template '" + template.getTemplateName() + "' erfolgreich geladen",
|
// Re-validate to enable submit button if all fields are valid
|
||||||
3000, Notification.Position.BOTTOM_END);
|
triggerValidation();
|
||||||
|
updateTabLabels();
|
||||||
|
|
||||||
|
Notification.show("Template '" + template.getTemplateName() + "' erfolgreich geladen", 3000,
|
||||||
|
Notification.Position.BOTTOM_END);
|
||||||
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.error("Error loading template tasks", ex);
|
log.error("Error loading template tasks", ex);
|
||||||
Notification.show("Fehler beim Laden des Templates: " + ex.getMessage(),
|
Notification.show("Fehler beim Laden des Templates: " + ex.getMessage(), 4000,
|
||||||
4000, Notification.Position.MIDDLE);
|
Notification.Position.MIDDLE);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2200,11 +2250,12 @@ public class AddJobView extends Main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a task row from an existing task (used when loading templates)
|
* Creates a task row from an existing task (used when loading templates) This
|
||||||
* This creates a UI row and populates it with the task's specific data
|
* creates a UI row and populates it with the task's specific data
|
||||||
*/
|
*/
|
||||||
private void createTaskRowFromTask(BaseTask task) {
|
private void createTaskRowFromTask(BaseTask task) {
|
||||||
// Don't call createTaskRow() directly, as it would create a default ConfirmationTask
|
// Don't call createTaskRow() directly, as it would create a default
|
||||||
|
// ConfirmationTask
|
||||||
// Instead, create the UI components and set them up with the loaded task
|
// Instead, create the UI components and set them up with the loaded task
|
||||||
|
|
||||||
VerticalLayout taskContainer = new VerticalLayout();
|
VerticalLayout taskContainer = new VerticalLayout();
|
||||||
@@ -2251,36 +2302,46 @@ public class AddJobView extends Main {
|
|||||||
|
|
||||||
final BaseTask[] currentTask = { task };
|
final BaseTask[] currentTask = { task };
|
||||||
|
|
||||||
// Set up the value change listener for the combo box
|
// Set the combo value BEFORE registering the listener so the listener does
|
||||||
|
// NOT fire during initialization. The loaded task object is already correct
|
||||||
|
// and is already in tasksState — no replacement needed.
|
||||||
|
TaskType taskType = getTaskTypeFromTask(task);
|
||||||
|
if (taskType != null) {
|
||||||
|
taskTypeCombo.setValue(taskType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the listener for user-initiated type changes only
|
||||||
taskTypeCombo.addValueChangeListener(ev -> {
|
taskTypeCombo.addValueChangeListener(ev -> {
|
||||||
TaskType selectedType = ev.getValue();
|
TaskType selectedType = ev.getValue();
|
||||||
if (selectedType != null) {
|
if (selectedType != null) {
|
||||||
BaseTask newTask = createTaskByType(selectedType);
|
BaseTask newTask = createTaskByType(selectedType);
|
||||||
BaseTask oldTask = currentTask[0];
|
BaseTask oldTask = currentTask[0];
|
||||||
|
|
||||||
|
newTask.setDescription(oldTask.getDescription());
|
||||||
newTask.setCompleted(oldTask.isCompleted());
|
newTask.setCompleted(oldTask.isCompleted());
|
||||||
newTask.setCompletedAt(oldTask.getCompletedAt());
|
newTask.setCompletedAt(oldTask.getCompletedAt());
|
||||||
newTask.setCompletedBy(oldTask.getCompletedBy());
|
newTask.setCompletedBy(oldTask.getCompletedBy());
|
||||||
|
|
||||||
// Preserve task-specific properties
|
// Preserve task-specific properties
|
||||||
switch (oldTask) {
|
switch (oldTask) {
|
||||||
case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask ->
|
case ConfirmationTask oldConfirmationTask when newTask instanceof ConfirmationTask newConfirmationTask ->
|
||||||
newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText());
|
newConfirmationTask.setButtonText(oldConfirmationTask.getButtonText());
|
||||||
case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask ->
|
case TodoListTask oldTodoTask when newTask instanceof TodoListTask newTodoTask ->
|
||||||
newTodoTask.setTodoItems(oldTodoTask.getTodoItems());
|
newTodoTask.setTodoItems(oldTodoTask.getTodoItems());
|
||||||
case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> {
|
case PhotoTask oldPhotoTask when newTask instanceof PhotoTask newPhotoTask -> {
|
||||||
newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount());
|
newPhotoTask.setMinPhotoCount(oldPhotoTask.getMinPhotoCount());
|
||||||
newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount());
|
newPhotoTask.setMaxPhotoCount(oldPhotoTask.getMaxPhotoCount());
|
||||||
}
|
}
|
||||||
case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> {
|
case BarcodeTask oldBarcodeTask when newTask instanceof BarcodeTask newBarcodeTask -> {
|
||||||
newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount());
|
newBarcodeTask.setMinBarcodeCount(oldBarcodeTask.getMinBarcodeCount());
|
||||||
newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount());
|
newBarcodeTask.setMaxBarcodeCount(oldBarcodeTask.getMaxBarcodeCount());
|
||||||
}
|
}
|
||||||
case CommentTask oldCommentTask when newTask instanceof CommentTask newCommentTask -> {
|
case CommentTask oldCommentTask when newTask instanceof CommentTask newCommentTask -> {
|
||||||
newCommentTask.setCommentText(oldCommentTask.getCommentText());
|
newCommentTask.setCommentText(oldCommentTask.getCommentText());
|
||||||
newCommentTask.setRequired(oldCommentTask.isRequired());
|
newCommentTask.setRequired(oldCommentTask.isRequired());
|
||||||
}
|
}
|
||||||
default -> {}
|
default -> {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace in state and preserve order
|
// Replace in state and preserve order
|
||||||
@@ -2297,14 +2358,10 @@ public class AddJobView extends Main {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set the correct task type based on the loaded task
|
// Render the UI with the loaded task directly (which IS in tasksState)
|
||||||
TaskType taskType = getTaskTypeFromTask(task);
|
updateTaskConfiguration(configContainer, task);
|
||||||
if (taskType != null) {
|
triggerValidation();
|
||||||
taskTypeCombo.setValue(taskType);
|
updateTabLabels();
|
||||||
updateTaskConfiguration(configContainer, task);
|
|
||||||
triggerValidation();
|
|
||||||
updateTabLabels();
|
|
||||||
}
|
|
||||||
|
|
||||||
tasksList.add(taskContainer);
|
tasksList.add(taskContainer);
|
||||||
}
|
}
|
||||||
@@ -2313,14 +2370,19 @@ public class AddJobView extends Main {
|
|||||||
* Gets the TaskType enum value from a BaseTask instance
|
* Gets the TaskType enum value from a BaseTask instance
|
||||||
*/
|
*/
|
||||||
private TaskType getTaskTypeFromTask(BaseTask task) {
|
private TaskType getTaskTypeFromTask(BaseTask task) {
|
||||||
if (task instanceof ConfirmationTask) return TaskType.CONFIRMATION;
|
if (task instanceof ConfirmationTask)
|
||||||
if (task instanceof SignatureTask) return TaskType.SIGNATURE;
|
return TaskType.CONFIRMATION;
|
||||||
if (task instanceof TodoListTask) return TaskType.TODOLIST;
|
if (task instanceof SignatureTask)
|
||||||
if (task instanceof PhotoTask) return TaskType.PHOTO;
|
return TaskType.SIGNATURE;
|
||||||
if (task instanceof BarcodeTask) return TaskType.BARCODE;
|
if (task instanceof TodoListTask)
|
||||||
if (task instanceof CommentTask) return TaskType.COMMENT;
|
return TaskType.TODOLIST;
|
||||||
|
if (task instanceof PhotoTask)
|
||||||
|
return TaskType.PHOTO;
|
||||||
|
if (task instanceof BarcodeTask)
|
||||||
|
return TaskType.BARCODE;
|
||||||
|
if (task instanceof CommentTask)
|
||||||
|
return TaskType.COMMENT;
|
||||||
return TaskType.CONFIRMATION; // fallback
|
return TaskType.CONFIRMATION; // fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ import com.vaadin.flow.router.Route;
|
|||||||
import com.vaadin.flow.theme.lumo.LumoUtility;
|
import com.vaadin.flow.theme.lumo.LumoUtility;
|
||||||
import de.assecutor.votianlt.model.JobStatus;
|
import de.assecutor.votianlt.model.JobStatus;
|
||||||
import de.assecutor.votianlt.repository.*;
|
import de.assecutor.votianlt.repository.*;
|
||||||
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
@Route(value = "admin-dashboard", layout = de.assecutor.votianlt.pages.base.ui.view.AdminLayout.class)
|
@Route(value = "admin-dashboard", layout = de.assecutor.votianlt.pages.base.ui.view.AdminLayout.class)
|
||||||
@@ -43,19 +43,12 @@ public class AdminDashboardView extends Main {
|
|||||||
private final PendingMqttMessageRepository pendingMqttMessageRepository;
|
private final PendingMqttMessageRepository pendingMqttMessageRepository;
|
||||||
|
|
||||||
private final Div statisticsContainer;
|
private final Div statisticsContainer;
|
||||||
private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public AdminDashboardView(
|
public AdminDashboardView(JobRepository jobRepository, TaskRepository taskRepository, UserRepository userRepository,
|
||||||
JobRepository jobRepository,
|
AppUserRepository appUserRepository, CargoItemRepository cargoItemRepository,
|
||||||
TaskRepository taskRepository,
|
PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
||||||
UserRepository userRepository,
|
SignatureRepository signatureRepository, CommentRepository commentRepository,
|
||||||
AppUserRepository appUserRepository,
|
|
||||||
CargoItemRepository cargoItemRepository,
|
|
||||||
PhotoRepository photoRepository,
|
|
||||||
BarcodeRepository barcodeRepository,
|
|
||||||
SignatureRepository signatureRepository,
|
|
||||||
CommentRepository commentRepository,
|
|
||||||
PendingMqttMessageRepository pendingMqttMessageRepository) {
|
PendingMqttMessageRepository pendingMqttMessageRepository) {
|
||||||
|
|
||||||
this.jobRepository = jobRepository;
|
this.jobRepository = jobRepository;
|
||||||
@@ -70,8 +63,8 @@ public class AdminDashboardView extends Main {
|
|||||||
this.pendingMqttMessageRepository = pendingMqttMessageRepository;
|
this.pendingMqttMessageRepository = pendingMqttMessageRepository;
|
||||||
|
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX,
|
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
||||||
LumoUtility.FlexDirection.COLUMN, LumoUtility.Padding.MEDIUM);
|
LumoUtility.Padding.MEDIUM);
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
H1 title = new H1("Administrator Dashboard");
|
H1 title = new H1("Administrator Dashboard");
|
||||||
@@ -170,7 +163,7 @@ public class AdminDashboardView extends Main {
|
|||||||
cards.add(createStatCard("App-Benutzer", String.valueOf(totalAppUsers), VaadinIcon.MOBILE, "purple"));
|
cards.add(createStatCard("App-Benutzer", String.valueOf(totalAppUsers), VaadinIcon.MOBILE, "purple"));
|
||||||
|
|
||||||
// Current time
|
// Current time
|
||||||
String currentTime = LocalDateTime.now().format(dateTimeFormatter);
|
String currentTime = DateTimeFormatUtil.formatDateTime(LocalDateTime.now());
|
||||||
cards.add(createStatCard("Letzte Aktualisierung", currentTime, VaadinIcon.CLOCK, "gray"));
|
cards.add(createStatCard("Letzte Aktualisierung", currentTime, VaadinIcon.CLOCK, "gray"));
|
||||||
|
|
||||||
section.add(title, cards);
|
section.add(title, cards);
|
||||||
@@ -240,7 +233,8 @@ public class AdminDashboardView extends Main {
|
|||||||
|
|
||||||
// Completion rate
|
// Completion rate
|
||||||
double completionRate = totalTasks > 0 ? (completedTasks * 100.0 / totalTasks) : 0;
|
double completionRate = totalTasks > 0 ? (completedTasks * 100.0 / totalTasks) : 0;
|
||||||
cards.add(createStatCard("Erfolgsquote", String.format("%.1f%%", completionRate), VaadinIcon.TRENDING_UP, "purple"));
|
cards.add(createStatCard("Erfolgsquote", String.format("%.1f%%", completionRate), VaadinIcon.TRENDING_UP,
|
||||||
|
"purple"));
|
||||||
|
|
||||||
section.add(title, cards);
|
section.add(title, cards);
|
||||||
return section;
|
return section;
|
||||||
@@ -321,11 +315,8 @@ public class AdminDashboardView extends Main {
|
|||||||
private Div createStatCard(String title, String value, VaadinIcon icon, String color) {
|
private Div createStatCard(String title, String value, VaadinIcon icon, String color) {
|
||||||
Div card = new Div();
|
Div card = new Div();
|
||||||
card.addClassName(LumoUtility.Background.BASE);
|
card.addClassName(LumoUtility.Background.BASE);
|
||||||
card.getStyle()
|
card.getStyle().set("border-radius", "8px").set("padding", "1rem")
|
||||||
.set("border-radius", "8px")
|
.set("box-shadow", "0 2px 4px rgba(0,0,0,0.1)").set("min-width", "200px")
|
||||||
.set("padding", "1rem")
|
|
||||||
.set("box-shadow", "0 2px 4px rgba(0,0,0,0.1)")
|
|
||||||
.set("min-width", "200px")
|
|
||||||
.set("border-left", "4px solid var(--lumo-" + color + "-color, #007bff)");
|
.set("border-left", "4px solid var(--lumo-" + color + "-color, #007bff)");
|
||||||
|
|
||||||
HorizontalLayout header = new HorizontalLayout();
|
HorizontalLayout header = new HorizontalLayout();
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ public class AdminPricetableView extends VerticalLayout {
|
|||||||
setPadding(false);
|
setPadding(false);
|
||||||
getStyle().set("margin", "14px");
|
getStyle().set("margin", "14px");
|
||||||
setWidth("90%");
|
setWidth("90%");
|
||||||
|
|
||||||
H2 title = new H2("Preis-Tabelle");
|
H2 title = new H2("Preis-Tabelle");
|
||||||
add(title);
|
add(title);
|
||||||
|
|
||||||
VerticalLayout fieldsLayout = new VerticalLayout();
|
VerticalLayout fieldsLayout = new VerticalLayout();
|
||||||
fieldsLayout.setSpacing(true);
|
fieldsLayout.setSpacing(true);
|
||||||
fieldsLayout.setPadding(false);
|
fieldsLayout.setPadding(false);
|
||||||
@@ -66,9 +66,7 @@ public class AdminPricetableView extends VerticalLayout {
|
|||||||
private void savePriceTable() {
|
private void savePriceTable() {
|
||||||
try {
|
try {
|
||||||
// Get first entry or create new one
|
// Get first entry or create new one
|
||||||
PriceTable priceTable = priceTableRepository.findAll().stream()
|
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(new PriceTable());
|
||||||
.findFirst()
|
|
||||||
.orElse(new PriceTable());
|
|
||||||
|
|
||||||
priceTable.setMonthlyBasePackage(monthlyBasePackage.getValue());
|
priceTable.setMonthlyBasePackage(monthlyBasePackage.getValue());
|
||||||
priceTable.setAppUsageLicense(appUsageLicense.getValue());
|
priceTable.setAppUsageLicense(appUsageLicense.getValue());
|
||||||
@@ -83,17 +81,19 @@ public class AdminPricetableView extends VerticalLayout {
|
|||||||
|
|
||||||
private void loadPriceTable() {
|
private void loadPriceTable() {
|
||||||
try {
|
try {
|
||||||
PriceTable priceTable = priceTableRepository.findAll().stream()
|
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(null);
|
||||||
.findFirst()
|
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
if (priceTable != null) {
|
if (priceTable != null) {
|
||||||
monthlyBasePackage.setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : "");
|
monthlyBasePackage
|
||||||
appUsageLicense.setValue(priceTable.getAppUsageLicense() != null ? priceTable.getAppUsageLicense() : "");
|
.setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : "");
|
||||||
revenueParticipation.setValue(priceTable.getRevenueParticipation() != null ? priceTable.getRevenueParticipation() : "");
|
appUsageLicense
|
||||||
|
.setValue(priceTable.getAppUsageLicense() != null ? priceTable.getAppUsageLicense() : "");
|
||||||
|
revenueParticipation.setValue(
|
||||||
|
priceTable.getRevenueParticipation() != null ? priceTable.getRevenueParticipation() : "");
|
||||||
}
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Notification.show("Fehler beim Laden der Daten: " + ex.getMessage(), 5000, Notification.Position.BOTTOM_CENTER);
|
Notification.show("Fehler beim Laden der Daten: " + ex.getMessage(), 5000,
|
||||||
|
Notification.Position.BOTTOM_CENTER);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import jakarta.annotation.security.RolesAllowed;
|
|||||||
|
|
||||||
@Route(value = "dashboard", layout = MainLayout.class)
|
@Route(value = "dashboard", layout = MainLayout.class)
|
||||||
@PageTitle("VotianLT - Dashboard")
|
@PageTitle("VotianLT - Dashboard")
|
||||||
@RolesAllowed({"USER"})
|
@RolesAllowed({ "USER" })
|
||||||
public class AuthenticatedStartView extends VerticalLayout {
|
public class AuthenticatedStartView extends VerticalLayout {
|
||||||
|
|
||||||
private final SecurityService securityService;
|
private final SecurityService securityService;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import jakarta.annotation.security.RolesAllowed;
|
|||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
|
|
||||||
@PageTitle("App-Nutzer bearbeiten")
|
@PageTitle("App-Nutzer bearbeiten")
|
||||||
@Route(value = "edit-app-user", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
@Route(value = "edit-app-user", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
||||||
@RolesAllowed({ "USER", "ADMIN" })
|
@RolesAllowed({ "USER", "ADMIN" })
|
||||||
@@ -194,7 +193,6 @@ public class EditAppUserView extends VerticalLayout implements HasUrlParameter<S
|
|||||||
appUser.setPassword(originalPassword);
|
appUser.setPassword(originalPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
appUserService.updateAppUser(appUser);
|
appUserService.updateAppUser(appUser);
|
||||||
Notification.show("App-Nutzer erfolgreich gespeichert", 3000, Notification.Position.MIDDLE);
|
Notification.show("App-Nutzer erfolgreich gespeichert", 3000, Notification.Position.MIDDLE);
|
||||||
navigateBack();
|
navigateBack();
|
||||||
@@ -230,7 +228,6 @@ public class EditAppUserView extends VerticalLayout implements HasUrlParameter<S
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void deleteAppUser() {
|
private void deleteAppUser() {
|
||||||
// Show confirmation dialog
|
// Show confirmation dialog
|
||||||
com.vaadin.flow.component.dialog.Dialog confirmDialog = new com.vaadin.flow.component.dialog.Dialog();
|
com.vaadin.flow.component.dialog.Dialog confirmDialog = new com.vaadin.flow.component.dialog.Dialog();
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public class EditProfileView extends HorizontalLayout {
|
|||||||
private Checkbox billingEnabled;
|
private Checkbox billingEnabled;
|
||||||
|
|
||||||
public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService,
|
public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService,
|
||||||
CustomerInvoiceService customerInvoiceService, SecurityService securityService) {
|
CustomerInvoiceService customerInvoiceService, SecurityService securityService) {
|
||||||
this.userInvoiceDataService = userInvoiceDataService;
|
this.userInvoiceDataService = userInvoiceDataService;
|
||||||
this.customerInvoiceService = customerInvoiceService;
|
this.customerInvoiceService = customerInvoiceService;
|
||||||
this.currentUser = securityService.getCurrentDatabaseUser();
|
this.currentUser = securityService.getCurrentDatabaseUser();
|
||||||
@@ -202,24 +202,25 @@ public class EditProfileView extends HorizontalLayout {
|
|||||||
binder.forField(companyField).asRequired("Firma ist erforderlich").bind(User::getCompany, User::setCompany);
|
binder.forField(companyField).asRequired("Firma ist erforderlich").bind(User::getCompany, User::setCompany);
|
||||||
binder.forField(companyAddField).bind(User::getCompanyAddition, User::setCompanyAddition);
|
binder.forField(companyAddField).bind(User::getCompanyAddition, User::setCompanyAddition);
|
||||||
binder.forField(streetField).asRequired("Straße ist erforderlich").bind(User::getStreet, User::setStreet);
|
binder.forField(streetField).asRequired("Straße ist erforderlich").bind(User::getStreet, User::setStreet);
|
||||||
binder.forField(houseNumberField).asRequired("Hausnummer ist erforderlich").bind(User::getHouseNumber, User::setHouseNumber);
|
binder.forField(houseNumberField).asRequired("Hausnummer ist erforderlich").bind(User::getHouseNumber,
|
||||||
|
User::setHouseNumber);
|
||||||
binder.forField(addressAddField).bind(User::getAddressAddition, User::setAddressAddition);
|
binder.forField(addressAddField).bind(User::getAddressAddition, User::setAddressAddition);
|
||||||
binder.forField(zipField).asRequired("Postleitzahl ist erforderlich").bind(User::getZip, User::setZip);
|
binder.forField(zipField).asRequired("Postleitzahl ist erforderlich").bind(User::getZip, User::setZip);
|
||||||
binder.forField(cityField).asRequired("Stadt ist erforderlich").bind(User::getCity, User::setCity);
|
binder.forField(cityField).asRequired("Stadt ist erforderlich").bind(User::getCity, User::setCity);
|
||||||
|
|
||||||
// Personendaten binden
|
// Personendaten binden
|
||||||
binder.forField(firstnameField).asRequired("Vorname ist erforderlich").bind(User::getFirstname, User::setFirstname);
|
binder.forField(firstnameField).asRequired("Vorname ist erforderlich").bind(User::getFirstname,
|
||||||
|
User::setFirstname);
|
||||||
binder.forField(lastnameField).asRequired("Nachname ist erforderlich").bind(User::getName, User::setName);
|
binder.forField(lastnameField).asRequired("Nachname ist erforderlich").bind(User::getName, User::setName);
|
||||||
binder.forField(phoneField).asRequired("Telefonnummer ist erforderlich").bind(User::getPhone, User::setPhone);
|
binder.forField(phoneField).asRequired("Telefonnummer ist erforderlich").bind(User::getPhone, User::setPhone);
|
||||||
binder.forField(emailField).asRequired("E-Mail ist erforderlich").withValidator(new EmailValidator("Ungültige E-Mail-Adresse"))
|
binder.forField(emailField).asRequired("E-Mail ist erforderlich")
|
||||||
.bind(User::getEmail, User::setEmail);
|
.withValidator(new EmailValidator("Ungültige E-Mail-Adresse")).bind(User::getEmail, User::setEmail);
|
||||||
// Optionale Felder
|
// Optionale Felder
|
||||||
binder.forField(mobileField).bind(User::getPhone2, User::setPhone2);
|
binder.forField(mobileField).bind(User::getPhone2, User::setPhone2);
|
||||||
binder.forField(faxField).bind(User::getFax, User::setFax);
|
binder.forField(faxField).bind(User::getFax, User::setFax);
|
||||||
|
|
||||||
// Abweichende Rechnungsadresse binden
|
// Abweichende Rechnungsadresse binden
|
||||||
binder.forField(diffInvoiceAddress).bind(
|
binder.forField(diffInvoiceAddress).bind(User::isDiffInvoiceAddress,
|
||||||
User::isDiffInvoiceAddress,
|
|
||||||
(user, value) -> user.setDiffInvoiceAddress(Boolean.TRUE.equals(value)));
|
(user, value) -> user.setDiffInvoiceAddress(Boolean.TRUE.equals(value)));
|
||||||
binder.forField(invCompanyField).bind(User::getInvCompany, User::setInvCompany);
|
binder.forField(invCompanyField).bind(User::getInvCompany, User::setInvCompany);
|
||||||
binder.forField(invCompanyAddField).bind(User::getInvCompanyAddition, User::setInvCompanyAddition);
|
binder.forField(invCompanyAddField).bind(User::getInvCompanyAddition, User::setInvCompanyAddition);
|
||||||
@@ -364,16 +365,14 @@ public class EditProfileView extends HorizontalLayout {
|
|||||||
|
|
||||||
Span digitalProcessInfo = new Span("Aktiviert die digitale Auftragsabwicklung über die mobile App");
|
Span digitalProcessInfo = new Span("Aktiviert die digitale Auftragsabwicklung über die mobile App");
|
||||||
digitalProcessInfo.getStyle().set("font-size", "var(--lumo-font-size-s)")
|
digitalProcessInfo.getStyle().set("font-size", "var(--lumo-font-size-s)")
|
||||||
.set("color", "var(--lumo-secondary-text-color)")
|
.set("color", "var(--lumo-secondary-text-color)").set("margin-left", "var(--lumo-space-xl)");
|
||||||
.set("margin-left", "var(--lumo-space-xl)");
|
|
||||||
|
|
||||||
Checkbox locateAppUser = new Checkbox("App-Nutzer orten");
|
Checkbox locateAppUser = new Checkbox("App-Nutzer orten");
|
||||||
locateAppUser.setValue(currentUser.isLocationTrackingEnabled());
|
locateAppUser.setValue(currentUser.isLocationTrackingEnabled());
|
||||||
|
|
||||||
Span locateAppUserInfo = new Span("Ermöglicht die Ortung von App-Nutzern während der Auftragsausführung");
|
Span locateAppUserInfo = new Span("Ermöglicht die Ortung von App-Nutzern während der Auftragsausführung");
|
||||||
locateAppUserInfo.getStyle().set("font-size", "var(--lumo-font-size-s)")
|
locateAppUserInfo.getStyle().set("font-size", "var(--lumo-font-size-s)")
|
||||||
.set("color", "var(--lumo-secondary-text-color)")
|
.set("color", "var(--lumo-secondary-text-color)").set("margin-left", "var(--lumo-space-xl)");
|
||||||
.set("margin-left", "var(--lumo-space-xl)");
|
|
||||||
|
|
||||||
// Save checkbox states when changed
|
// Save checkbox states when changed
|
||||||
digitalProcess.addValueChangeListener(e -> {
|
digitalProcess.addValueChangeListener(e -> {
|
||||||
@@ -403,8 +402,7 @@ public class EditProfileView extends HorizontalLayout {
|
|||||||
|
|
||||||
Span twoFactorDescription = new Span("Bei Aktivierung wird bei jeder Anmeldung ein Code per E-Mail gesendet");
|
Span twoFactorDescription = new Span("Bei Aktivierung wird bei jeder Anmeldung ein Code per E-Mail gesendet");
|
||||||
twoFactorDescription.getStyle().set("font-size", "var(--lumo-font-size-s)")
|
twoFactorDescription.getStyle().set("font-size", "var(--lumo-font-size-s)")
|
||||||
.set("color", "var(--lumo-secondary-text-color)")
|
.set("color", "var(--lumo-secondary-text-color)").set("margin-left", "var(--lumo-space-xl)");
|
||||||
.set("margin-left", "var(--lumo-space-xl)");
|
|
||||||
|
|
||||||
securityTab.add(twoFactorLayout, twoFactorDescription);
|
securityTab.add(twoFactorLayout, twoFactorDescription);
|
||||||
|
|
||||||
@@ -426,10 +424,11 @@ public class EditProfileView extends HorizontalLayout {
|
|||||||
saveProfile.addClickListener(e -> {
|
saveProfile.addClickListener(e -> {
|
||||||
// Validate all required fields first
|
// Validate all required fields first
|
||||||
boolean isValid = validateAllProfileFields(companyField, firstnameField, lastnameField, phoneField,
|
boolean isValid = validateAllProfileFields(companyField, firstnameField, lastnameField, phoneField,
|
||||||
emailField, streetField, houseNumberField, zipField, cityField);
|
emailField, streetField, houseNumberField, zipField, cityField);
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
Notification.show("Bitte füllen Sie alle Pflichtfelder korrekt aus", 3000, Notification.Position.MIDDLE);
|
Notification.show("Bitte füllen Sie alle Pflichtfelder korrekt aus", 3000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,21 +570,18 @@ public class EditProfileView extends HorizontalLayout {
|
|||||||
List<CustomerInvoiceItem> items = new ArrayList<>();
|
List<CustomerInvoiceItem> items = new ArrayList<>();
|
||||||
BigDecimal vatRate = parseVatRate(safe(taxRateField));
|
BigDecimal vatRate = parseVatRate(safe(taxRateField));
|
||||||
|
|
||||||
CustomerInvoiceItem item1 = new CustomerInvoiceItem(
|
CustomerInvoiceItem item1 = new CustomerInvoiceItem(new BigDecimal("1"), "Stk.",
|
||||||
new BigDecimal("1"), "Stk.", "Beispiel-Dienstleistung 1",
|
"Beispiel-Dienstleistung 1", new BigDecimal("100.00"), vatRate);
|
||||||
new BigDecimal("100.00"), vatRate);
|
CustomerInvoiceItem item2 = new CustomerInvoiceItem(new BigDecimal("2"), "Std.",
|
||||||
CustomerInvoiceItem item2 = new CustomerInvoiceItem(
|
"Beispiel-Dienstleistung 2", new BigDecimal("50.00"), vatRate);
|
||||||
new BigDecimal("2"), "Std.", "Beispiel-Dienstleistung 2",
|
|
||||||
new BigDecimal("50.00"), vatRate);
|
|
||||||
|
|
||||||
items.add(item1);
|
items.add(item1);
|
||||||
items.add(item2);
|
items.add(item2);
|
||||||
invoiceData.setItems(items);
|
invoiceData.setItems(items);
|
||||||
|
|
||||||
// Calculate amounts
|
// Calculate amounts
|
||||||
BigDecimal netAmount = items.stream()
|
BigDecimal netAmount = items.stream().map(CustomerInvoiceItem::getNetTotal).reduce(BigDecimal.ZERO,
|
||||||
.map(CustomerInvoiceItem::getNetTotal)
|
BigDecimal::add);
|
||||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
||||||
BigDecimal vatAmount = netAmount.multiply(vatRate);
|
BigDecimal vatAmount = netAmount.multiply(vatRate);
|
||||||
BigDecimal totalAmount = netAmount.add(vatAmount);
|
BigDecimal totalAmount = netAmount.add(vatAmount);
|
||||||
|
|
||||||
@@ -664,18 +660,9 @@ public class EditProfileView extends HorizontalLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void saveInvoiceData() {
|
private void saveInvoiceData() {
|
||||||
currentInvoiceData = userInvoiceDataService.createOrUpdate(
|
currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(),
|
||||||
currentUser.getId(),
|
prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(),
|
||||||
billingEnabled.getValue(),
|
ibanField.getValue(), taxRateField.getValue(), introTextArea.getValue(), termsTextArea.getValue());
|
||||||
prefixField.getValue(),
|
|
||||||
ustIdField.getValue(),
|
|
||||||
taxNumberField.getValue(),
|
|
||||||
bankNameField.getValue(),
|
|
||||||
ibanField.getValue(),
|
|
||||||
taxRateField.getValue(),
|
|
||||||
introTextArea.getValue(),
|
|
||||||
termsTextArea.getValue()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String safe(String value) {
|
private String safe(String value) {
|
||||||
@@ -708,8 +695,8 @@ public class EditProfileView extends HorizontalLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean validateAllProfileFields(TextField companyField, TextField firstnameField, TextField lastnameField,
|
private boolean validateAllProfileFields(TextField companyField, TextField firstnameField, TextField lastnameField,
|
||||||
TextField phoneField, EmailField emailField, TextField streetField,
|
TextField phoneField, EmailField emailField, TextField streetField, TextField houseNumberField,
|
||||||
TextField houseNumberField, TextField zipField, TextField cityField) {
|
TextField zipField, TextField cityField) {
|
||||||
validateField(companyField, "Firma ist ein Pflichtfeld");
|
validateField(companyField, "Firma ist ein Pflichtfeld");
|
||||||
validateField(firstnameField, "Vorname ist ein Pflichtfeld");
|
validateField(firstnameField, "Vorname ist ein Pflichtfeld");
|
||||||
validateField(lastnameField, "Nachname ist ein Pflichtfeld");
|
validateField(lastnameField, "Nachname ist ein Pflichtfeld");
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import de.assecutor.votianlt.model.invoices.SystemInvoice;
|
|||||||
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
|
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
|
||||||
import de.assecutor.votianlt.model.invoices.SystemInvoiceItem;
|
import de.assecutor.votianlt.model.invoices.SystemInvoiceItem;
|
||||||
import de.assecutor.votianlt.service.SystemInvoiceService;
|
import de.assecutor.votianlt.service.SystemInvoiceService;
|
||||||
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.text.NumberFormat;
|
import java.text.NumberFormat;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -94,12 +94,11 @@ public class InvoicesView extends VerticalLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private byte[] generateSystemInvoicePdf(SystemInvoice systemInvoice) throws Exception {
|
private byte[] generateSystemInvoicePdf(SystemInvoice systemInvoice) throws Exception {
|
||||||
DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
|
||||||
NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
|
NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
|
||||||
|
|
||||||
SystemInvoiceData data = new SystemInvoiceData();
|
SystemInvoiceData data = new SystemInvoiceData();
|
||||||
data.setInvoiceNumber(systemInvoice.getId());
|
data.setInvoiceNumber(systemInvoice.getId());
|
||||||
data.setInvoiceDate(DATE_FMT.format(systemInvoice.getDatum()));
|
data.setInvoiceDate(DateTimeFormatUtil.formatDate(systemInvoice.getDatum()));
|
||||||
data.setInvoiceText(systemInvoice.getBeschreibung());
|
data.setInvoiceText(systemInvoice.getBeschreibung());
|
||||||
|
|
||||||
// Empfänger aus der Zeile (nur Name in den Testdaten vorhanden)
|
// Empfänger aus der Zeile (nur Name in den Testdaten vorhanden)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import de.assecutor.votianlt.repository.JobRepository;
|
|||||||
import de.assecutor.votianlt.repository.PhotoRepository;
|
import de.assecutor.votianlt.repository.PhotoRepository;
|
||||||
import de.assecutor.votianlt.repository.SignatureRepository;
|
import de.assecutor.votianlt.repository.SignatureRepository;
|
||||||
import de.assecutor.votianlt.service.JobHistoryService;
|
import de.assecutor.votianlt.service.JobHistoryService;
|
||||||
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
@@ -294,9 +295,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String> {
|
|||||||
if (dateTime == null)
|
if (dateTime == null)
|
||||||
return "";
|
return "";
|
||||||
try {
|
try {
|
||||||
java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter
|
return DateTimeFormatUtil.formatDateTime(dateTime);
|
||||||
.ofPattern("dd.MM.yyyy HH:mm");
|
|
||||||
return dateTime.format(formatter);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return dateTime.toString();
|
return dateTime.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,12 +42,18 @@ import de.assecutor.votianlt.model.Signature;
|
|||||||
import de.assecutor.votianlt.model.Barcode;
|
import de.assecutor.votianlt.model.Barcode;
|
||||||
import de.assecutor.votianlt.model.Photo;
|
import de.assecutor.votianlt.model.Photo;
|
||||||
import de.assecutor.votianlt.model.Comment;
|
import de.assecutor.votianlt.model.Comment;
|
||||||
|
import de.assecutor.votianlt.model.JobStatus;
|
||||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||||
|
import de.assecutor.votianlt.service.JobHistoryService;
|
||||||
import de.assecutor.votianlt.service.MessageService;
|
import de.assecutor.votianlt.service.MessageService;
|
||||||
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
|
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -66,6 +72,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
|||||||
private final PhotoRepository photoRepository;
|
private final PhotoRepository photoRepository;
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
private final AppUserService appUserService;
|
private final AppUserService appUserService;
|
||||||
|
private final JobHistoryService jobHistoryService;
|
||||||
|
|
||||||
@Value("${app.google.maps.api-key}")
|
@Value("${app.google.maps.api-key}")
|
||||||
private String googleMapsApiKey;
|
private String googleMapsApiKey;
|
||||||
@@ -76,7 +83,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
|||||||
public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository,
|
public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository,
|
||||||
TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository,
|
TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository,
|
||||||
PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService,
|
PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService,
|
||||||
MessageService messageService) {
|
MessageService messageService, JobHistoryService jobHistoryService) {
|
||||||
this.jobRepository = jobRepository;
|
this.jobRepository = jobRepository;
|
||||||
this.cargoItemRepository = cargoItemRepository;
|
this.cargoItemRepository = cargoItemRepository;
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
@@ -85,6 +92,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
|||||||
this.photoRepository = photoRepository;
|
this.photoRepository = photoRepository;
|
||||||
this.commentRepository = commentRepository;
|
this.commentRepository = commentRepository;
|
||||||
this.appUserService = appUserService;
|
this.appUserService = appUserService;
|
||||||
|
this.jobHistoryService = jobHistoryService;
|
||||||
|
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
||||||
@@ -132,17 +140,14 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
|||||||
sendMessageButton.addClickListener(e -> {
|
sendMessageButton.addClickListener(e -> {
|
||||||
// Check if job has an app user assigned
|
// Check if job has an app user assigned
|
||||||
if (job.getAppUser() == null || job.getAppUser().isBlank()) {
|
if (job.getAppUser() == null || job.getAppUser().isBlank()) {
|
||||||
Notification.show(
|
Notification.show("Diesem Auftrag ist kein App-Nutzer zugeordnet", 3000, Notification.Position.MIDDLE)
|
||||||
"Diesem Auftrag ist kein App-Nutzer zugeordnet",
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
3000,
|
|
||||||
Notification.Position.MIDDLE
|
|
||||||
).addThemeVariants(NotificationVariant.LUMO_ERROR);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String appUserId = job.getAppUser();
|
String appUserId = job.getAppUser();
|
||||||
String jobNumber = job.getJobNumber() != null ? job.getJobNumber() : job.getId().toHexString();
|
String jobNumber = job.getJobNumber() != null ? job.getJobNumber() : job.getId().toHexString();
|
||||||
|
|
||||||
// Navigate to message details view with job conversation
|
// Navigate to message details view with job conversation
|
||||||
// Format: message-details/{clientId}/job-{jobNumber}
|
// Format: message-details/{clientId}/job-{jobNumber}
|
||||||
String conversationId = "job-" + jobNumber;
|
String conversationId = "job-" + jobNumber;
|
||||||
@@ -270,6 +275,51 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
|||||||
|
|
||||||
// Google Maps Karte mit Route
|
// Google Maps Karte mit Route
|
||||||
addRouteMap(job);
|
addRouteMap(job);
|
||||||
|
|
||||||
|
// Manual completion button for jobs without digital processing
|
||||||
|
if (!job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED
|
||||||
|
&& job.getStatus() != JobStatus.CANCELLED) {
|
||||||
|
HorizontalLayout buttonRow = new HorizontalLayout();
|
||||||
|
buttonRow.setWidthFull();
|
||||||
|
buttonRow.setJustifyContentMode(HorizontalLayout.JustifyContentMode.CENTER);
|
||||||
|
buttonRow.getStyle().set("margin-top", "var(--lumo-space-l)");
|
||||||
|
|
||||||
|
Button completeButton = new Button("Auftrag manuell abschließen", new Icon(VaadinIcon.CHECK_CIRCLE));
|
||||||
|
completeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
|
||||||
|
completeButton.addClickListener(e -> {
|
||||||
|
ConfirmDialog dialog = new ConfirmDialog();
|
||||||
|
dialog.setHeader("Auftrag abschließen");
|
||||||
|
dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + " manuell abschließen?");
|
||||||
|
dialog.setCancelable(true);
|
||||||
|
dialog.setCancelText("Abbrechen");
|
||||||
|
dialog.setConfirmText("Abschließen");
|
||||||
|
dialog.setConfirmButtonTheme("primary");
|
||||||
|
dialog.addConfirmListener(ev -> {
|
||||||
|
try {
|
||||||
|
JobStatus oldStatus = job.getStatus();
|
||||||
|
job.setStatus(JobStatus.COMPLETED);
|
||||||
|
job.setUpdatedAt(LocalDateTime.now());
|
||||||
|
jobRepository.save(job);
|
||||||
|
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
|
||||||
|
Notification
|
||||||
|
.show("Auftrag " + job.getJobNumber() + " wurde abgeschlossen.", 3000,
|
||||||
|
Notification.Position.BOTTOM_END)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||||
|
// Re-render the page
|
||||||
|
getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString()));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Notification
|
||||||
|
.show("Fehler beim Abschließen: " + ex.getMessage(), 5000,
|
||||||
|
Notification.Position.BOTTOM_END)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonRow.add(completeButton);
|
||||||
|
content.add(buttonRow);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private VerticalLayout borderedBox() {
|
private VerticalLayout borderedBox() {
|
||||||
@@ -284,9 +334,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
|||||||
|
|
||||||
private String formatLocalDate(java.time.LocalDate date) {
|
private String formatLocalDate(java.time.LocalDate date) {
|
||||||
try {
|
try {
|
||||||
java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
return DateTimeFormatUtil.formatDate(date);
|
||||||
.withLocale(Locale.GERMANY);
|
|
||||||
return date.format(fmt);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -406,8 +454,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
|||||||
+ " }" + " });" + " if (!bounds.isEmpty()) { map.fitBounds(bounds); }"
|
+ " }" + " });" + " if (!bounds.isEmpty()) { map.fitBounds(bounds); }"
|
||||||
+ " }});" + " }" + " if (!(window.google && window.google.maps)) {"
|
+ " }});" + " }" + " if (!(window.google && window.google.maps)) {"
|
||||||
+ " var s=document.createElement('script');"
|
+ " var s=document.createElement('script');"
|
||||||
+ " s.src='https://maps.googleapis.com/maps/api/js?key=" + getGoogleMapsApiKey() + "&libraries=places';"
|
+ " s.src='https://maps.googleapis.com/maps/api/js?key=" + getGoogleMapsApiKey()
|
||||||
+ " s.onload=init; document.head.appendChild(s);" + " } else { init(); }" + "})();");
|
+ "&libraries=places';" + " s.onload=init; document.head.appendChild(s);" + " } else { init(); }"
|
||||||
|
+ "})();");
|
||||||
|
|
||||||
map.getElement().executeJs(js, map.getElement(), routeInfo.getElement());
|
map.getElement().executeJs(js, map.getElement(), routeInfo.getElement());
|
||||||
}
|
}
|
||||||
@@ -636,13 +685,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
|||||||
|
|
||||||
for (Comment comment : comments) {
|
for (Comment comment : comments) {
|
||||||
Div commentContainer = new Div();
|
Div commentContainer = new Div();
|
||||||
commentContainer.getStyle()
|
commentContainer.getStyle().set("background-color", "#f5f5f5")
|
||||||
.set("background-color", "#f5f5f5")
|
.set("border", "1px solid #ddd").set("border-radius", "4px").set("padding", "8px")
|
||||||
.set("border", "1px solid #ddd")
|
.set("margin", "4px 0").set("font-family", "monospace")
|
||||||
.set("border-radius", "4px")
|
|
||||||
.set("padding", "8px")
|
|
||||||
.set("margin", "4px 0")
|
|
||||||
.set("font-family", "monospace")
|
|
||||||
.set("white-space", "pre-wrap");
|
.set("white-space", "pre-wrap");
|
||||||
|
|
||||||
Span commentText = new Span(comment.getCommentText());
|
Span commentText = new Span(comment.getCommentText());
|
||||||
@@ -658,13 +703,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String formatDateTime(java.time.LocalDateTime dateTime) {
|
private String formatDateTime(java.time.LocalDateTime dateTime) {
|
||||||
try {
|
return DateTimeFormatUtil.formatDateTime(dateTime);
|
||||||
java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
|
|
||||||
.withLocale(Locale.GERMANY);
|
|
||||||
return dateTime.format(fmt);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return dateTime.toString();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Div createTaskCard(BaseTask task, String displayName) {
|
private Div createTaskCard(BaseTask task, String displayName) {
|
||||||
|
|||||||
@@ -93,8 +93,7 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af
|
|||||||
// Version display - will be set in @PostConstruct
|
// Version display - will be set in @PostConstruct
|
||||||
versionSpan = new Span("");
|
versionSpan = new Span("");
|
||||||
versionSpan.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
versionSpan.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
||||||
.set("font-size", "var(--lumo-font-size-s)")
|
.set("font-size", "var(--lumo-font-size-s)").set("margin-top", "var(--lumo-space-m)");
|
||||||
.set("margin-top", "var(--lumo-space-m)");
|
|
||||||
|
|
||||||
// Inline flash message box (hidden by default)
|
// Inline flash message box (hidden by default)
|
||||||
flashBox.getStyle().set("background", "var(--lumo-error-color-10pct)")
|
flashBox.getStyle().set("background", "var(--lumo-error-color-10pct)")
|
||||||
@@ -139,8 +138,7 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af
|
|||||||
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
|
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
|
||||||
|
|
||||||
// Prüfe ob 2FA für diesen Nutzer aktiviert ist (global UND nutzer-spezifisch)
|
// Prüfe ob 2FA für diesen Nutzer aktiviert ist (global UND nutzer-spezifisch)
|
||||||
boolean userTwoFactorEnabled = userRepository.findByEmail(username)
|
boolean userTwoFactorEnabled = userRepository.findByEmail(username).map(User::isTwoFactorEnabled)
|
||||||
.map(User::isTwoFactorEnabled)
|
|
||||||
.orElse(true); // Standardmäßig aktiviert falls Nutzer nicht gefunden
|
.orElse(true); // Standardmäßig aktiviert falls Nutzer nicht gefunden
|
||||||
|
|
||||||
if (twoFactorEnabledGlobal && userTwoFactorEnabled) {
|
if (twoFactorEnabledGlobal && userTwoFactorEnabled) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import de.assecutor.votianlt.model.MessageType;
|
|||||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||||
import de.assecutor.votianlt.service.MessageBroadcaster;
|
import de.assecutor.votianlt.service.MessageBroadcaster;
|
||||||
import de.assecutor.votianlt.service.MessageService;
|
import de.assecutor.votianlt.service.MessageService;
|
||||||
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
import de.assecutor.votianlt.event.MessageReadStatusChangedEvent;
|
import de.assecutor.votianlt.event.MessageReadStatusChangedEvent;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
@@ -58,7 +59,6 @@ import javax.imageio.ImageWriter;
|
|||||||
import javax.imageio.stream.ImageOutputStream;
|
import javax.imageio.stream.ImageOutputStream;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -89,15 +89,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
private List<Message> currentMessages; // Current messages being displayed for redrawing
|
private List<Message> currentMessages; // Current messages being displayed for redrawing
|
||||||
private Registration broadcasterRegistration; // Track listener registration
|
private Registration broadcasterRegistration; // Track listener registration
|
||||||
private TextArea messageInput;
|
private TextArea messageInput;
|
||||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
|
|
||||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
|
||||||
private static final int MAX_IMAGE_FILE_SIZE = 32 * 1024 * 1024; // 32 MB aligns with Spring settings
|
private static final int MAX_IMAGE_FILE_SIZE = 32 * 1024 * 1024; // 32 MB aligns with Spring settings
|
||||||
private static final int TARGET_IMAGE_WIDTH = 1920;
|
private static final int TARGET_IMAGE_WIDTH = 1920;
|
||||||
private static final float JPEG_COMPRESSION_QUALITY = 0.8f;
|
private static final float JPEG_COMPRESSION_QUALITY = 0.8f;
|
||||||
|
|
||||||
public MessageDetailsView(AppUserService appUserService, MessageService messageService,
|
public MessageDetailsView(AppUserService appUserService, MessageService messageService,
|
||||||
MessageBroadcaster messageBroadcaster,
|
MessageBroadcaster messageBroadcaster, ApplicationEventPublisher eventPublisher) {
|
||||||
ApplicationEventPublisher eventPublisher) {
|
|
||||||
this.appUserService = appUserService;
|
this.appUserService = appUserService;
|
||||||
this.messageService = messageService;
|
this.messageService = messageService;
|
||||||
this.messageBroadcaster = messageBroadcaster;
|
this.messageBroadcaster = messageBroadcaster;
|
||||||
@@ -128,7 +125,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
log.info("MessageDetailsView - participant: {}, conversationId: {}", participantKey, conversationId);
|
log.info("MessageDetailsView - participant: {}, conversationId: {}", participantKey, conversationId);
|
||||||
|
|
||||||
if (participantKey == null || conversationId == null) {
|
if (participantKey == null || conversationId == null) {
|
||||||
log.warn("Missing required route parameters: participantKey={}, conversationId={}", participantKey, conversationId);
|
log.warn("Missing required route parameters: participantKey={}, conversationId={}", participantKey,
|
||||||
|
conversationId);
|
||||||
event.rerouteToError(IllegalArgumentException.class, "Missing required parameters");
|
event.rerouteToError(IllegalArgumentException.class, "Missing required parameters");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -175,10 +173,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
messagesContainer.setPadding(false);
|
messagesContainer.setPadding(false);
|
||||||
messagesContainer.setSpacing(false);
|
messagesContainer.setSpacing(false);
|
||||||
messagesContainer.setWidthFull();
|
messagesContainer.setWidthFull();
|
||||||
messagesContainer.getStyle()
|
messagesContainer.getStyle().set("background-color", "#f0f0f0").set("border-radius", "8px").set("padding",
|
||||||
.set("background-color", "#f0f0f0")
|
"15px");
|
||||||
.set("border-radius", "8px")
|
|
||||||
.set("padding", "15px");
|
|
||||||
|
|
||||||
// Wrap messages container in scroller for vertical scrolling
|
// Wrap messages container in scroller for vertical scrolling
|
||||||
messagesScroller = new Scroller(messagesContainer);
|
messagesScroller = new Scroller(messagesContainer);
|
||||||
@@ -229,14 +225,15 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
messagesContainer.add(createMessageBubble(message, timestamp));
|
messagesContainer.add(createMessageBubble(message, timestamp));
|
||||||
}
|
}
|
||||||
|
|
||||||
// After rendering, mark any unread messages directed to the current user as read
|
// After rendering, mark any unread messages directed to the current user as
|
||||||
|
// read
|
||||||
markVisibleMessagesAsRead();
|
markVisibleMessagesAsRead();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks all currently visible messages that are addressed to the logged-in user as read.
|
* Marks all currently visible messages that are addressed to the logged-in user
|
||||||
* This is triggered after (re)rendering the conversation and will also update the in-memory
|
* as read. This is triggered after (re)rendering the conversation and will also
|
||||||
* message objects to keep UI state consistent.
|
* update the in-memory message objects to keep UI state consistent.
|
||||||
*/
|
*/
|
||||||
private void markVisibleMessagesAsRead() {
|
private void markVisibleMessagesAsRead() {
|
||||||
try {
|
try {
|
||||||
@@ -278,27 +275,19 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
upload.setWidthFull();
|
upload.setWidthFull();
|
||||||
|
|
||||||
Span helper = new Span("Unterstützte Formate: PNG, JPG, GIF, WebP (max. 32 MB)");
|
Span helper = new Span("Unterstützte Formate: PNG, JPG, GIF, WebP (max. 32 MB)");
|
||||||
helper.getStyle()
|
helper.getStyle().set("font-size", "12px").set("color", "#666666");
|
||||||
.set("font-size", "12px")
|
|
||||||
.set("color", "#666666");
|
|
||||||
|
|
||||||
Image preview = new Image();
|
Image preview = new Image();
|
||||||
preview.setAlt("Vorschau des ausgewählten Bildes");
|
preview.setAlt("Vorschau des ausgewählten Bildes");
|
||||||
preview.setVisible(false);
|
preview.setVisible(false);
|
||||||
preview.setWidth(null);
|
preview.setWidth(null);
|
||||||
preview.setHeight(null);
|
preview.setHeight(null);
|
||||||
preview.getStyle()
|
preview.getStyle().set("max-width", "100%").set("max-height", "320px").set("height", "auto")
|
||||||
.set("max-width", "100%")
|
.set("border-radius", "12px").set("display", "inline-block");
|
||||||
.set("max-height", "320px")
|
|
||||||
.set("height", "auto")
|
|
||||||
.set("border-radius", "12px")
|
|
||||||
.set("display", "inline-block");
|
|
||||||
|
|
||||||
Div previewWrapper = new Div(preview);
|
Div previewWrapper = new Div(preview);
|
||||||
previewWrapper.setWidthFull();
|
previewWrapper.setWidthFull();
|
||||||
previewWrapper.getStyle()
|
previewWrapper.getStyle().set("text-align", "center").set("margin-top", "10px");
|
||||||
.set("text-align", "center")
|
|
||||||
.set("margin-top", "10px");
|
|
||||||
|
|
||||||
AtomicReference<String> base64Ref = new AtomicReference<>();
|
AtomicReference<String> base64Ref = new AtomicReference<>();
|
||||||
|
|
||||||
@@ -325,8 +314,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
base64Ref.set(null);
|
base64Ref.set(null);
|
||||||
preview.setVisible(false);
|
preview.setVisible(false);
|
||||||
confirmButton.setEnabled(false);
|
confirmButton.setEnabled(false);
|
||||||
Notification.show("Das Bild konnte nicht verarbeitet werden.", 3000,
|
Notification.show("Das Bild konnte nicht verarbeitet werden.", 3000, Notification.Position.MIDDLE)
|
||||||
Notification.Position.MIDDLE).addThemeVariants(NotificationVariant.LUMO_ERROR);
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,22 +389,14 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
*/
|
*/
|
||||||
private Div createDateSeparator(LocalDate date) {
|
private Div createDateSeparator(LocalDate date) {
|
||||||
Div separator = new Div();
|
Div separator = new Div();
|
||||||
separator.getStyle()
|
separator.getStyle().set("display", "flex").set("justify-content", "center").set("text-align", "center")
|
||||||
.set("display", "flex")
|
.set("margin", "20px 0");
|
||||||
.set("justify-content", "center")
|
|
||||||
.set("text-align", "center")
|
|
||||||
.set("margin", "20px 0");
|
|
||||||
separator.setWidthFull();
|
separator.setWidthFull();
|
||||||
|
|
||||||
Span dateLabel = new Span(date.format(DATE_FORMATTER));
|
Span dateLabel = new Span(DateTimeFormatUtil.formatDate(date));
|
||||||
dateLabel.getStyle()
|
dateLabel.getStyle().set("background-color", "#d0d0d0").set("padding", "4px 10px").set("border-radius", "12px")
|
||||||
.set("background-color", "#d0d0d0")
|
.set("font-size", "12px").set("font-weight", "500").set("color", "#333333")
|
||||||
.set("padding", "4px 10px")
|
.set("display", "inline-block");
|
||||||
.set("border-radius", "12px")
|
|
||||||
.set("font-size", "12px")
|
|
||||||
.set("font-weight", "500")
|
|
||||||
.set("color", "#333333")
|
|
||||||
.set("display", "inline-block");
|
|
||||||
|
|
||||||
separator.add(dateLabel);
|
separator.add(dateLabel);
|
||||||
return separator;
|
return separator;
|
||||||
@@ -426,41 +407,31 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
*/
|
*/
|
||||||
private Div createMessageBubble(Message message, LocalDateTime timestamp) {
|
private Div createMessageBubble(Message message, LocalDateTime timestamp) {
|
||||||
// Determine alignment based on message origin
|
// Determine alignment based on message origin
|
||||||
// CLIENT origin = client messages (left), SERVER origin = server messages (right)
|
// CLIENT origin = client messages (left), SERVER origin = server messages
|
||||||
|
// (right)
|
||||||
boolean isServerMessage = message.getOrigin() == MessageOrigin.SERVER;
|
boolean isServerMessage = message.getOrigin() == MessageOrigin.SERVER;
|
||||||
|
|
||||||
// Container for the message (aligns left or right)
|
// Container for the message (aligns left or right)
|
||||||
Div messageWrapper = new Div();
|
Div messageWrapper = new Div();
|
||||||
String alignment = isServerMessage ? "right" : "left";
|
String alignment = isServerMessage ? "right" : "left";
|
||||||
|
|
||||||
messageWrapper.getStyle()
|
messageWrapper.getStyle().set("display", "flex")
|
||||||
.set("display", "flex")
|
.set("justify-content", isServerMessage ? "flex-end" : "flex-start").set("margin", "5px 0")
|
||||||
.set("justify-content", isServerMessage ? "flex-end" : "flex-start")
|
.set("width", "100%");
|
||||||
.set("margin", "5px 0")
|
|
||||||
.set("width", "100%");
|
|
||||||
|
|
||||||
// Message bubble
|
// Message bubble
|
||||||
Div bubble = new Div();
|
Div bubble = new Div();
|
||||||
bubble.getStyle()
|
bubble.getStyle().set("background-color", isServerMessage ? "#dcf8c6" : "#ffffff").set("padding", "10px 15px")
|
||||||
.set("background-color", isServerMessage ? "#dcf8c6" : "#ffffff")
|
.set("border-radius", "18px").set("max-width", "70%").set("box-shadow", "0 1px 2px rgba(0,0,0,0.1)")
|
||||||
.set("padding", "10px 15px")
|
.set("word-wrap", "break-word").set("white-space", "pre-wrap").set("text-align", alignment);
|
||||||
.set("border-radius", "18px")
|
|
||||||
.set("max-width", "70%")
|
|
||||||
.set("box-shadow", "0 1px 2px rgba(0,0,0,0.1)")
|
|
||||||
.set("word-wrap", "break-word")
|
|
||||||
.set("white-space", "pre-wrap")
|
|
||||||
.set("text-align", alignment);
|
|
||||||
|
|
||||||
// Message content component (text or media)
|
// Message content component (text or media)
|
||||||
Component contentComponent = createContentComponent(message, alignment);
|
Component contentComponent = createContentComponent(message, alignment);
|
||||||
|
|
||||||
// Timestamp
|
// Timestamp
|
||||||
Span timeSpan = new Span(timestamp.format(TIME_FORMATTER));
|
Span timeSpan = new Span(DateTimeFormatUtil.formatTime(timestamp));
|
||||||
timeSpan.getStyle()
|
timeSpan.getStyle().set("font-size", "11px").set("color", isServerMessage ? "#666666" : "#999999")
|
||||||
.set("font-size", "11px")
|
.set("display", "block").set("text-align", alignment);
|
||||||
.set("color", isServerMessage ? "#666666" : "#999999")
|
|
||||||
.set("display", "block")
|
|
||||||
.set("text-align", alignment);
|
|
||||||
|
|
||||||
bubble.add(contentComponent, timeSpan);
|
bubble.add(contentComponent, timeSpan);
|
||||||
messageWrapper.add(bubble);
|
messageWrapper.add(bubble);
|
||||||
@@ -471,30 +442,24 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
private Component createContentComponent(Message message, String alignment) {
|
private Component createContentComponent(Message message, String alignment) {
|
||||||
MessageContentType contentType = message.getContentType();
|
MessageContentType contentType = message.getContentType();
|
||||||
return switch (contentType) {
|
return switch (contentType) {
|
||||||
case IMAGE -> createImageContent(message.getContent(), alignment);
|
case IMAGE -> createImageContent(message.getContent(), alignment);
|
||||||
case TEXT -> createTextContent(message.getContent(), alignment);
|
case TEXT -> createTextContent(message.getContent(), alignment);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private Component createTextContent(String contentValue, String alignment) {
|
private Component createTextContent(String contentValue, String alignment) {
|
||||||
Div contentDiv = new Div();
|
Div contentDiv = new Div();
|
||||||
String content = Optional.ofNullable(contentValue).filter(value -> !value.isBlank())
|
String content = Optional.ofNullable(contentValue).filter(value -> !value.isBlank()).orElse("(kein Inhalt)");
|
||||||
.orElse("(kein Inhalt)");
|
|
||||||
contentDiv.setText(content);
|
contentDiv.setText(content);
|
||||||
contentDiv.getStyle()
|
contentDiv.getStyle().set("font-size", "14px").set("color", "#000000").set("margin-bottom", "5px")
|
||||||
.set("font-size", "14px")
|
.set("text-align", alignment);
|
||||||
.set("color", "#000000")
|
|
||||||
.set("margin-bottom", "5px")
|
|
||||||
.set("text-align", alignment);
|
|
||||||
return contentDiv;
|
return contentDiv;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Component createImageContent(String base64Value, String alignment) {
|
private Component createImageContent(String base64Value, String alignment) {
|
||||||
Div wrapper = new Div();
|
Div wrapper = new Div();
|
||||||
wrapper.getStyle()
|
wrapper.getStyle().set("margin-bottom", "5px").set("display", "flex").set("justify-content",
|
||||||
.set("margin-bottom", "5px")
|
"right".equals(alignment) ? "flex-end" : "flex-start");
|
||||||
.set("display", "flex")
|
|
||||||
.set("justify-content", "right".equals(alignment) ? "flex-end" : "flex-start");
|
|
||||||
|
|
||||||
String trimmed = Optional.ofNullable(base64Value).map(String::trim).orElse("");
|
String trimmed = Optional.ofNullable(base64Value).map(String::trim).orElse("");
|
||||||
if (trimmed.isEmpty()) {
|
if (trimmed.isEmpty()) {
|
||||||
@@ -509,12 +474,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Image image = new Image(dataUrl, "Nachrichtenbild");
|
Image image = new Image(dataUrl, "Nachrichtenbild");
|
||||||
image.getStyle()
|
image.getStyle().set("max-width", "100%").set("border-radius", "12px").set("display", "block")
|
||||||
.set("max-width", "100%")
|
.set("max-height", "320px").set("height", "auto");
|
||||||
.set("border-radius", "12px")
|
|
||||||
.set("display", "block")
|
|
||||||
.set("max-height", "320px")
|
|
||||||
.set("height", "auto");
|
|
||||||
image.getElement().setAttribute("loading", "lazy");
|
image.getElement().setAttribute("loading", "lazy");
|
||||||
|
|
||||||
wrapper.add(image);
|
wrapper.add(image);
|
||||||
@@ -676,18 +637,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
i18n.setError(error);
|
i18n.setError(error);
|
||||||
|
|
||||||
UploadI18N.Uploading uploading = new UploadI18N.Uploading();
|
UploadI18N.Uploading uploading = new UploadI18N.Uploading();
|
||||||
uploading.setStatus(new UploadI18N.Uploading.Status()
|
uploading.setStatus(new UploadI18N.Uploading.Status().setConnecting("Verbindung wird aufgebaut...")
|
||||||
.setConnecting("Verbindung wird aufgebaut...")
|
.setStalled("Upload pausiert").setProcessing("Verarbeitung...").setHeld("Warten auf Upload..."));
|
||||||
.setStalled("Upload pausiert")
|
uploading.setRemainingTime(new UploadI18N.Uploading.RemainingTime().setPrefix("Verbleibende Zeit: ")
|
||||||
.setProcessing("Verarbeitung...")
|
|
||||||
.setHeld("Warten auf Upload..."));
|
|
||||||
uploading.setRemainingTime(new UploadI18N.Uploading.RemainingTime()
|
|
||||||
.setPrefix("Verbleibende Zeit: ")
|
|
||||||
.setUnknown("Verbleibende Zeit unbekannt"));
|
.setUnknown("Verbleibende Zeit unbekannt"));
|
||||||
uploading.setError(new UploadI18N.Uploading.Error()
|
uploading.setError(new UploadI18N.Uploading.Error().setServerUnavailable("Server nicht erreichbar")
|
||||||
.setServerUnavailable("Server nicht erreichbar")
|
.setUnexpectedServerError("Unerwarteter Serverfehler").setForbidden("Upload nicht erlaubt"));
|
||||||
.setUnexpectedServerError("Unerwarteter Serverfehler")
|
|
||||||
.setForbidden("Upload nicht erlaubt"));
|
|
||||||
i18n.setUploading(uploading);
|
i18n.setUploading(uploading);
|
||||||
|
|
||||||
return i18n;
|
return i18n;
|
||||||
@@ -704,9 +659,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
if (scrollAnchor == null) {
|
if (scrollAnchor == null) {
|
||||||
scrollAnchor = new Div();
|
scrollAnchor = new Div();
|
||||||
scrollAnchor.setId("scroll-anchor");
|
scrollAnchor.setId("scroll-anchor");
|
||||||
scrollAnchor.getStyle()
|
scrollAnchor.getStyle().set("height", "1px").set("width", "100%");
|
||||||
.set("height", "1px")
|
|
||||||
.set("width", "100%");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scrollAnchor.getParent().isEmpty()) {
|
if (scrollAnchor.getParent().isEmpty()) {
|
||||||
@@ -748,7 +701,6 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private HorizontalLayout createMessageInputArea() {
|
private HorizontalLayout createMessageInputArea() {
|
||||||
messageInput = new TextArea();
|
messageInput = new TextArea();
|
||||||
messageInput.setPlaceholder("Nachricht eingeben...");
|
messageInput.setPlaceholder("Nachricht eingeben...");
|
||||||
@@ -803,12 +755,11 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
Message saved;
|
Message saved;
|
||||||
if (jobConversation) {
|
if (jobConversation) {
|
||||||
// participantKey = AppUser ID (receiver)
|
// participantKey = AppUser ID (receiver)
|
||||||
saved = messageService.sendJobMessageToClient(payload, participantKey,
|
saved = messageService.sendJobMessageToClient(payload, participantKey, contentType, jobIdContext,
|
||||||
contentType, jobIdContext, jobNumberContext);
|
jobNumberContext);
|
||||||
} else {
|
} else {
|
||||||
// participantKey = AppUser ID (receiver)
|
// participantKey = AppUser ID (receiver)
|
||||||
saved = messageService.sendGeneralMessageToClient(payload, participantKey,
|
saved = messageService.sendGeneralMessageToClient(payload, participantKey, contentType);
|
||||||
contentType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark own outgoing message as read immediately
|
// Mark own outgoing message as read immediately
|
||||||
@@ -831,8 +782,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.error("Failed to send message to {}: {}", participantKey, ex.getMessage(), ex);
|
log.error("Failed to send message to {}: {}", participantKey, ex.getMessage(), ex);
|
||||||
Notification.show("Nachricht konnte nicht gesendet werden: " + ex.getMessage(), 4000,
|
Notification.show("Nachricht konnte nicht gesendet werden: " + ex.getMessage(), 4000,
|
||||||
Notification.Position.MIDDLE)
|
Notification.Position.MIDDLE).addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,8 +796,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
} catch (IllegalArgumentException ex) {
|
} catch (IllegalArgumentException ex) {
|
||||||
return appUserService.findAll().stream()
|
return appUserService.findAll().stream()
|
||||||
.filter(user -> participantKey.equals(user.getEmail()) || participantKey.equals(user.getAppCode()))
|
.filter(user -> participantKey.equals(user.getEmail()) || participantKey.equals(user.getAppCode()))
|
||||||
.findFirst()
|
.findFirst().orElse(null);
|
||||||
.orElse(null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,14 +805,15 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
if ("general".equalsIgnoreCase(conversationId)) {
|
if ("general".equalsIgnoreCase(conversationId)) {
|
||||||
return messages.stream()
|
return messages.stream().filter(
|
||||||
.filter(msg -> Optional.ofNullable(msg.getMessageType()).orElse(MessageType.GENERAL) == MessageType.GENERAL)
|
msg -> Optional.ofNullable(msg.getMessageType()).orElse(MessageType.GENERAL) == MessageType.GENERAL)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
if (conversationId.startsWith("job-")) {
|
if (conversationId.startsWith("job-")) {
|
||||||
String token = conversationId.substring(4);
|
String token = conversationId.substring(4);
|
||||||
return messages.stream()
|
return messages.stream()
|
||||||
.filter(msg -> Optional.ofNullable(msg.getMessageType()).orElse(MessageType.GENERAL) == MessageType.JOB_RELATED
|
.filter(msg -> Optional.ofNullable(msg.getMessageType())
|
||||||
|
.orElse(MessageType.GENERAL) == MessageType.JOB_RELATED
|
||||||
&& matchesJobConversation(msg, token))
|
&& matchesJobConversation(msg, token))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
@@ -879,8 +829,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
String jobId = Optional.ofNullable(message.getJobIdAsString()).orElse("");
|
String jobId = Optional.ofNullable(message.getJobIdAsString()).orElse("");
|
||||||
|
|
||||||
return sanitize(jobNumber).equalsIgnoreCase(normalizedToken)
|
return sanitize(jobNumber).equalsIgnoreCase(normalizedToken)
|
||||||
|| sanitize(jobId).equalsIgnoreCase(normalizedToken)
|
|| sanitize(jobId).equalsIgnoreCase(normalizedToken) || jobNumber.equalsIgnoreCase(token)
|
||||||
|| jobNumber.equalsIgnoreCase(token)
|
|
||||||
|| jobId.equalsIgnoreCase(token);
|
|| jobId.equalsIgnoreCase(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,11 +861,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (messages != null && (jobNumberContext == null || jobNumberContext.isBlank())) {
|
if (messages != null && (jobNumberContext == null || jobNumberContext.isBlank())) {
|
||||||
jobNumberContext = messages.stream()
|
jobNumberContext = messages.stream().map(Message::getJobNumber)
|
||||||
.map(Message::getJobNumber)
|
.filter(value -> value != null && !value.isBlank()).findFirst().orElse(jobNumberContext);
|
||||||
.filter(value -> value != null && !value.isBlank())
|
|
||||||
.findFirst()
|
|
||||||
.orElse(jobNumberContext);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jobIdContext == null || jobNumberContext == null || jobNumberContext.isBlank()) {
|
if (jobIdContext == null || jobNumberContext == null || jobNumberContext.isBlank()) {
|
||||||
@@ -927,8 +873,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
if (jobOptional.isPresent()) {
|
if (jobOptional.isPresent()) {
|
||||||
Job job = jobOptional.get();
|
Job job = jobOptional.get();
|
||||||
jobIdContext = Optional.ofNullable(job.getId()).orElse(jobIdContext);
|
jobIdContext = Optional.ofNullable(job.getId()).orElse(jobIdContext);
|
||||||
jobNumberContext = Optional.ofNullable(job.getJobNumber())
|
jobNumberContext = Optional.ofNullable(job.getJobNumber()).filter(value -> !value.isBlank())
|
||||||
.filter(value -> !value.isBlank())
|
|
||||||
.orElse(jobNumberContext);
|
.orElse(jobNumberContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -955,11 +900,13 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
if (conversationId.startsWith("job-")) {
|
if (conversationId.startsWith("job-")) {
|
||||||
if (messages != null && !messages.isEmpty()) {
|
if (messages != null && !messages.isEmpty()) {
|
||||||
for (Message message : messages) {
|
for (Message message : messages) {
|
||||||
String jobNumber = Optional.ofNullable(message.getJobNumber()).filter(s -> !s.isBlank()).orElse(null);
|
String jobNumber = Optional.ofNullable(message.getJobNumber()).filter(s -> !s.isBlank())
|
||||||
|
.orElse(null);
|
||||||
if (jobNumber != null) {
|
if (jobNumber != null) {
|
||||||
return "Auftrag " + jobNumber;
|
return "Auftrag " + jobNumber;
|
||||||
}
|
}
|
||||||
String jobId = Optional.ofNullable(message.getJobIdAsString()).filter(s -> !s.isBlank()).orElse(null);
|
String jobId = Optional.ofNullable(message.getJobIdAsString()).filter(s -> !s.isBlank())
|
||||||
|
.orElse(null);
|
||||||
if (jobId != null) {
|
if (jobId != null) {
|
||||||
return "Auftrag " + jobId;
|
return "Auftrag " + jobId;
|
||||||
}
|
}
|
||||||
@@ -975,8 +922,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the view is attached to the UI
|
* Called when the view is attached to the UI Registers listener for incoming
|
||||||
* Registers listener for incoming messages
|
* messages
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
protected void onAttach(AttachEvent attachEvent) {
|
protected void onAttach(AttachEvent attachEvent) {
|
||||||
@@ -992,8 +939,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the view is detached from the UI
|
* Called when the view is detached from the UI Unregisters listener to prevent
|
||||||
* Unregisters listener to prevent memory leaks
|
* memory leaks
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
protected void onDetach(DetachEvent detachEvent) {
|
protected void onDetach(DetachEvent detachEvent) {
|
||||||
@@ -1006,8 +953,8 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming message broadcast
|
* Handle incoming message broadcast Filters messages to only show those
|
||||||
* Filters messages to only show those belonging to the current conversation
|
* belonging to the current conversation
|
||||||
*/
|
*/
|
||||||
private void handleIncomingMessage(UI ui, Message message) {
|
private void handleIncomingMessage(UI ui, Message message) {
|
||||||
if (message == null || participantKey == null || conversationId == null) {
|
if (message == null || participantKey == null || conversationId == null) {
|
||||||
@@ -1057,6 +1004,12 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
ensureScrollAnchor();
|
ensureScrollAnchor();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
|
|
||||||
|
// Play notification sound and show browser notification for incoming client messages
|
||||||
|
if (message.getOrigin() == MessageOrigin.CLIENT) {
|
||||||
|
playNotificationSound(ui);
|
||||||
|
showBrowserNotification(ui, message);
|
||||||
|
}
|
||||||
|
|
||||||
log.info("Messages re-rendered with new message");
|
log.info("Messages re-rendered with new message");
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -1064,4 +1017,62 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a notification sound when a new message arrives
|
||||||
|
*/
|
||||||
|
private void playNotificationSound(UI ui) {
|
||||||
|
ui.getPage().executeJs(
|
||||||
|
"const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGHm7A7+OZSA0PVK3m8LRjHAU7k9nzyn0tBSd5ye/glEIKEl206O2oVhQLSKHi8r5uIgU0idT01IMzByBwwvHjmEgNDlWs5PCzYhsFO5TY88p+Kwcme8jw4JVCChNdt+jvp1QVDEih4vK+bSIGNIrV9dODMggib8Lx5JdIDQ9VrObws2IbBT6U2PXKfi0IJnzH8OCVQgoVXbfp76dVFQ5IouLyvW0jCDSL1fTSgTQJJG7C8eSWSA8RVq3m8LJgGwg/lNj0yn4tCSV7x+/glUILFl237++nVhYOSKPi8rxtIwo0i9X00oE1CiNuwvDklkkREVat5u+yXxwJP5PY9Ml+Lgoge8fv4JVCDBVct+7vqFYYEUij4vG8bSQKNIvV89GBNQshbcLw5JZJERFV\u003d\u003d'); audio.play().catch(err => console.log('Audio play failed:', err));");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a browser notification when a new message arrives
|
||||||
|
*/
|
||||||
|
private void showBrowserNotification(UI ui, Message message) {
|
||||||
|
String senderName = resolveAppUserName();
|
||||||
|
String preview = resolveNotificationPreview(message);
|
||||||
|
String title = senderName != null ? senderName : "Neue Nachricht";
|
||||||
|
|
||||||
|
ui.getPage().executeJs(
|
||||||
|
"if (!('Notification' in window)) {" + " console.log('Browser does not support notifications');"
|
||||||
|
+ "} else if (Notification.permission === 'granted') {" + " new Notification($0, { body: $1 });"
|
||||||
|
+ "} else if (Notification.permission !== 'denied') {" + " Notification.requestPermission().then(permission => {"
|
||||||
|
+ " if (permission === 'granted') {" + " new Notification($0, { body: $1 });" + " }" + " });" + "}",
|
||||||
|
title, preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the AppUser name for the current conversation
|
||||||
|
*/
|
||||||
|
private String resolveAppUserName() {
|
||||||
|
if (participantKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ObjectId appUserId = new ObjectId(participantKey);
|
||||||
|
AppUser appUser = appUserService.findById(appUserId);
|
||||||
|
return appUser != null ? appUser.getBezeichnung() : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a short preview text for the notification
|
||||||
|
*/
|
||||||
|
private String resolveNotificationPreview(Message message) {
|
||||||
|
if (message == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (message.getContentType() == MessageContentType.IMAGE) {
|
||||||
|
return "📷 Bild";
|
||||||
|
}
|
||||||
|
String content = message.getContent();
|
||||||
|
if (content == null || content.isBlank()) {
|
||||||
|
return "(kein Inhalt)";
|
||||||
|
}
|
||||||
|
// Limit preview to 100 characters
|
||||||
|
return content.length() > 100 ? content.substring(0, 97) + "..." : content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ import de.assecutor.votianlt.model.Message;
|
|||||||
import de.assecutor.votianlt.model.MessageContentType;
|
import de.assecutor.votianlt.model.MessageContentType;
|
||||||
import de.assecutor.votianlt.model.MessageOrigin;
|
import de.assecutor.votianlt.model.MessageOrigin;
|
||||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||||
|
import de.assecutor.votianlt.service.MessageBroadcaster;
|
||||||
import de.assecutor.votianlt.service.MessageService;
|
import de.assecutor.votianlt.service.MessageService;
|
||||||
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
@@ -47,15 +49,19 @@ public class MessagesView extends Main {
|
|||||||
|
|
||||||
private final MessageService messageService;
|
private final MessageService messageService;
|
||||||
private final AppUserService appUserService;
|
private final AppUserService appUserService;
|
||||||
|
private final MessageBroadcaster messageBroadcaster;
|
||||||
|
|
||||||
private Grid<ClientMessageSummary> clientGrid;
|
private Grid<ClientMessageSummary> clientGrid;
|
||||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
|
|
||||||
private final AtomicBoolean loading = new AtomicBoolean(false);
|
private final AtomicBoolean loading = new AtomicBoolean(false);
|
||||||
private Registration pollRegistration;
|
private Registration pollRegistration;
|
||||||
|
private Registration broadcasterRegistration;
|
||||||
|
private int lastMessageCount = 0;
|
||||||
|
|
||||||
public MessagesView(MessageService messageService, AppUserService appUserService) {
|
public MessagesView(MessageService messageService, AppUserService appUserService,
|
||||||
|
MessageBroadcaster messageBroadcaster) {
|
||||||
this.messageService = messageService;
|
this.messageService = messageService;
|
||||||
this.appUserService = appUserService;
|
this.appUserService = appUserService;
|
||||||
|
this.messageBroadcaster = messageBroadcaster;
|
||||||
|
|
||||||
// Create main layout
|
// Create main layout
|
||||||
VerticalLayout layout = new VerticalLayout();
|
VerticalLayout layout = new VerticalLayout();
|
||||||
@@ -65,16 +71,16 @@ public class MessagesView extends Main {
|
|||||||
|
|
||||||
// Add title and action buttons
|
// Add title and action buttons
|
||||||
HorizontalLayout headerLayout = createHeaderLayout();
|
HorizontalLayout headerLayout = createHeaderLayout();
|
||||||
|
|
||||||
// Create client grid
|
// Create client grid
|
||||||
clientGrid = createClientGrid();
|
clientGrid = createClientGrid();
|
||||||
|
|
||||||
// Add components to layout
|
// Add components to layout
|
||||||
layout.add(headerLayout, clientGrid);
|
layout.add(headerLayout, clientGrid);
|
||||||
|
|
||||||
// Add layout to main view
|
// Add layout to main view
|
||||||
add(layout);
|
add(layout);
|
||||||
|
|
||||||
// Load client summaries
|
// Load client summaries
|
||||||
loadClientSummaries();
|
loadClientSummaries();
|
||||||
}
|
}
|
||||||
@@ -91,7 +97,7 @@ public class MessagesView extends Main {
|
|||||||
Grid<ClientMessageSummary> grid = new Grid<>(ClientMessageSummary.class, false);
|
Grid<ClientMessageSummary> grid = new Grid<>(ClientMessageSummary.class, false);
|
||||||
grid.setWidthFull();
|
grid.setWidthFull();
|
||||||
grid.setHeight("600px");
|
grid.setHeight("600px");
|
||||||
|
|
||||||
// Add columns
|
// Add columns
|
||||||
grid.addColumn(new ComponentRenderer<>(summary -> {
|
grid.addColumn(new ComponentRenderer<>(summary -> {
|
||||||
Span span = new Span(summary.getUnreadCount() > 0 ? "●" : "○");
|
Span span = new Span(summary.getUnreadCount() > 0 ? "●" : "○");
|
||||||
@@ -101,15 +107,15 @@ public class MessagesView extends Main {
|
|||||||
}
|
}
|
||||||
return span;
|
return span;
|
||||||
})).setHeader("Status").setWidth("80px").setFlexGrow(0);
|
})).setHeader("Status").setWidth("80px").setFlexGrow(0);
|
||||||
|
|
||||||
grid.addColumn(ClientMessageSummary::getClientName).setHeader("Client").setAutoWidth(true);
|
grid.addColumn(ClientMessageSummary::getClientName).setHeader("Client").setAutoWidth(true);
|
||||||
grid.addColumn(ClientMessageSummary::getClientEmail).setHeader("E-Mail").setAutoWidth(true);
|
grid.addColumn(ClientMessageSummary::getClientEmail).setHeader("E-Mail").setAutoWidth(true);
|
||||||
|
|
||||||
grid.addColumn(new ComponentRenderer<>(summary -> {
|
grid.addColumn(new ComponentRenderer<>(summary -> {
|
||||||
Span span = new Span(String.valueOf(summary.getTotalMessages()));
|
Span span = new Span(String.valueOf(summary.getTotalMessages()));
|
||||||
return span;
|
return span;
|
||||||
})).setHeader("Nachrichten").setWidth("120px").setFlexGrow(0);
|
})).setHeader("Nachrichten").setWidth("120px").setFlexGrow(0);
|
||||||
|
|
||||||
grid.addColumn(new ComponentRenderer<>(summary -> {
|
grid.addColumn(new ComponentRenderer<>(summary -> {
|
||||||
if (summary.getUnreadCount() > 0) {
|
if (summary.getUnreadCount() > 0) {
|
||||||
Span span = new Span(String.valueOf(summary.getUnreadCount()));
|
Span span = new Span(String.valueOf(summary.getUnreadCount()));
|
||||||
@@ -119,11 +125,11 @@ public class MessagesView extends Main {
|
|||||||
}
|
}
|
||||||
return new Span("0");
|
return new Span("0");
|
||||||
})).setHeader("Ungelesen").setWidth("100px").setFlexGrow(0);
|
})).setHeader("Ungelesen").setWidth("100px").setFlexGrow(0);
|
||||||
|
|
||||||
grid.addColumn(summary ->
|
grid.addColumn(summary -> summary.getLastMessageDate() != null
|
||||||
summary.getLastMessageDate() != null ? summary.getLastMessageDate().format(DATE_FORMATTER) : "-"
|
? DateTimeFormatUtil.formatDateTime(summary.getLastMessageDate())
|
||||||
).setHeader("Letzte Nachricht").setAutoWidth(true);
|
: "-").setHeader("Letzte Nachricht").setAutoWidth(true);
|
||||||
|
|
||||||
grid.addColumn(new ComponentRenderer<>(summary -> {
|
grid.addColumn(new ComponentRenderer<>(summary -> {
|
||||||
String preview = summary.getLastMessagePreview();
|
String preview = summary.getLastMessagePreview();
|
||||||
if (preview != null && preview.length() > 50) {
|
if (preview != null && preview.length() > 50) {
|
||||||
@@ -131,13 +137,13 @@ public class MessagesView extends Main {
|
|||||||
}
|
}
|
||||||
return new Span(preview != null ? preview : "-");
|
return new Span(preview != null ? preview : "-");
|
||||||
})).setHeader("Vorschau").setAutoWidth(true);
|
})).setHeader("Vorschau").setAutoWidth(true);
|
||||||
|
|
||||||
// Add click listener to navigate to UserMessagesView
|
// Add click listener to navigate to UserMessagesView
|
||||||
grid.addItemClickListener(event -> {
|
grid.addItemClickListener(event -> {
|
||||||
ClientMessageSummary summary = event.getItem();
|
ClientMessageSummary summary = event.getItem();
|
||||||
UI.getCurrent().navigate("user-messages/" + summary.getClientId());
|
UI.getCurrent().navigate("user-messages/" + summary.getClientId());
|
||||||
});
|
});
|
||||||
|
|
||||||
return grid;
|
return grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +163,7 @@ public class MessagesView extends Main {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error loading client summaries: {}", e.getMessage(), e);
|
log.error("Error loading client summaries: {}", e.getMessage(), e);
|
||||||
Notification.show("Fehler beim Laden der Nachrichten", 3000, Notification.Position.MIDDLE)
|
Notification.show("Fehler beim Laden der Nachrichten", 3000, Notification.Position.MIDDLE)
|
||||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
} finally {
|
} finally {
|
||||||
loading.set(false);
|
loading.set(false);
|
||||||
}
|
}
|
||||||
@@ -199,20 +205,17 @@ public class MessagesView extends Main {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
conversation.sort(Comparator.comparing(Message::getCreatedAt,
|
conversation.sort(Comparator
|
||||||
Comparator.nullsLast(LocalDateTime::compareTo)).reversed());
|
.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)).reversed());
|
||||||
|
|
||||||
Message latest = conversation.stream()
|
Message latest = conversation.stream().filter(msg -> msg.getCreatedAt() != null).findFirst()
|
||||||
.filter(msg -> msg.getCreatedAt() != null)
|
|
||||||
.findFirst()
|
|
||||||
.orElse(conversation.get(0));
|
.orElse(conversation.get(0));
|
||||||
|
|
||||||
LocalDateTime lastDate = latest.getCreatedAt();
|
LocalDateTime lastDate = latest.getCreatedAt();
|
||||||
String preview = resolvePreview(latest);
|
String preview = resolvePreview(latest);
|
||||||
int totalMessages = conversation.size();
|
int totalMessages = conversation.size();
|
||||||
int unreadCount = (int) conversation.stream()
|
int unreadCount = (int) conversation.stream()
|
||||||
.filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead())
|
.filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead()).count();
|
||||||
.count();
|
|
||||||
|
|
||||||
summary.setTotalMessages(summary.getTotalMessages() + totalMessages);
|
summary.setTotalMessages(summary.getTotalMessages() + totalMessages);
|
||||||
summary.setUnreadCount(summary.getUnreadCount() + unreadCount);
|
summary.setUnreadCount(summary.getUnreadCount() + unreadCount);
|
||||||
@@ -235,8 +238,9 @@ public class MessagesView extends Main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<ClientMessageSummary> summaries = new ArrayList<>(summaryMap.values());
|
List<ClientMessageSummary> summaries = new ArrayList<>(summaryMap.values());
|
||||||
summaries.sort(Comparator.comparing(ClientMessageSummary::getLastMessageDate,
|
summaries.sort(Comparator
|
||||||
Comparator.nullsLast(LocalDateTime::compareTo)).reversed());
|
.comparing(ClientMessageSummary::getLastMessageDate, Comparator.nullsLast(LocalDateTime::compareTo))
|
||||||
|
.reversed());
|
||||||
return summaries;
|
return summaries;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,9 +253,7 @@ public class MessagesView extends Main {
|
|||||||
return "[Bildnachricht]";
|
return "[Bildnachricht]";
|
||||||
}
|
}
|
||||||
|
|
||||||
return Optional.ofNullable(message.getContent())
|
return Optional.ofNullable(message.getContent()).filter(content -> !content.isBlank()).orElse("(kein Inhalt)");
|
||||||
.filter(content -> !content.isBlank())
|
|
||||||
.orElse("(kein Inhalt)");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveParticipantKey(Message message) {
|
private String resolveParticipantKey(Message message) {
|
||||||
@@ -326,6 +328,12 @@ public class MessagesView extends Main {
|
|||||||
UI ui = attachEvent.getUI();
|
UI ui = attachEvent.getUI();
|
||||||
ui.setPollInterval(POLL_INTERVAL_MS);
|
ui.setPollInterval(POLL_INTERVAL_MS);
|
||||||
pollRegistration = ui.addPollListener(event -> loadClientSummaries());
|
pollRegistration = ui.addPollListener(event -> loadClientSummaries());
|
||||||
|
|
||||||
|
// Register broadcaster for real-time notifications
|
||||||
|
broadcasterRegistration = messageBroadcaster.register(message -> {
|
||||||
|
handleIncomingMessage(ui, message);
|
||||||
|
});
|
||||||
|
|
||||||
loadClientSummaries();
|
loadClientSummaries();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +344,96 @@ public class MessagesView extends Main {
|
|||||||
pollRegistration.remove();
|
pollRegistration.remove();
|
||||||
pollRegistration = null;
|
pollRegistration = null;
|
||||||
}
|
}
|
||||||
|
if (broadcasterRegistration != null) {
|
||||||
|
broadcasterRegistration.remove();
|
||||||
|
broadcasterRegistration = null;
|
||||||
|
}
|
||||||
detachEvent.getUI().setPollInterval(-1);
|
detachEvent.getUI().setPollInterval(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming message for notification purposes
|
||||||
|
*/
|
||||||
|
private void handleIncomingMessage(UI ui, Message message) {
|
||||||
|
if (message == null || message.getOrigin() != MessageOrigin.CLIENT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.access(() -> {
|
||||||
|
try {
|
||||||
|
// Count total messages to detect new ones
|
||||||
|
int currentMessageCount = messageService.countAllMessages();
|
||||||
|
if (currentMessageCount > lastMessageCount) {
|
||||||
|
lastMessageCount = currentMessageCount;
|
||||||
|
|
||||||
|
// Play notification sound and show browser notification
|
||||||
|
playNotificationSound(ui);
|
||||||
|
showBrowserNotification(ui, message);
|
||||||
|
|
||||||
|
// Refresh the grid
|
||||||
|
loadClientSummaries();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error handling incoming message notification", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a notification sound when a new message arrives
|
||||||
|
*/
|
||||||
|
private void playNotificationSound(UI ui) {
|
||||||
|
ui.getPage().executeJs(
|
||||||
|
"const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGHm7A7+OZSA0PVK3m8LRjHAU7k9nzyn0tBSd5ye/glEIKEl206O2oVhQLSKHi8r5uIgU0idT01IMzByBwwvHjmEgNDlWs5PCzYhsFO5TY88p+Kwcme8jw4JVCChNdt+jvp1QVDEih4vK+bSIGNIrV9dODMggib8Lx5JdIDQ9VrObws2IbBT6U2PXKfi0IJnzH8OCVQgoVXbfp76dVFQ5IouLyvW0jCDSL1fTSgTQJJG7C8eSWSA8RVq3m8LJgGwg/lNj0yn4tCSV7x+/glUILFl237++nVhYOSKPi8rxtIwo0i9X00oE1CiNuwvDklkkREVat5u+yXxwJP5PY9Ml+Lgoge8fv4JVCDBVct+7vqFYYEUij4vG8bSQKNIvV89GBNQshbcLw5JZJERFV\u003d\u003d'); audio.play().catch(err => console.log('Audio play failed:', err));");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a browser notification when a new message arrives
|
||||||
|
*/
|
||||||
|
private void showBrowserNotification(UI ui, Message message) {
|
||||||
|
String senderName = resolveAppUserName(message.getReceiver());
|
||||||
|
String preview = resolveNotificationPreview(message);
|
||||||
|
String title = senderName != null ? ("Nachricht von " + senderName) : "Neue Nachricht";
|
||||||
|
|
||||||
|
ui.getPage().executeJs(
|
||||||
|
"if (!('Notification' in window)) {" + " console.log('Browser does not support notifications');"
|
||||||
|
+ "} else if (Notification.permission === 'granted') {" + " new Notification($0, { body: $1 });"
|
||||||
|
+ "} else if (Notification.permission !== 'denied') {" + " Notification.requestPermission().then(permission => {"
|
||||||
|
+ " if (permission === 'granted') {" + " new Notification($0, { body: $1 });" + " }" + " });" + "}",
|
||||||
|
title, preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the AppUser name by ID
|
||||||
|
*/
|
||||||
|
private String resolveAppUserName(String appUserId) {
|
||||||
|
if (appUserId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ObjectId id = new ObjectId(appUserId);
|
||||||
|
AppUser appUser = appUserService.findById(id);
|
||||||
|
return appUser != null ? appUser.getBezeichnung() : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a short preview text for the notification
|
||||||
|
*/
|
||||||
|
private String resolveNotificationPreview(Message message) {
|
||||||
|
if (message == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (message.getContentType() == MessageContentType.IMAGE) {
|
||||||
|
return "📷 Bild";
|
||||||
|
}
|
||||||
|
String content = message.getContent();
|
||||||
|
if (content == null || content.isBlank()) {
|
||||||
|
return "(kein Inhalt)";
|
||||||
|
}
|
||||||
|
// Limit preview to 100 characters
|
||||||
|
return content.length() > 100 ? content.substring(0, 97) + "..." : content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ import com.vaadin.flow.component.UI;
|
|||||||
import com.vaadin.flow.server.StreamResource;
|
import com.vaadin.flow.server.StreamResource;
|
||||||
import com.vaadin.flow.server.StreamRegistration;
|
import com.vaadin.flow.server.StreamRegistration;
|
||||||
import de.assecutor.votianlt.service.SystemInvoiceService;
|
import de.assecutor.votianlt.service.SystemInvoiceService;
|
||||||
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
|
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
|
||||||
import de.assecutor.votianlt.model.invoices.SystemInvoiceItem;
|
import de.assecutor.votianlt.model.invoices.SystemInvoiceItem;
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
|
|
||||||
import java.text.NumberFormat;
|
import java.text.NumberFormat;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -50,7 +50,6 @@ public class MyInvoicesView extends Main {
|
|||||||
private final Div emptyState = new Div();
|
private final Div emptyState = new Div();
|
||||||
private final SystemInvoiceService systemInvoiceService;
|
private final SystemInvoiceService systemInvoiceService;
|
||||||
|
|
||||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
|
||||||
private static final NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
|
private static final NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
|
||||||
|
|
||||||
public MyInvoicesView(SystemInvoiceService systemInvoiceService) {
|
public MyInvoicesView(SystemInvoiceService systemInvoiceService) {
|
||||||
@@ -76,9 +75,7 @@ public class MyInvoicesView extends Main {
|
|||||||
private Component createTopCards() {
|
private Component createTopCards() {
|
||||||
// Container mit responsiven Spalten
|
// Container mit responsiven Spalten
|
||||||
Div container = new Div();
|
Div container = new Div();
|
||||||
container.getStyle()
|
container.getStyle().set("display", "grid").set("grid-template-columns", "repeat(auto-fit, minmax(280px, 1fr))")
|
||||||
.set("display", "grid")
|
|
||||||
.set("grid-template-columns", "repeat(auto-fit, minmax(280px, 1fr))")
|
|
||||||
.set("gap", "var(--lumo-space-m)");
|
.set("gap", "var(--lumo-space-m)");
|
||||||
|
|
||||||
// Karte: Offene Rechnungen
|
// Karte: Offene Rechnungen
|
||||||
@@ -143,7 +140,8 @@ public class MyInvoicesView extends Main {
|
|||||||
grid.addColumn(new ComponentRenderer<>(row -> statusBadge(row.status()))).setHeader("Status").setAutoWidth(true)
|
grid.addColumn(new ComponentRenderer<>(row -> statusBadge(row.status()))).setHeader("Status").setAutoWidth(true)
|
||||||
.setFlexGrow(0);
|
.setFlexGrow(0);
|
||||||
grid.addColumn(MyInvoicesView::formatInvoiceNumber).setHeader("Rechnungsnummer").setAutoWidth(true);
|
grid.addColumn(MyInvoicesView::formatInvoiceNumber).setHeader("Rechnungsnummer").setAutoWidth(true);
|
||||||
grid.addColumn(row -> DATE_FMT.format(row.date())).setHeader("Datum").setAutoWidth(true).setFlexGrow(0);
|
grid.addColumn(row -> DateTimeFormatUtil.formatDate(row.date())).setHeader("Datum").setAutoWidth(true)
|
||||||
|
.setFlexGrow(0);
|
||||||
grid.addColumn(row -> CURRENCY_FMT.format(row.amount())).setHeader("Betrag").setAutoWidth(true)
|
grid.addColumn(row -> CURRENCY_FMT.format(row.amount())).setHeader("Betrag").setAutoWidth(true)
|
||||||
.setTextAlign(ColumnTextAlign.END).setFlexGrow(0);
|
.setTextAlign(ColumnTextAlign.END).setFlexGrow(0);
|
||||||
grid.setAllRowsVisible(true);
|
grid.setAllRowsVisible(true);
|
||||||
@@ -276,7 +274,7 @@ public class MyInvoicesView extends Main {
|
|||||||
private byte[] generateSystemInvoicePdf(MyInvoiceRow row) throws Exception {
|
private byte[] generateSystemInvoicePdf(MyInvoiceRow row) throws Exception {
|
||||||
SystemInvoiceData data = new SystemInvoiceData();
|
SystemInvoiceData data = new SystemInvoiceData();
|
||||||
data.setInvoiceNumber(row.invoiceNumber());
|
data.setInvoiceNumber(row.invoiceNumber());
|
||||||
data.setInvoiceDate(DATE_FMT.format(row.date()));
|
data.setInvoiceDate(DateTimeFormatUtil.formatDate(row.date()));
|
||||||
data.setInvoiceText("Rechnung " + row.invoiceNumber());
|
data.setInvoiceText("Rechnung " + row.invoiceNumber());
|
||||||
|
|
||||||
// Minimal recipient information
|
// Minimal recipient information
|
||||||
|
|||||||
@@ -61,8 +61,7 @@ public class PdfTestView extends VerticalLayout {
|
|||||||
try {
|
try {
|
||||||
byte[] pdfBytes = systemInvoiceService.generateInvoicePdfFromHtml();
|
byte[] pdfBytes = systemInvoiceService.generateInvoicePdfFromHtml();
|
||||||
|
|
||||||
StreamResource resource = new StreamResource("vlt-invoice.pdf",
|
StreamResource resource = new StreamResource("vlt-invoice.pdf", () -> new ByteArrayInputStream(pdfBytes));
|
||||||
() -> new ByteArrayInputStream(pdfBytes));
|
|
||||||
resource.setContentType("application/pdf");
|
resource.setContentType("application/pdf");
|
||||||
|
|
||||||
getUI().ifPresent(ui -> {
|
getUI().ifPresent(ui -> {
|
||||||
@@ -72,8 +71,8 @@ public class PdfTestView extends VerticalLayout {
|
|||||||
|
|
||||||
Notification.show("PDF aus HTML erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER);
|
Notification.show("PDF aus HTML erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Notification.show("Fehler beim Generieren des PDFs aus HTML: " + ex.getMessage(),
|
Notification.show("Fehler beim Generieren des PDFs aus HTML: " + ex.getMessage(), 5000,
|
||||||
5000, Notification.Position.BOTTOM_CENTER);
|
Notification.Position.BOTTOM_CENTER);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +91,8 @@ public class PdfTestView extends VerticalLayout {
|
|||||||
|
|
||||||
Notification.show("Customer PDF erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER);
|
Notification.show("Customer PDF erfolgreich generiert!", 3000, Notification.Position.BOTTOM_CENTER);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Notification.show("Fehler beim Generieren des Customer PDFs: " + ex.getMessage(),
|
Notification.show("Fehler beim Generieren des Customer PDFs: " + ex.getMessage(), 5000,
|
||||||
5000, Notification.Position.BOTTOM_CENTER);
|
Notification.Position.BOTTOM_CENTER);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,43 @@
|
|||||||
package de.assecutor.votianlt.pages.view;
|
package de.assecutor.votianlt.pages.view;
|
||||||
|
|
||||||
import com.vaadin.flow.component.datepicker.DatePicker;
|
import com.vaadin.flow.component.AttachEvent;
|
||||||
|
import com.vaadin.flow.component.DetachEvent;
|
||||||
|
import com.vaadin.flow.component.UI;
|
||||||
import com.vaadin.flow.component.button.Button;
|
import com.vaadin.flow.component.button.Button;
|
||||||
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
import com.vaadin.flow.component.combobox.ComboBox;
|
import com.vaadin.flow.component.combobox.ComboBox;
|
||||||
|
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
|
||||||
|
import com.vaadin.flow.component.datepicker.DatePicker;
|
||||||
import com.vaadin.flow.component.grid.Grid;
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
import com.vaadin.flow.component.html.Anchor;
|
import com.vaadin.flow.component.html.Anchor;
|
||||||
import com.vaadin.flow.component.html.H2;
|
import com.vaadin.flow.component.html.H2;
|
||||||
|
import com.vaadin.flow.component.icon.Icon;
|
||||||
|
import com.vaadin.flow.component.icon.VaadinIcon;
|
||||||
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
|
import com.vaadin.flow.component.notification.NotificationVariant;
|
||||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
import com.vaadin.flow.component.textfield.TextField;
|
import com.vaadin.flow.component.textfield.TextField;
|
||||||
import com.vaadin.flow.server.StreamResource;
|
import com.vaadin.flow.server.StreamResource;
|
||||||
|
import com.vaadin.flow.shared.Registration;
|
||||||
import com.vaadin.flow.router.PageTitle;
|
import com.vaadin.flow.router.PageTitle;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
import de.assecutor.votianlt.model.Job;
|
import de.assecutor.votianlt.model.Job;
|
||||||
import de.assecutor.votianlt.model.JobStatus;
|
import de.assecutor.votianlt.model.JobStatus;
|
||||||
import de.assecutor.votianlt.repository.JobRepository;
|
import de.assecutor.votianlt.repository.JobRepository;
|
||||||
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
|
import de.assecutor.votianlt.service.JobBroadcaster;
|
||||||
|
import de.assecutor.votianlt.service.JobHistoryService;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@PageTitle("Aufträge")
|
@PageTitle("Aufträge")
|
||||||
@Route(value = "jobs", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
@Route(value = "jobs", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
||||||
@RolesAllowed({ "USER" })
|
@RolesAllowed({ "USER" })
|
||||||
|
@Slf4j
|
||||||
public class ShowJobsView extends VerticalLayout {
|
public class ShowJobsView extends VerticalLayout {
|
||||||
|
|
||||||
private final DatePicker startDate = new DatePicker("Startdatum");
|
private final DatePicker startDate = new DatePicker("Startdatum");
|
||||||
@@ -28,11 +45,19 @@ public class ShowJobsView extends VerticalLayout {
|
|||||||
private final TextField searchField = new TextField("Auftragsnummer suchen");
|
private final TextField searchField = new TextField("Auftragsnummer suchen");
|
||||||
private final ComboBox<String> statusFilter = new ComboBox<>("Status");
|
private final ComboBox<String> statusFilter = new ComboBox<>("Status");
|
||||||
private final JobRepository jobRepository;
|
private final JobRepository jobRepository;
|
||||||
|
private final JobHistoryService jobHistoryService;
|
||||||
|
private final SecurityService securityService;
|
||||||
|
private final JobBroadcaster jobBroadcaster;
|
||||||
private final Grid<Job> grid = new Grid<>(Job.class, false);
|
private final Grid<Job> grid = new Grid<>(Job.class, false);
|
||||||
|
private Registration broadcasterRegistration;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public ShowJobsView(JobRepository jobRepository) {
|
public ShowJobsView(JobRepository jobRepository, JobHistoryService jobHistoryService,
|
||||||
|
SecurityService securityService, JobBroadcaster jobBroadcaster) {
|
||||||
this.jobRepository = jobRepository;
|
this.jobRepository = jobRepository;
|
||||||
|
this.jobHistoryService = jobHistoryService;
|
||||||
|
this.securityService = securityService;
|
||||||
|
this.jobBroadcaster = jobBroadcaster;
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
setPadding(true);
|
setPadding(true);
|
||||||
setSpacing(true);
|
setSpacing(true);
|
||||||
@@ -78,11 +103,28 @@ public class ShowJobsView extends VerticalLayout {
|
|||||||
endDate.addValueChangeListener(e -> loadData());
|
endDate.addValueChangeListener(e -> loadData());
|
||||||
|
|
||||||
// Configure grid columns: Auftraggeber, Auftragsnummer, Auftragsdatum, Zielort
|
// Configure grid columns: Auftraggeber, Auftragsnummer, Auftragsdatum, Zielort
|
||||||
grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber").setAutoWidth(true).setFlexGrow(1).setSortable(true);
|
grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber")
|
||||||
|
.setAutoWidth(true).setFlexGrow(1).setSortable(true);
|
||||||
grid.addColumn(Job::getJobNumber).setHeader("Auftragsnummer").setAutoWidth(true).setSortable(true);
|
grid.addColumn(Job::getJobNumber).setHeader("Auftragsnummer").setAutoWidth(true).setSortable(true);
|
||||||
grid.addColumn(Job::getCreatedAt).setHeader("Auftragsdatum").setAutoWidth(true).setSortable(true);
|
grid.addColumn(Job::getCreatedAt).setHeader("Auftragsdatum").setAutoWidth(true).setSortable(true);
|
||||||
grid.addColumn(Job::getDeliveryCity).setHeader("Zielort").setAutoWidth(true).setFlexGrow(1).setSortable(true);
|
grid.addColumn(Job::getDeliveryCity).setHeader("Zielort").setAutoWidth(true).setFlexGrow(1).setSortable(true);
|
||||||
|
|
||||||
|
// Action column: manual completion for jobs without digital processing
|
||||||
|
grid.addComponentColumn(job -> {
|
||||||
|
if (!job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED
|
||||||
|
&& job.getStatus() != JobStatus.CANCELLED) {
|
||||||
|
Button completeBtn = new Button(new Icon(VaadinIcon.CHECK_CIRCLE));
|
||||||
|
completeBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SUCCESS);
|
||||||
|
completeBtn.setTooltipText("Auftrag manuell abschließen");
|
||||||
|
completeBtn.addClickListener(e -> {
|
||||||
|
e.getSource().getElement().getNode(); // prevent row click
|
||||||
|
showCompleteJobDialog(job);
|
||||||
|
});
|
||||||
|
return completeBtn;
|
||||||
|
}
|
||||||
|
return new com.vaadin.flow.component.html.Span();
|
||||||
|
}).setHeader("").setAutoWidth(true).setFlexGrow(0);
|
||||||
|
|
||||||
grid.setMultiSort(true);
|
grid.setMultiSort(true);
|
||||||
grid.setSizeFull();
|
grid.setSizeFull();
|
||||||
|
|
||||||
@@ -103,6 +145,32 @@ public class ShowJobsView extends VerticalLayout {
|
|||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void showCompleteJobDialog(Job job) {
|
||||||
|
ConfirmDialog dialog = new ConfirmDialog();
|
||||||
|
dialog.setHeader("Auftrag abschließen");
|
||||||
|
dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + " manuell abschließen?");
|
||||||
|
dialog.setCancelable(true);
|
||||||
|
dialog.setCancelText("Abbrechen");
|
||||||
|
dialog.setConfirmText("Abschließen");
|
||||||
|
dialog.setConfirmButtonTheme("primary");
|
||||||
|
dialog.addConfirmListener(e -> {
|
||||||
|
try {
|
||||||
|
JobStatus oldStatus = job.getStatus();
|
||||||
|
job.setStatus(JobStatus.COMPLETED);
|
||||||
|
job.setUpdatedAt(LocalDateTime.now());
|
||||||
|
jobRepository.save(job);
|
||||||
|
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
|
||||||
|
Notification.show("Auftrag " + job.getJobNumber() + " wurde abgeschlossen.", 3000,
|
||||||
|
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||||
|
loadData();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Notification.show("Fehler beim Abschließen: " + ex.getMessage(), 5000, Notification.Position.BOTTOM_END)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
private void loadData() {
|
private void loadData() {
|
||||||
var start = startDate.getValue();
|
var start = startDate.getValue();
|
||||||
var end = endDate.getValue();
|
var end = endDate.getValue();
|
||||||
@@ -132,8 +200,10 @@ public class ShowJobsView extends VerticalLayout {
|
|||||||
// wenn
|
// wenn
|
||||||
// leer
|
// leer
|
||||||
|
|
||||||
// Verwende die erweiterte Suchmethode
|
// Verwende die erweiterte Suchmethode, gefiltert nach aktuellem Benutzer
|
||||||
var filteredJobs = jobRepository.findWithFilters(startDt, endDt, jobNumberPattern, statusList);
|
String currentUserId = securityService.getCurrentUserId().toHexString();
|
||||||
|
var filteredJobs = jobRepository.findWithFiltersByCreatedBy(currentUserId, startDt, endDt, jobNumberPattern,
|
||||||
|
statusList);
|
||||||
grid.setItems(filteredJobs);
|
grid.setItems(filteredJobs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,4 +263,75 @@ public class ShowJobsView extends VerticalLayout {
|
|||||||
}
|
}
|
||||||
return customerSelection.trim();
|
return customerSelection.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onAttach(AttachEvent attachEvent) {
|
||||||
|
super.onAttach(attachEvent);
|
||||||
|
UI ui = attachEvent.getUI();
|
||||||
|
|
||||||
|
// Register broadcaster for real-time job notifications
|
||||||
|
broadcasterRegistration = jobBroadcaster.register(job -> {
|
||||||
|
handleNewJob(ui, job);
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("ShowJobsView attached and job listener registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetach(DetachEvent detachEvent) {
|
||||||
|
super.onDetach(detachEvent);
|
||||||
|
if (broadcasterRegistration != null) {
|
||||||
|
broadcasterRegistration.remove();
|
||||||
|
broadcasterRegistration = null;
|
||||||
|
}
|
||||||
|
log.info("ShowJobsView detached and job listener unregistered");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle new job notification
|
||||||
|
*/
|
||||||
|
private void handleNewJob(UI ui, Job job) {
|
||||||
|
if (job == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.access(() -> {
|
||||||
|
try {
|
||||||
|
// Play notification sound and show browser notification
|
||||||
|
playNotificationSound(ui);
|
||||||
|
showBrowserNotification(ui, job);
|
||||||
|
|
||||||
|
// Refresh the grid
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
log.info("New job notification displayed for job {}", job.getJobNumber());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error handling new job notification", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a notification sound when a new job is created
|
||||||
|
*/
|
||||||
|
private void playNotificationSound(UI ui) {
|
||||||
|
ui.getPage().executeJs(
|
||||||
|
"const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGHm7A7+OZSA0PVK3m8LRjHAU7k9nzyn0tBSd5ye/glEIKEl206O2oVhQLSKHi8r5uIgU0idT01IMzByBwwvHjmEgNDlWs5PCzYhsFO5TY88p+Kwcme8jw4JVCChNdt+jvp1QVDEih4vK+bSIGNIrV9dODMggib8Lx5JdIDQ9VrObws2IbBT6U2PXKfi0IJnzH8OCVQgoVXbfp76dVFQ5IouLyvW0jCDSL1fTSgTQJJG7C8eSWSA8RVq3m8LJgGwg/lNj0yn4tCSV7x+/glUILFl237++nVhYOSKPi8rxtIwo0i9X00oE1CiNuwvDklkkREVat5u+yXxwJP5PY9Ml+Lgoge8fv4JVCDBVct+7vqFYYEUij4vG8bSQKNIvV89GBNQshbcLw5JZJERFV\u003d\u003d'); audio.play().catch(err => console.log('Audio play failed:', err));");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a browser notification when a new job is created
|
||||||
|
*/
|
||||||
|
private void showBrowserNotification(UI ui, Job job) {
|
||||||
|
String jobNumber = job.getJobNumber() != null ? job.getJobNumber() : "Neuer Auftrag";
|
||||||
|
String company = extractCompanyName(job.getCustomerSelection());
|
||||||
|
String message = company != null && !company.isBlank() ? ("Kunde: " + company) : "Neuer Auftrag erstellt";
|
||||||
|
|
||||||
|
ui.getPage().executeJs(
|
||||||
|
"if (!('Notification' in window)) {" + " console.log('Browser does not support notifications');"
|
||||||
|
+ "} else if (Notification.permission === 'granted') {" + " new Notification($0, { body: $1 });"
|
||||||
|
+ "} else if (Notification.permission !== 'denied') {" + " Notification.requestPermission().then(permission => {"
|
||||||
|
+ " if (permission === 'granted') {" + " new Notification($0, { body: $1 });" + " }" + " });" + "}",
|
||||||
|
jobNumber, message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,8 +330,7 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
// Versionsnummer
|
// Versionsnummer
|
||||||
Span versionSpan = new Span("Version " + appVersion);
|
Span versionSpan = new Span("Version " + appVersion);
|
||||||
versionSpan.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
versionSpan.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
||||||
.set("font-size", "var(--lumo-font-size-s)")
|
.set("font-size", "var(--lumo-font-size-s)").set("margin-top", "var(--lumo-space-l)");
|
||||||
.set("margin-top", "var(--lumo-space-l)");
|
|
||||||
|
|
||||||
footer.add(companyTitle, companyInfo, ctaText, slogan, versionSpan);
|
footer.add(companyTitle, companyInfo, ctaText, slogan, versionSpan);
|
||||||
return footer;
|
return footer;
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ import com.vaadin.flow.component.textfield.TextField;
|
|||||||
import com.vaadin.flow.router.PageTitle;
|
import com.vaadin.flow.router.PageTitle;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
import de.assecutor.votianlt.ai.service.AiStatisticsService;
|
import de.assecutor.votianlt.ai.service.AiStatisticsService;
|
||||||
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@PageTitle("KI-Statistiken")
|
@PageTitle("KI-Statistiken")
|
||||||
@@ -37,7 +37,6 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
private final AiStatisticsService aiStatisticsService;
|
private final AiStatisticsService aiStatisticsService;
|
||||||
private final VerticalLayout chatContainer;
|
private final VerticalLayout chatContainer;
|
||||||
private final TextField promptField;
|
private final TextField promptField;
|
||||||
private final DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm");
|
|
||||||
|
|
||||||
public StatisticsView(AiStatisticsService aiStatisticsService) {
|
public StatisticsView(AiStatisticsService aiStatisticsService) {
|
||||||
this.aiStatisticsService = aiStatisticsService;
|
this.aiStatisticsService = aiStatisticsService;
|
||||||
@@ -69,7 +68,6 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
|
scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
|
||||||
scroller.getStyle().set("background", "var(--lumo-contrast-5pct)");
|
scroller.getStyle().set("background", "var(--lumo-contrast-5pct)");
|
||||||
|
|
||||||
|
|
||||||
add(scroller);
|
add(scroller);
|
||||||
setFlexGrow(1, scroller);
|
setFlexGrow(1, scroller);
|
||||||
|
|
||||||
@@ -83,9 +81,8 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
header.setWidthFull();
|
header.setWidthFull();
|
||||||
header.setPadding(true);
|
header.setPadding(true);
|
||||||
header.setAlignItems(FlexComponent.Alignment.CENTER);
|
header.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
header.getStyle()
|
header.getStyle().set("background", "var(--lumo-base-color)").set("border-bottom",
|
||||||
.set("background", "var(--lumo-base-color)")
|
"1px solid var(--lumo-contrast-10pct)");
|
||||||
.set("border-bottom", "1px solid var(--lumo-contrast-10pct)");
|
|
||||||
|
|
||||||
Icon aiIcon = VaadinIcon.MAGIC.create();
|
Icon aiIcon = VaadinIcon.MAGIC.create();
|
||||||
aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
|
aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
|
||||||
@@ -94,9 +91,7 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
title.getStyle().set("margin", "0").set("font-size", "var(--lumo-font-size-xl)");
|
title.getStyle().set("margin", "0").set("font-size", "var(--lumo-font-size-xl)");
|
||||||
|
|
||||||
Span subtitle = new Span("Frage mich zu Aufträgen, Umsätzen und Statistiken");
|
Span subtitle = new Span("Frage mich zu Aufträgen, Umsätzen und Statistiken");
|
||||||
subtitle.getStyle()
|
subtitle.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size", "var(--lumo-font-size-s)")
|
||||||
.set("color", "var(--lumo-secondary-text-color)")
|
|
||||||
.set("font-size", "var(--lumo-font-size-s)")
|
|
||||||
.set("margin-left", "var(--lumo-space-m)");
|
.set("margin-left", "var(--lumo-space-m)");
|
||||||
|
|
||||||
header.add(aiIcon, title, subtitle);
|
header.add(aiIcon, title, subtitle);
|
||||||
@@ -109,9 +104,8 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
inputArea.setPadding(true);
|
inputArea.setPadding(true);
|
||||||
inputArea.setSpacing(true);
|
inputArea.setSpacing(true);
|
||||||
inputArea.setAlignItems(FlexComponent.Alignment.CENTER);
|
inputArea.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
inputArea.getStyle()
|
inputArea.getStyle().set("background", "var(--lumo-base-color)").set("border-top",
|
||||||
.set("background", "var(--lumo-base-color)")
|
"1px solid var(--lumo-contrast-10pct)");
|
||||||
.set("border-top", "1px solid var(--lumo-contrast-10pct)");
|
|
||||||
|
|
||||||
Button sendButton = new Button(VaadinIcon.PAPERPLANE.create());
|
Button sendButton = new Button(VaadinIcon.PAPERPLANE.create());
|
||||||
sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
@@ -119,9 +113,11 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
sendButton.getStyle().set("min-width", "50px");
|
sendButton.getStyle().set("min-width", "50px");
|
||||||
|
|
||||||
// Quick Action Buttons
|
// Quick Action Buttons
|
||||||
Button jobCountBtn = createQuickActionButton("Aufträge zählen", "Wie viele Aufträge gibt es insgesamt und nach Status?");
|
Button jobCountBtn = createQuickActionButton("Aufträge zählen",
|
||||||
|
"Wie viele Aufträge gibt es insgesamt und nach Status?");
|
||||||
Button revenueBtn = createQuickActionButton("Umsatz", "Zeige mir den Umsatz pro Kunde.");
|
Button revenueBtn = createQuickActionButton("Umsatz", "Zeige mir den Umsatz pro Kunde.");
|
||||||
Button trendBtn = createQuickActionButton("Monatstrend", "Zeige mir den Monatstrend der Aufträge für dieses Jahr.");
|
Button trendBtn = createQuickActionButton("Monatstrend",
|
||||||
|
"Zeige mir den Monatstrend der Aufträge für dieses Jahr.");
|
||||||
|
|
||||||
HorizontalLayout quickActions = new HorizontalLayout(jobCountBtn, revenueBtn, trendBtn);
|
HorizontalLayout quickActions = new HorizontalLayout(jobCountBtn, revenueBtn, trendBtn);
|
||||||
quickActions.setSpacing(true);
|
quickActions.setSpacing(true);
|
||||||
@@ -194,30 +190,22 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
Div messageDiv = new Div();
|
Div messageDiv = new Div();
|
||||||
messageDiv.addClassName("chat-message");
|
messageDiv.addClassName("chat-message");
|
||||||
messageDiv.addClassName("user-message");
|
messageDiv.addClassName("user-message");
|
||||||
messageDiv.getStyle()
|
messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-end").set("margin-bottom",
|
||||||
.set("display", "flex")
|
"var(--lumo-space-m)");
|
||||||
.set("justify-content", "flex-end")
|
|
||||||
.set("margin-bottom", "var(--lumo-space-m)");
|
|
||||||
|
|
||||||
Div bubble = new Div();
|
Div bubble = new Div();
|
||||||
bubble.getStyle()
|
bubble.getStyle().set("background", "var(--lumo-primary-color)")
|
||||||
.set("background", "var(--lumo-primary-color)")
|
|
||||||
.set("color", "var(--lumo-primary-contrast-color)")
|
.set("color", "var(--lumo-primary-contrast-color)")
|
||||||
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
|
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
|
||||||
.set("border-radius", "var(--lumo-border-radius-l)")
|
.set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "70%")
|
||||||
.set("max-width", "70%")
|
|
||||||
.set("word-wrap", "break-word");
|
.set("word-wrap", "break-word");
|
||||||
|
|
||||||
Paragraph text = new Paragraph(message);
|
Paragraph text = new Paragraph(message);
|
||||||
text.getStyle().set("margin", "0");
|
text.getStyle().set("margin", "0");
|
||||||
|
|
||||||
Span time = new Span(LocalDateTime.now().format(timeFormatter));
|
Span time = new Span(DateTimeFormatUtil.formatTime(LocalDateTime.now()));
|
||||||
time.getStyle()
|
time.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("opacity", "0.7").set("display", "block")
|
||||||
.set("font-size", "var(--lumo-font-size-xs)")
|
.set("text-align", "right").set("margin-top", "var(--lumo-space-xs)");
|
||||||
.set("opacity", "0.7")
|
|
||||||
.set("display", "block")
|
|
||||||
.set("text-align", "right")
|
|
||||||
.set("margin-top", "var(--lumo-space-xs)");
|
|
||||||
|
|
||||||
bubble.add(text, time);
|
bubble.add(text, time);
|
||||||
messageDiv.add(bubble);
|
messageDiv.add(bubble);
|
||||||
@@ -228,18 +216,13 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
Div messageDiv = new Div();
|
Div messageDiv = new Div();
|
||||||
messageDiv.addClassName("chat-message");
|
messageDiv.addClassName("chat-message");
|
||||||
messageDiv.addClassName("ai-message");
|
messageDiv.addClassName("ai-message");
|
||||||
messageDiv.getStyle()
|
messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom",
|
||||||
.set("display", "flex")
|
"var(--lumo-space-m)");
|
||||||
.set("justify-content", "flex-start")
|
|
||||||
.set("margin-bottom", "var(--lumo-space-m)");
|
|
||||||
|
|
||||||
Div bubble = new Div();
|
Div bubble = new Div();
|
||||||
bubble.getStyle()
|
bubble.getStyle().set("background", "var(--lumo-base-color)")
|
||||||
.set("background", "var(--lumo-base-color)")
|
.set("border", "1px solid var(--lumo-contrast-10pct)").set("padding", "var(--lumo-space-m)")
|
||||||
.set("border", "1px solid var(--lumo-contrast-10pct)")
|
.set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "85%")
|
||||||
.set("padding", "var(--lumo-space-m)")
|
|
||||||
.set("border-radius", "var(--lumo-border-radius-l)")
|
|
||||||
.set("max-width", "85%")
|
|
||||||
.set("box-shadow", "var(--lumo-box-shadow-xs)");
|
.set("box-shadow", "var(--lumo-box-shadow-xs)");
|
||||||
|
|
||||||
// AI Icon
|
// AI Icon
|
||||||
@@ -252,9 +235,7 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
|
aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
|
||||||
|
|
||||||
Span aiLabel = new Span("KI-Assistent");
|
Span aiLabel = new Span("KI-Assistent");
|
||||||
aiLabel.getStyle()
|
aiLabel.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-s)");
|
||||||
.set("font-weight", "bold")
|
|
||||||
.set("font-size", "var(--lumo-font-size-s)");
|
|
||||||
|
|
||||||
header.add(aiIcon, aiLabel);
|
header.add(aiIcon, aiLabel);
|
||||||
bubble.add(header);
|
bubble.add(header);
|
||||||
@@ -273,20 +254,15 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
if (response.chartData() != null && !response.chartData().isEmpty()) {
|
if (response.chartData() != null && !response.chartData().isEmpty()) {
|
||||||
Div chartContainer = createChart(response.chartType(), response.chartData());
|
Div chartContainer = createChart(response.chartType(), response.chartData());
|
||||||
if (chartContainer != null) {
|
if (chartContainer != null) {
|
||||||
chartContainer.getStyle()
|
chartContainer.getStyle().set("margin-top", "var(--lumo-space-m)").set("height", "300px");
|
||||||
.set("margin-top", "var(--lumo-space-m)")
|
|
||||||
.set("height", "300px");
|
|
||||||
bubble.add(chartContainer);
|
bubble.add(chartContainer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timestamp
|
// Timestamp
|
||||||
Span time = new Span(LocalDateTime.now().format(timeFormatter));
|
Span time = new Span(DateTimeFormatUtil.formatTime(LocalDateTime.now()));
|
||||||
time.getStyle()
|
time.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("color", "var(--lumo-secondary-text-color)")
|
||||||
.set("font-size", "var(--lumo-font-size-xs)")
|
.set("display", "block").set("margin-top", "var(--lumo-space-s)");
|
||||||
.set("color", "var(--lumo-secondary-text-color)")
|
|
||||||
.set("display", "block")
|
|
||||||
.set("margin-top", "var(--lumo-space-s)");
|
|
||||||
bubble.add(time);
|
bubble.add(time);
|
||||||
|
|
||||||
messageDiv.add(bubble);
|
messageDiv.add(bubble);
|
||||||
@@ -295,18 +271,14 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
|
|
||||||
private void addErrorMessage(String message) {
|
private void addErrorMessage(String message) {
|
||||||
Div messageDiv = new Div();
|
Div messageDiv = new Div();
|
||||||
messageDiv.getStyle()
|
messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom",
|
||||||
.set("display", "flex")
|
"var(--lumo-space-m)");
|
||||||
.set("justify-content", "flex-start")
|
|
||||||
.set("margin-bottom", "var(--lumo-space-m)");
|
|
||||||
|
|
||||||
Div bubble = new Div();
|
Div bubble = new Div();
|
||||||
bubble.getStyle()
|
bubble.getStyle().set("background", "var(--lumo-error-color-10pct)")
|
||||||
.set("background", "var(--lumo-error-color-10pct)")
|
|
||||||
.set("border", "1px solid var(--lumo-error-color)")
|
.set("border", "1px solid var(--lumo-error-color)")
|
||||||
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
|
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
|
||||||
.set("border-radius", "var(--lumo-border-radius-l)")
|
.set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "70%");
|
||||||
.set("max-width", "70%");
|
|
||||||
|
|
||||||
Icon errorIcon = VaadinIcon.EXCLAMATION_CIRCLE.create();
|
Icon errorIcon = VaadinIcon.EXCLAMATION_CIRCLE.create();
|
||||||
errorIcon.setSize("16px");
|
errorIcon.setSize("16px");
|
||||||
@@ -326,22 +298,17 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
|
|
||||||
private Div createLoadingMessage() {
|
private Div createLoadingMessage() {
|
||||||
Div messageDiv = new Div();
|
Div messageDiv = new Div();
|
||||||
messageDiv.getStyle()
|
messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom",
|
||||||
.set("display", "flex")
|
"var(--lumo-space-m)");
|
||||||
.set("justify-content", "flex-start")
|
|
||||||
.set("margin-bottom", "var(--lumo-space-m)");
|
|
||||||
|
|
||||||
Div bubble = new Div();
|
Div bubble = new Div();
|
||||||
bubble.getStyle()
|
bubble.getStyle().set("background", "var(--lumo-base-color)")
|
||||||
.set("background", "var(--lumo-base-color)")
|
|
||||||
.set("border", "1px solid var(--lumo-contrast-10pct)")
|
.set("border", "1px solid var(--lumo-contrast-10pct)")
|
||||||
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
|
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
|
||||||
.set("border-radius", "var(--lumo-border-radius-l)");
|
.set("border-radius", "var(--lumo-border-radius-l)");
|
||||||
|
|
||||||
Span dots = new Span("Analysiere...");
|
Span dots = new Span("Analysiere...");
|
||||||
dots.getStyle()
|
dots.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-style", "italic");
|
||||||
.set("color", "var(--lumo-secondary-text-color)")
|
|
||||||
.set("font-style", "italic");
|
|
||||||
|
|
||||||
bubble.add(dots);
|
bubble.add(dots);
|
||||||
messageDiv.add(bubble);
|
messageDiv.add(bubble);
|
||||||
@@ -356,10 +323,8 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
String canvasId = "chart-" + UUID.randomUUID().toString().substring(0, 8);
|
String canvasId = "chart-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
Div chartContainer = new Div();
|
Div chartContainer = new Div();
|
||||||
chartContainer.addClassName("chart-wrapper");
|
chartContainer.addClassName("chart-wrapper");
|
||||||
chartContainer.getStyle()
|
chartContainer.getStyle().set("background", "var(--lumo-contrast-5pct)")
|
||||||
.set("background", "var(--lumo-contrast-5pct)")
|
.set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-s)");
|
||||||
.set("border-radius", "var(--lumo-border-radius-m)")
|
|
||||||
.set("padding", "var(--lumo-space-s)");
|
|
||||||
|
|
||||||
chartContainer.getElement().setProperty("innerHTML",
|
chartContainer.getElement().setProperty("innerHTML",
|
||||||
"<canvas id='" + canvasId + "' style='width: 100%; height: 100%;'></canvas>");
|
"<canvas id='" + canvasId + "' style='width: 100%; height: 100%;'></canvas>");
|
||||||
@@ -410,277 +375,274 @@ public class StatisticsView extends VerticalLayout {
|
|||||||
private String getChartOptions(String chartType) {
|
private String getChartOptions(String chartType) {
|
||||||
// Gradient und moderne Farben für verschiedene Chart-Typen
|
// Gradient und moderne Farben für verschiedene Chart-Typen
|
||||||
return switch (chartType) {
|
return switch (chartType) {
|
||||||
case "line" -> """
|
case "line" -> """
|
||||||
{
|
{
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: {
|
interaction: {
|
||||||
intersect: false,
|
intersect: false,
|
||||||
mode: 'index'
|
mode: 'index'
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
labels: {
|
labels: {
|
||||||
usePointStyle: true,
|
usePointStyle: true,
|
||||||
padding: 20
|
padding: 20
|
||||||
}
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
|
||||||
titleFont: { size: 14, weight: 'bold' },
|
|
||||||
bodyFont: { size: 13 },
|
|
||||||
padding: 12,
|
|
||||||
cornerRadius: 8
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
tooltip: {
|
||||||
y: {
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
beginAtZero: true,
|
titleFont: { size: 14, weight: 'bold' },
|
||||||
grid: {
|
bodyFont: { size: 13 },
|
||||||
color: 'rgba(0,0,0,0.05)'
|
padding: 12,
|
||||||
}
|
cornerRadius: 8
|
||||||
},
|
|
||||||
x: {
|
|
||||||
grid: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
elements: {
|
|
||||||
line: {
|
|
||||||
tension: 0.4,
|
|
||||||
borderWidth: 3
|
|
||||||
},
|
|
||||||
point: {
|
|
||||||
radius: 4,
|
|
||||||
hoverRadius: 6,
|
|
||||||
hitRadius: 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
duration: 1000,
|
|
||||||
easing: 'easeOutQuart'
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
""";
|
scales: {
|
||||||
case "bar" -> """
|
y: {
|
||||||
{
|
beginAtZero: true,
|
||||||
responsive: true,
|
grid: {
|
||||||
maintainAspectRatio: false,
|
color: 'rgba(0,0,0,0.05)'
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'bottom',
|
|
||||||
labels: {
|
|
||||||
usePointStyle: true,
|
|
||||||
padding: 20
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
|
||||||
titleFont: { size: 14, weight: 'bold' },
|
|
||||||
bodyFont: { size: 13 },
|
|
||||||
padding: 12,
|
|
||||||
cornerRadius: 8
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
x: {
|
||||||
y: {
|
grid: {
|
||||||
beginAtZero: true,
|
display: false
|
||||||
grid: {
|
|
||||||
color: 'rgba(0,0,0,0.05)'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
grid: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
elements: {
|
|
||||||
bar: {
|
|
||||||
borderRadius: 6,
|
|
||||||
borderSkipped: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
duration: 800,
|
|
||||||
easing: 'easeOutQuart'
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
""";
|
elements: {
|
||||||
case "doughnut" -> """
|
line: {
|
||||||
{
|
tension: 0.4,
|
||||||
responsive: true,
|
borderWidth: 3
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'right',
|
|
||||||
labels: {
|
|
||||||
usePointStyle: true,
|
|
||||||
padding: 15,
|
|
||||||
font: { size: 12 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
|
||||||
titleFont: { size: 14, weight: 'bold' },
|
|
||||||
bodyFont: { size: 13 },
|
|
||||||
padding: 12,
|
|
||||||
cornerRadius: 8
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
cutout: '60%',
|
point: {
|
||||||
animation: {
|
radius: 4,
|
||||||
animateRotate: true,
|
hoverRadius: 6,
|
||||||
animateScale: true,
|
hitRadius: 10
|
||||||
duration: 1000,
|
|
||||||
easing: 'easeOutQuart'
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
duration: 1000,
|
||||||
|
easing: 'easeOutQuart'
|
||||||
}
|
}
|
||||||
""";
|
}
|
||||||
case "pie" -> """
|
""";
|
||||||
{
|
case "bar" -> """
|
||||||
responsive: true,
|
{
|
||||||
maintainAspectRatio: false,
|
responsive: true,
|
||||||
plugins: {
|
maintainAspectRatio: false,
|
||||||
legend: {
|
plugins: {
|
||||||
position: 'right',
|
legend: {
|
||||||
labels: {
|
position: 'bottom',
|
||||||
usePointStyle: true,
|
labels: {
|
||||||
padding: 15,
|
usePointStyle: true,
|
||||||
font: { size: 12 }
|
padding: 20
|
||||||
}
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
|
||||||
titleFont: { size: 14, weight: 'bold' },
|
|
||||||
bodyFont: { size: 13 },
|
|
||||||
padding: 12,
|
|
||||||
cornerRadius: 8
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
animation: {
|
tooltip: {
|
||||||
animateRotate: true,
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
animateScale: true,
|
titleFont: { size: 14, weight: 'bold' },
|
||||||
duration: 1000,
|
bodyFont: { size: 13 },
|
||||||
easing: 'easeOutQuart'
|
padding: 12,
|
||||||
|
cornerRadius: 8
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
""";
|
scales: {
|
||||||
case "radar" -> """
|
y: {
|
||||||
{
|
beginAtZero: true,
|
||||||
responsive: true,
|
grid: {
|
||||||
maintainAspectRatio: false,
|
color: 'rgba(0,0,0,0.05)'
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'top',
|
|
||||||
labels: {
|
|
||||||
usePointStyle: true,
|
|
||||||
padding: 15
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
|
||||||
titleFont: { size: 14, weight: 'bold' },
|
|
||||||
bodyFont: { size: 13 },
|
|
||||||
padding: 12,
|
|
||||||
cornerRadius: 8
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
x: {
|
||||||
r: {
|
grid: {
|
||||||
beginAtZero: true,
|
display: false
|
||||||
grid: {
|
|
||||||
color: 'rgba(0,0,0,0.1)'
|
|
||||||
},
|
|
||||||
pointLabels: {
|
|
||||||
font: { size: 12 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
elements: {
|
|
||||||
line: {
|
|
||||||
borderWidth: 2
|
|
||||||
},
|
|
||||||
point: {
|
|
||||||
radius: 4,
|
|
||||||
hoverRadius: 6
|
|
||||||
}
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
duration: 1000,
|
|
||||||
easing: 'easeOutQuart'
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
bar: {
|
||||||
|
borderRadius: 6,
|
||||||
|
borderSkipped: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
duration: 800,
|
||||||
|
easing: 'easeOutQuart'
|
||||||
}
|
}
|
||||||
""";
|
}
|
||||||
case "polarArea" -> """
|
""";
|
||||||
{
|
case "doughnut" -> """
|
||||||
responsive: true,
|
{
|
||||||
maintainAspectRatio: false,
|
responsive: true,
|
||||||
plugins: {
|
maintainAspectRatio: false,
|
||||||
legend: {
|
plugins: {
|
||||||
position: 'right',
|
legend: {
|
||||||
labels: {
|
position: 'right',
|
||||||
usePointStyle: true,
|
labels: {
|
||||||
padding: 15,
|
usePointStyle: true,
|
||||||
font: { size: 12 }
|
padding: 15,
|
||||||
}
|
font: { size: 12 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
titleFont: { size: 14, weight: 'bold' },
|
||||||
|
bodyFont: { size: 13 },
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cutout: '60%',
|
||||||
|
animation: {
|
||||||
|
animateRotate: true,
|
||||||
|
animateScale: true,
|
||||||
|
duration: 1000,
|
||||||
|
easing: 'easeOutQuart'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
case "pie" -> """
|
||||||
|
{
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 15,
|
||||||
|
font: { size: 12 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
titleFont: { size: 14, weight: 'bold' },
|
||||||
|
bodyFont: { size: 13 },
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
animateRotate: true,
|
||||||
|
animateScale: true,
|
||||||
|
duration: 1000,
|
||||||
|
easing: 'easeOutQuart'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
case "radar" -> """
|
||||||
|
{
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 15
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
titleFont: { size: 14, weight: 'bold' },
|
||||||
|
bodyFont: { size: 13 },
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(0,0,0,0.1)'
|
||||||
},
|
},
|
||||||
tooltip: {
|
pointLabels: {
|
||||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
font: { size: 12 }
|
||||||
titleFont: { size: 14, weight: 'bold' },
|
|
||||||
bodyFont: { size: 13 },
|
|
||||||
padding: 12,
|
|
||||||
cornerRadius: 8
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
r: {
|
|
||||||
beginAtZero: true,
|
|
||||||
grid: {
|
|
||||||
color: 'rgba(0,0,0,0.1)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
animateRotate: true,
|
|
||||||
animateScale: true,
|
|
||||||
duration: 1000,
|
|
||||||
easing: 'easeOutQuart'
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
line: {
|
||||||
|
borderWidth: 2
|
||||||
|
},
|
||||||
|
point: {
|
||||||
|
radius: 4,
|
||||||
|
hoverRadius: 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
duration: 1000,
|
||||||
|
easing: 'easeOutQuart'
|
||||||
}
|
}
|
||||||
""";
|
}
|
||||||
default -> """
|
""";
|
||||||
{
|
case "polarArea" -> """
|
||||||
responsive: true,
|
{
|
||||||
maintainAspectRatio: false,
|
responsive: true,
|
||||||
plugins: {
|
maintainAspectRatio: false,
|
||||||
legend: {
|
plugins: {
|
||||||
position: 'bottom'
|
legend: {
|
||||||
|
position: 'right',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 15,
|
||||||
|
font: { size: 12 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
animation: {
|
tooltip: {
|
||||||
duration: 800
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
titleFont: { size: 14, weight: 'bold' },
|
||||||
|
bodyFont: { size: 13 },
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 8
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(0,0,0,0.1)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
animateRotate: true,
|
||||||
|
animateScale: true,
|
||||||
|
duration: 1000,
|
||||||
|
easing: 'easeOutQuart'
|
||||||
}
|
}
|
||||||
""";
|
}
|
||||||
|
""";
|
||||||
|
default -> """
|
||||||
|
{
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
duration: 800
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private String formatMarkdown(String text) {
|
private String formatMarkdown(String text) {
|
||||||
if (text == null) return "";
|
if (text == null)
|
||||||
|
return "";
|
||||||
// Einfache Markdown-Formatierung
|
// Einfache Markdown-Formatierung
|
||||||
return text
|
return text.replace("\n", "<br>").replaceAll("\\*\\*(.+?)\\*\\*", "<strong>$1</strong>")
|
||||||
.replace("\n", "<br>")
|
.replaceAll("\\*(.+?)\\*", "<em>$1</em>").replaceAll("`(.+?)`", "<code>$1</code>");
|
||||||
.replaceAll("\\*\\*(.+?)\\*\\*", "<strong>$1</strong>")
|
|
||||||
.replaceAll("\\*(.+?)\\*", "<em>$1</em>")
|
|
||||||
.replaceAll("`(.+?)`", "<code>$1</code>");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void scrollToBottom() {
|
private void scrollToBottom() {
|
||||||
chatContainer.getElement().executeJs(
|
chatContainer.getElement().executeJs("this.parentElement.scrollTop = this.parentElement.scrollHeight");
|
||||||
"this.parentElement.scrollTop = this.parentElement.scrollHeight");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ import de.assecutor.votianlt.model.MessageOrigin;
|
|||||||
import de.assecutor.votianlt.model.MessageType;
|
import de.assecutor.votianlt.model.MessageType;
|
||||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||||
import de.assecutor.votianlt.service.MessageService;
|
import de.assecutor.votianlt.service.MessageService;
|
||||||
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -46,18 +46,17 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
|
|
||||||
private String participantKey;
|
private String participantKey;
|
||||||
private VerticalLayout contentLayout;
|
private VerticalLayout contentLayout;
|
||||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
|
|
||||||
|
|
||||||
public UserMessagesView(AppUserService appUserService, MessageService messageService) {
|
public UserMessagesView(AppUserService appUserService, MessageService messageService) {
|
||||||
this.appUserService = appUserService;
|
this.appUserService = appUserService;
|
||||||
this.messageService = messageService;
|
this.messageService = messageService;
|
||||||
|
|
||||||
// Create main layout
|
// Create main layout
|
||||||
contentLayout = new VerticalLayout();
|
contentLayout = new VerticalLayout();
|
||||||
contentLayout.setPadding(true);
|
contentLayout.setPadding(true);
|
||||||
contentLayout.setSpacing(true);
|
contentLayout.setSpacing(true);
|
||||||
contentLayout.setWidthFull();
|
contentLayout.setWidthFull();
|
||||||
|
|
||||||
add(contentLayout);
|
add(contentLayout);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,15 +78,15 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
log.debug("Could not resolve AppUser for participant key {}: {}", participantKey, e.getMessage());
|
log.debug("Could not resolve AppUser for participant key {}: {}", participantKey, e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
String clientName = client != null ?
|
String clientName = client != null ? client.getVorname() + " " + client.getNachname()
|
||||||
client.getVorname() + " " + client.getNachname() : Optional.ofNullable(participantKey).orElse("Unbekannter Teilnehmer");
|
: Optional.ofNullable(participantKey).orElse("Unbekannter Teilnehmer");
|
||||||
|
|
||||||
HorizontalLayout headerLayout = createHeaderLayout(clientName);
|
HorizontalLayout headerLayout = createHeaderLayout(clientName);
|
||||||
contentLayout.add(headerLayout);
|
contentLayout.add(headerLayout);
|
||||||
|
|
||||||
List<Message> conversation = messageService.getMessagesForAppUserAscending(participantKey);
|
List<Message> conversation = messageService.getMessagesForAppUserAscending(participantKey);
|
||||||
Map<MessageType, List<Message>> messagesByType = conversation.stream()
|
Map<MessageType, List<Message>> messagesByType = conversation.stream().collect(Collectors
|
||||||
.collect(Collectors.groupingBy(message -> Optional.ofNullable(message.getMessageType()).orElse(MessageType.GENERAL)));
|
.groupingBy(message -> Optional.ofNullable(message.getMessageType()).orElse(MessageType.GENERAL)));
|
||||||
|
|
||||||
VerticalLayout generalSection = createGeneralMessagesSection(messagesByType.get(MessageType.GENERAL));
|
VerticalLayout generalSection = createGeneralMessagesSection(messagesByType.get(MessageType.GENERAL));
|
||||||
|
|
||||||
@@ -99,14 +98,14 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
private HorizontalLayout createHeaderLayout(String clientName) {
|
private HorizontalLayout createHeaderLayout(String clientName) {
|
||||||
Button backButton = new Button("Zurück", VaadinIcon.ARROW_LEFT.create());
|
Button backButton = new Button("Zurück", VaadinIcon.ARROW_LEFT.create());
|
||||||
backButton.addClickListener(e -> UI.getCurrent().navigate("messages"));
|
backButton.addClickListener(e -> UI.getCurrent().navigate("messages"));
|
||||||
|
|
||||||
H2 title = new H2("Nachrichten mit " + clientName);
|
H2 title = new H2("Nachrichten mit " + clientName);
|
||||||
|
|
||||||
HorizontalLayout layout = new HorizontalLayout(backButton, title);
|
HorizontalLayout layout = new HorizontalLayout(backButton, title);
|
||||||
layout.setWidthFull();
|
layout.setWidthFull();
|
||||||
layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
|
layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
|
||||||
layout.setSpacing(true);
|
layout.setSpacing(true);
|
||||||
|
|
||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,24 +126,18 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
sortedMessages.addAll(generalMessages);
|
sortedMessages.addAll(generalMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
sortedMessages.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)));
|
sortedMessages
|
||||||
|
.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)));
|
||||||
|
|
||||||
Message latest = sortedMessages.isEmpty() ? null : sortedMessages.get(sortedMessages.size() - 1);
|
Message latest = sortedMessages.isEmpty() ? null : sortedMessages.get(sortedMessages.size() - 1);
|
||||||
int unreadCount = (int) sortedMessages.stream()
|
int unreadCount = (int) sortedMessages.stream()
|
||||||
.filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead())
|
.filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()).count();
|
||||||
.count();
|
|
||||||
int messageCount = sortedMessages.size();
|
int messageCount = sortedMessages.size();
|
||||||
LocalDateTime lastMessageTime = latest != null ? latest.getCreatedAt() : null;
|
LocalDateTime lastMessageTime = latest != null ? latest.getCreatedAt() : null;
|
||||||
String preview = resolvePreview(latest);
|
String preview = resolvePreview(latest);
|
||||||
|
|
||||||
section.add(createMessageCard(
|
section.add(createMessageCard("Allgemeine Unterhaltung", preview, lastMessageTime, messageCount, unreadCount,
|
||||||
"Allgemeine Unterhaltung",
|
"general"));
|
||||||
preview,
|
|
||||||
lastMessageTime,
|
|
||||||
messageCount,
|
|
||||||
unreadCount,
|
|
||||||
"general"
|
|
||||||
));
|
|
||||||
|
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
@@ -173,18 +166,11 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
messages.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)));
|
messages.sort(Comparator.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)));
|
||||||
Message latest = messages.get(messages.size() - 1);
|
Message latest = messages.get(messages.size() - 1);
|
||||||
int unreadCount = (int) messages.stream()
|
int unreadCount = (int) messages.stream()
|
||||||
.filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead())
|
.filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()).count();
|
||||||
.count();
|
|
||||||
|
|
||||||
String conversationTitle = "Auftrag " + jobKey;
|
String conversationTitle = "Auftrag " + jobKey;
|
||||||
section.add(createMessageCard(
|
section.add(createMessageCard(conversationTitle, resolvePreview(latest), latest.getCreatedAt(),
|
||||||
conversationTitle,
|
messages.size(), unreadCount, "job-" + sanitizeConversationId(jobKey)));
|
||||||
resolvePreview(latest),
|
|
||||||
latest.getCreatedAt(),
|
|
||||||
messages.size(),
|
|
||||||
unreadCount,
|
|
||||||
"job-" + sanitizeConversationId(jobKey)
|
|
||||||
));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return section;
|
return section;
|
||||||
@@ -199,14 +185,11 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
return "[Bildnachricht]";
|
return "[Bildnachricht]";
|
||||||
}
|
}
|
||||||
|
|
||||||
return Optional.ofNullable(message.getContent())
|
return Optional.ofNullable(message.getContent()).map(String::trim).orElse("");
|
||||||
.map(String::trim)
|
|
||||||
.orElse("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Div createMessageCard(String conversationTitle, String lastMessagePreview,
|
private Div createMessageCard(String conversationTitle, String lastMessagePreview, LocalDateTime lastMessageTime,
|
||||||
LocalDateTime lastMessageTime, int messageCount,
|
int messageCount, int unreadCount, String conversationId) {
|
||||||
int unreadCount, String conversationId) {
|
|
||||||
Div card = new Div();
|
Div card = new Div();
|
||||||
card.setWidthFull();
|
card.setWidthFull();
|
||||||
card.getStyle().set("padding", "15px");
|
card.getStyle().set("padding", "15px");
|
||||||
@@ -217,7 +200,7 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
card.getStyle().set("margin-bottom", "10px");
|
card.getStyle().set("margin-bottom", "10px");
|
||||||
card.getStyle().set("max-width", "97.5%");
|
card.getStyle().set("max-width", "97.5%");
|
||||||
card.addClassName("message-card");
|
card.addClassName("message-card");
|
||||||
|
|
||||||
// Hover effect
|
// Hover effect
|
||||||
card.getElement().addEventListener("mouseenter", e -> {
|
card.getElement().addEventListener("mouseenter", e -> {
|
||||||
card.getStyle().set("background-color", "#f5f5f5");
|
card.getStyle().set("background-color", "#f5f5f5");
|
||||||
@@ -225,16 +208,16 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
card.getElement().addEventListener("mouseleave", e -> {
|
card.getElement().addEventListener("mouseleave", e -> {
|
||||||
card.getStyle().set("background-color", "#ffffff");
|
card.getStyle().set("background-color", "#ffffff");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Title row with unread indicator
|
// Title row with unread indicator
|
||||||
HorizontalLayout titleRow = new HorizontalLayout();
|
HorizontalLayout titleRow = new HorizontalLayout();
|
||||||
titleRow.setWidthFull();
|
titleRow.setWidthFull();
|
||||||
titleRow.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
|
titleRow.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
|
||||||
|
|
||||||
Span titleSpan = new Span(conversationTitle);
|
Span titleSpan = new Span(conversationTitle);
|
||||||
titleSpan.getStyle().set("font-weight", "bold");
|
titleSpan.getStyle().set("font-weight", "bold");
|
||||||
titleSpan.getStyle().set("font-size", "16px");
|
titleSpan.getStyle().set("font-size", "16px");
|
||||||
|
|
||||||
if (unreadCount > 0) {
|
if (unreadCount > 0) {
|
||||||
Span unreadBadge = new Span(String.valueOf(unreadCount));
|
Span unreadBadge = new Span(String.valueOf(unreadCount));
|
||||||
unreadBadge.getStyle().set("background-color", "var(--lumo-primary-color)");
|
unreadBadge.getStyle().set("background-color", "var(--lumo-primary-color)");
|
||||||
@@ -247,38 +230,40 @@ public class UserMessagesView extends Main implements HasUrlParameter<String> {
|
|||||||
} else {
|
} else {
|
||||||
titleRow.add(titleSpan);
|
titleRow.add(titleSpan);
|
||||||
}
|
}
|
||||||
|
|
||||||
titleRow.expand(titleSpan);
|
titleRow.expand(titleSpan);
|
||||||
|
|
||||||
// Preview text
|
// Preview text
|
||||||
Span preview = new Span(Optional.ofNullable(lastMessagePreview).filter(s -> !s.isBlank()).orElse("(kein Inhalt)"));
|
Span preview = new Span(
|
||||||
|
Optional.ofNullable(lastMessagePreview).filter(s -> !s.isBlank()).orElse("(kein Inhalt)"));
|
||||||
preview.getStyle().set("color", "#666666");
|
preview.getStyle().set("color", "#666666");
|
||||||
preview.getStyle().set("font-size", "14px");
|
preview.getStyle().set("font-size", "14px");
|
||||||
|
|
||||||
// Metadata row
|
// Metadata row
|
||||||
HorizontalLayout metaRow = new HorizontalLayout();
|
HorizontalLayout metaRow = new HorizontalLayout();
|
||||||
metaRow.setWidthFull();
|
metaRow.setWidthFull();
|
||||||
|
|
||||||
Span timeSpan = new Span(lastMessageTime != null ? lastMessageTime.format(DATE_FORMATTER) : "-");
|
Span timeSpan = new Span(lastMessageTime != null ? DateTimeFormatUtil.formatDateTime(lastMessageTime) : "-");
|
||||||
timeSpan.getStyle().set("color", "#999999");
|
timeSpan.getStyle().set("color", "#999999");
|
||||||
timeSpan.getStyle().set("font-size", "12px");
|
timeSpan.getStyle().set("font-size", "12px");
|
||||||
|
|
||||||
Span countSpan = new Span(messageCount + " Nachrichten");
|
Span countSpan = new Span(messageCount + " Nachrichten");
|
||||||
countSpan.getStyle().set("color", "#999999");
|
countSpan.getStyle().set("color", "#999999");
|
||||||
countSpan.getStyle().set("font-size", "12px");
|
countSpan.getStyle().set("font-size", "12px");
|
||||||
|
|
||||||
metaRow.add(timeSpan, countSpan);
|
metaRow.add(timeSpan, countSpan);
|
||||||
metaRow.expand(timeSpan);
|
metaRow.expand(timeSpan);
|
||||||
|
|
||||||
// Add all elements to card
|
// Add all elements to card
|
||||||
VerticalLayout cardContent = new VerticalLayout(titleRow, preview, metaRow);
|
VerticalLayout cardContent = new VerticalLayout(titleRow, preview, metaRow);
|
||||||
cardContent.setWidthFull();
|
cardContent.setWidthFull();
|
||||||
cardContent.setPadding(false);
|
cardContent.setPadding(false);
|
||||||
cardContent.setSpacing(false);
|
cardContent.setSpacing(false);
|
||||||
card.add(cardContent);
|
card.add(cardContent);
|
||||||
|
|
||||||
// Click listener to navigate to message details
|
// Click listener to navigate to message details
|
||||||
card.addClickListener(e -> UI.getCurrent().navigate("message-details/" + participantKey + "/" + conversationId));
|
card.addClickListener(
|
||||||
|
e -> UI.getCurrent().navigate("message-details/" + participantKey + "/" + conversationId));
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,10 +92,10 @@ public interface JobRepository extends MongoRepository<Job, ObjectId> {
|
|||||||
long countByIsDraftTrue();
|
long countByIsDraftTrue();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Findet alle nicht abgeschlossenen Aufträge, die einem bestimmten App-Nutzer zugewiesen sind.
|
* Findet alle nicht abgeschlossenen Aufträge, die einem bestimmten App-Nutzer
|
||||||
* Excludes jobs with status COMPLETED or CANCELLED.
|
* zugewiesen sind. Excludes jobs with status COMPLETED or CANCELLED. Uses
|
||||||
* Uses explicit query because @Field("app_user") annotation is not always
|
* explicit query because @Field("app_user") annotation is not always respected
|
||||||
* respected by Spring Data MongoDB query derivation.
|
* by Spring Data MongoDB query derivation.
|
||||||
*/
|
*/
|
||||||
@Query("{'app_user': ?0, 'status': {'$nin': ['COMPLETED', 'CANCELLED']}}")
|
@Query("{'app_user': ?0, 'status': {'$nin': ['COMPLETED', 'CANCELLED']}}")
|
||||||
List<Job> findByAppUser(String appUser);
|
List<Job> findByAppUser(String appUser);
|
||||||
@@ -109,8 +109,17 @@ public interface JobRepository extends MongoRepository<Job, ObjectId> {
|
|||||||
/**
|
/**
|
||||||
* Erweiterte Suche: Zeitraum, Auftragsnummer und Status kombiniert
|
* Erweiterte Suche: Zeitraum, Auftragsnummer und Status kombiniert
|
||||||
*/
|
*/
|
||||||
@Query("{'createdAt': {'$gte': ?0, '$lte': ?1}, "
|
@Query("{'createdAt': {'$gte': ?0, '$lte': ?1}, " + "'jobNumber': {'$regex': ?2, '$options': 'i'}, "
|
||||||
+ "'jobNumber': {'$regex': ?2, '$options': 'i'}, " + "'status': {'$in': ?3}}")
|
+ "'status': {'$in': ?3}}")
|
||||||
List<Job> findWithFilters(LocalDateTime startDate, LocalDateTime endDate, String jobNumberPattern,
|
List<Job> findWithFilters(LocalDateTime startDate, LocalDateTime endDate, String jobNumberPattern,
|
||||||
List<JobStatus> statusList);
|
List<JobStatus> statusList);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erweiterte Suche mit Benutzerfilter: Zeitraum, Auftragsnummer, Status und
|
||||||
|
* Ersteller
|
||||||
|
*/
|
||||||
|
@Query("{'created_by': ?0, 'createdAt': {'$gte': ?1, '$lte': ?2}, "
|
||||||
|
+ "'jobNumber': {'$regex': ?3, '$options': 'i'}, " + "'status': {'$in': ?4}}")
|
||||||
|
List<Job> findWithFiltersByCreatedBy(String createdBy, LocalDateTime startDate, LocalDateTime endDate,
|
||||||
|
String jobNumberPattern, List<JobStatus> statusList);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import java.util.List;
|
|||||||
public interface MessageRepository extends MongoRepository<Message, ObjectId> {
|
public interface MessageRepository extends MongoRepository<Message, ObjectId> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find all messages for a specific receiver (AppUser ID), ordered by creation time ascending (oldest first)
|
* Find all messages for a specific receiver (AppUser ID), ordered by creation
|
||||||
|
* time ascending (oldest first)
|
||||||
*/
|
*/
|
||||||
List<Message> findByReceiverOrderByCreatedAtAsc(String receiver);
|
List<Message> findByReceiverOrderByCreatedAtAsc(String receiver);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find all messages for a specific receiver (AppUser ID), ordered by creation time descending (newest first)
|
* Find all messages for a specific receiver (AppUser ID), ordered by creation
|
||||||
|
* time descending (newest first)
|
||||||
*/
|
*/
|
||||||
List<Message> findByReceiverOrderByCreatedAtDesc(String receiver);
|
List<Message> findByReceiverOrderByCreatedAtDesc(String receiver);
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ public interface PendingDeliveryRepository extends MongoRepository<PendingDelive
|
|||||||
List<PendingDelivery> findByStatus(DeliveryStatus status);
|
List<PendingDelivery> findByStatus(DeliveryStatus status);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find deliveries ready for retry (status = SENT and nextRetryAt is in the past)
|
* Find deliveries ready for retry (status = SENT and nextRetryAt is in the
|
||||||
|
* past)
|
||||||
*/
|
*/
|
||||||
List<PendingDelivery> findByStatusAndNextRetryAtBefore(DeliveryStatus status, LocalDateTime dateTime);
|
List<PendingDelivery> findByStatusAndNextRetryAtBefore(DeliveryStatus status, LocalDateTime dateTime);
|
||||||
|
|
||||||
@@ -66,4 +67,3 @@ public interface PendingDeliveryRepository extends MongoRepository<PendingDelive
|
|||||||
*/
|
*/
|
||||||
void deleteByCreatedAtBefore(LocalDateTime dateTime);
|
void deleteByCreatedAtBefore(LocalDateTime dateTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import java.util.Set;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for managing client connections via Ping/Pong mechanism.
|
* Service for managing client connections via Ping/Pong mechanism. Tracks
|
||||||
* Tracks connected clients and periodically checks their connectivity.
|
* connected clients and periodically checks their connectivity.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -27,14 +27,8 @@ public class ClientConnectionService {
|
|||||||
/**
|
/**
|
||||||
* Represents the connection state of a client.
|
* Represents the connection state of a client.
|
||||||
*/
|
*/
|
||||||
public record ClientState(
|
public record ClientState(String clientId, String userId, boolean connected, Instant lastPingSent,
|
||||||
String clientId,
|
Instant lastPongReceived, Instant connectedAt) {
|
||||||
String userId,
|
|
||||||
boolean connected,
|
|
||||||
Instant lastPingSent,
|
|
||||||
Instant lastPongReceived,
|
|
||||||
Instant connectedAt
|
|
||||||
) {
|
|
||||||
public ClientState withPingSent(Instant pingSent) {
|
public ClientState withPingSent(Instant pingSent) {
|
||||||
return new ClientState(clientId, userId, connected, pingSent, lastPongReceived, connectedAt);
|
return new ClientState(clientId, userId, connected, pingSent, lastPongReceived, connectedAt);
|
||||||
}
|
}
|
||||||
@@ -60,7 +54,7 @@ public class ClientConnectionService {
|
|||||||
private int pingTimeoutSeconds;
|
private int pingTimeoutSeconds;
|
||||||
|
|
||||||
public ClientConnectionService(PluginManager pluginManager, ObjectMapper objectMapper,
|
public ClientConnectionService(PluginManager pluginManager, ObjectMapper objectMapper,
|
||||||
@Lazy MessageDeliveryService messageDeliveryService) {
|
@Lazy MessageDeliveryService messageDeliveryService) {
|
||||||
this.pluginManager = pluginManager;
|
this.pluginManager = pluginManager;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.messageDeliveryService = messageDeliveryService;
|
this.messageDeliveryService = messageDeliveryService;
|
||||||
@@ -69,8 +63,10 @@ public class ClientConnectionService {
|
|||||||
/**
|
/**
|
||||||
* Registers a client as connected after successful login.
|
* Registers a client as connected after successful login.
|
||||||
*
|
*
|
||||||
* @param clientId The unique client identifier
|
* @param clientId
|
||||||
* @param userId The user ID associated with this client
|
* The unique client identifier
|
||||||
|
* @param userId
|
||||||
|
* The user ID associated with this client
|
||||||
*/
|
*/
|
||||||
public void registerClient(String clientId, String userId) {
|
public void registerClient(String clientId, String userId) {
|
||||||
if (clientId == null || clientId.isBlank()) {
|
if (clientId == null || clientId.isBlank()) {
|
||||||
@@ -84,8 +80,8 @@ public class ClientConnectionService {
|
|||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
ClientState state = new ClientState(clientId, userId, true, null, now, now);
|
ClientState state = new ClientState(clientId, userId, true, null, now, now);
|
||||||
connectedClients.put(clientId, state);
|
connectedClients.put(clientId, state);
|
||||||
log.info("[ClientConnectionService] Client registered: clientId={}, userId={}, totalClients={}",
|
log.info("[ClientConnectionService] Client registered: clientId={}, userId={}, totalClients={}", clientId,
|
||||||
clientId, userId, connectedClients.size());
|
userId, connectedClients.size());
|
||||||
|
|
||||||
// If client was previously disconnected, retry pending messages
|
// If client was previously disconnected, retry pending messages
|
||||||
if (wasDisconnected) {
|
if (wasDisconnected) {
|
||||||
@@ -97,7 +93,8 @@ public class ClientConnectionService {
|
|||||||
/**
|
/**
|
||||||
* Unregisters a client (e.g., on explicit logout).
|
* Unregisters a client (e.g., on explicit logout).
|
||||||
*
|
*
|
||||||
* @param clientId The client identifier to unregister
|
* @param clientId
|
||||||
|
* The client identifier to unregister
|
||||||
*/
|
*/
|
||||||
public void unregisterClient(String clientId) {
|
public void unregisterClient(String clientId) {
|
||||||
ClientState removed = connectedClients.remove(clientId);
|
ClientState removed = connectedClients.remove(clientId);
|
||||||
@@ -107,10 +104,11 @@ public class ClientConnectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles a pong response from a client.
|
* Handles a pong response from a client. Searches by both clientId and userId
|
||||||
* Searches by both clientId and userId since pong is sent to /server/{userId}/pong.
|
* since pong is sent to /server/{userId}/pong.
|
||||||
*
|
*
|
||||||
* @param id The client or user identifier that sent the pong
|
* @param id
|
||||||
|
* The client or user identifier that sent the pong
|
||||||
*/
|
*/
|
||||||
public void handlePong(String id) {
|
public void handlePong(String id) {
|
||||||
if (id == null || id.isBlank()) {
|
if (id == null || id.isBlank()) {
|
||||||
@@ -149,10 +147,11 @@ public class ClientConnectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a client is currently connected.
|
* Checks if a client is currently connected. Searches by both clientId and
|
||||||
* Searches by both clientId and userId.
|
* userId.
|
||||||
*
|
*
|
||||||
* @param id The client or user identifier
|
* @param id
|
||||||
|
* The client or user identifier
|
||||||
* @return true if the client is connected
|
* @return true if the client is connected
|
||||||
*/
|
*/
|
||||||
public boolean isClientConnected(String id) {
|
public boolean isClientConnected(String id) {
|
||||||
@@ -165,8 +164,7 @@ public class ClientConnectionService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Then search by userId
|
// Then search by userId
|
||||||
return connectedClients.values().stream()
|
return connectedClients.values().stream().anyMatch(s -> s.connected() && id.equals(s.userId()));
|
||||||
.anyMatch(s -> s.connected() && id.equals(s.userId()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,16 +173,15 @@ public class ClientConnectionService {
|
|||||||
* @return Set of connected client IDs
|
* @return Set of connected client IDs
|
||||||
*/
|
*/
|
||||||
public Set<String> getConnectedClientIds() {
|
public Set<String> getConnectedClientIds() {
|
||||||
return connectedClients.entrySet().stream()
|
return connectedClients.entrySet().stream().filter(e -> e.getValue().connected()).map(Map.Entry::getKey)
|
||||||
.filter(e -> e.getValue().connected())
|
|
||||||
.map(Map.Entry::getKey)
|
|
||||||
.collect(java.util.stream.Collectors.toSet());
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the connection state for a specific client.
|
* Gets the connection state for a specific client.
|
||||||
*
|
*
|
||||||
* @param clientId The client identifier
|
* @param clientId
|
||||||
|
* The client identifier
|
||||||
* @return ClientState or null if not found
|
* @return ClientState or null if not found
|
||||||
*/
|
*/
|
||||||
public ClientState getClientState(String clientId) {
|
public ClientState getClientState(String clientId) {
|
||||||
@@ -192,8 +189,8 @@ public class ClientConnectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheduled task to send pings to all connected clients.
|
* Scheduled task to send pings to all connected clients. Runs based on the
|
||||||
* Runs based on the configured interval (app.client.ping.interval-seconds).
|
* configured interval (app.client.ping.interval-seconds).
|
||||||
*/
|
*/
|
||||||
@Scheduled(fixedRateString = "${app.client.ping.interval-seconds:15}000")
|
@Scheduled(fixedRateString = "${app.client.ping.interval-seconds:15}000")
|
||||||
public void sendPingsToAllClients() {
|
public void sendPingsToAllClients() {
|
||||||
@@ -228,8 +225,8 @@ public class ClientConnectionService {
|
|||||||
// Client did not respond in time - mark as disconnected
|
// Client did not respond in time - mark as disconnected
|
||||||
ClientState disconnectedState = state.withConnected(false);
|
ClientState disconnectedState = state.withConnected(false);
|
||||||
connectedClients.put(clientId, disconnectedState);
|
connectedClients.put(clientId, disconnectedState);
|
||||||
log.warn("Client timed out, marking as disconnected: clientId={}, userId={}",
|
log.warn("Client timed out, marking as disconnected: clientId={}, userId={}", clientId,
|
||||||
clientId, state.userId());
|
state.userId());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,22 +243,17 @@ public class ClientConnectionService {
|
|||||||
/**
|
/**
|
||||||
* Sends a ping message to a specific user.
|
* Sends a ping message to a specific user.
|
||||||
*
|
*
|
||||||
* @param userId The target user ID (MongoDB ObjectId)
|
* @param userId
|
||||||
|
* The target user ID (MongoDB ObjectId)
|
||||||
*/
|
*/
|
||||||
private void sendPing(String userId) {
|
private void sendPing(String userId) {
|
||||||
try {
|
try {
|
||||||
Map<String, Object> pingPayload = Map.of(
|
Map<String, Object> pingPayload = Map.of("type", "ping", "timestamp", Instant.now().toEpochMilli());
|
||||||
"type", "ping",
|
|
||||||
"timestamp", Instant.now().toEpochMilli()
|
|
||||||
);
|
|
||||||
|
|
||||||
String json = objectMapper.writeValueAsString(pingPayload);
|
String json = objectMapper.writeValueAsString(pingPayload);
|
||||||
byte[] payload = json.getBytes(StandardCharsets.UTF_8);
|
byte[] payload = json.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
SendOptions options = SendOptions.builder()
|
SendOptions options = SendOptions.builder().qos(1).retained(false).build();
|
||||||
.qos(1)
|
|
||||||
.retained(false)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
pluginManager.sendToClient(userId, "ping", payload, options);
|
pluginManager.sendToClient(userId, "ping", payload, options);
|
||||||
|
|
||||||
@@ -276,9 +268,7 @@ public class ClientConnectionService {
|
|||||||
* @return Number of connected clients
|
* @return Number of connected clients
|
||||||
*/
|
*/
|
||||||
public int getConnectedClientCount() {
|
public int getConnectedClientCount() {
|
||||||
return (int) connectedClients.values().stream()
|
return (int) connectedClients.values().stream().filter(ClientState::connected).count();
|
||||||
.filter(ClientState::connected)
|
|
||||||
.count();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -85,15 +85,12 @@ public class CustomerInvoiceService {
|
|||||||
List<CustomerInvoiceItem> items = new ArrayList<>();
|
List<CustomerInvoiceItem> items = new ArrayList<>();
|
||||||
BigDecimal vatRate = new BigDecimal("0.19"); // 19% MwSt.
|
BigDecimal vatRate = new BigDecimal("0.19"); // 19% MwSt.
|
||||||
|
|
||||||
CustomerInvoiceItem item1 = new CustomerInvoiceItem(
|
CustomerInvoiceItem item1 = new CustomerInvoiceItem(new BigDecimal("2"), "Std.", "Transportdienstleistung",
|
||||||
new BigDecimal("2"), "Std.", "Transportdienstleistung",
|
new BigDecimal("85.00"), vatRate);
|
||||||
new BigDecimal("85.00"), vatRate);
|
CustomerInvoiceItem item2 = new CustomerInvoiceItem(new BigDecimal("1"), "Stk.", "Logistikkoordination",
|
||||||
CustomerInvoiceItem item2 = new CustomerInvoiceItem(
|
new BigDecimal("120.00"), vatRate);
|
||||||
new BigDecimal("1"), "Stk.", "Logistikkoordination",
|
CustomerInvoiceItem item3 = new CustomerInvoiceItem(new BigDecimal("50"), "km", "Kilometergebühr",
|
||||||
new BigDecimal("120.00"), vatRate);
|
new BigDecimal("0.60"), vatRate);
|
||||||
CustomerInvoiceItem item3 = new CustomerInvoiceItem(
|
|
||||||
new BigDecimal("50"), "km", "Kilometergebühr",
|
|
||||||
new BigDecimal("0.60"), vatRate);
|
|
||||||
|
|
||||||
items.add(item1);
|
items.add(item1);
|
||||||
items.add(item2);
|
items.add(item2);
|
||||||
@@ -101,9 +98,8 @@ public class CustomerInvoiceService {
|
|||||||
invoiceData.setItems(items);
|
invoiceData.setItems(items);
|
||||||
|
|
||||||
// Beträge berechnen
|
// Beträge berechnen
|
||||||
BigDecimal netAmount = items.stream()
|
BigDecimal netAmount = items.stream().map(CustomerInvoiceItem::getNetTotal).reduce(BigDecimal.ZERO,
|
||||||
.map(CustomerInvoiceItem::getNetTotal)
|
BigDecimal::add);
|
||||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
||||||
BigDecimal vatAmount = netAmount.multiply(vatRate);
|
BigDecimal vatAmount = netAmount.multiply(vatRate);
|
||||||
BigDecimal totalAmount = netAmount.add(vatAmount);
|
BigDecimal totalAmount = netAmount.add(vatAmount);
|
||||||
|
|
||||||
@@ -124,7 +120,7 @@ public class CustomerInvoiceService {
|
|||||||
|
|
||||||
return invoiceData;
|
return invoiceData;
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte[] generateCustomerInvoicePdf() throws Exception {
|
public byte[] generateCustomerInvoicePdf() throws Exception {
|
||||||
// Backward-compatible sample generation
|
// Backward-compatible sample generation
|
||||||
CustomerInvoiceData sampleData = createCustomerInvoiceData("customerId", "jobId");
|
CustomerInvoiceData sampleData = createCustomerInvoiceData("customerId", "jobId");
|
||||||
@@ -190,17 +186,13 @@ public class CustomerInvoiceService {
|
|||||||
StringBuilder itemRows = new StringBuilder();
|
StringBuilder itemRows = new StringBuilder();
|
||||||
for (CustomerInvoiceItem item : data.getItems()) {
|
for (CustomerInvoiceItem item : data.getItems()) {
|
||||||
itemRows.append("<tr>");
|
itemRows.append("<tr>");
|
||||||
itemRows.append("<td style='text-align: center;'>")
|
itemRows.append("<td style='text-align: center;'>").append(formatDecimal(item.getQuantity()))
|
||||||
.append(formatDecimal(item.getQuantity()))
|
.append(" ").append(nvl(item.getUnit())).append("</td>");
|
||||||
.append(" ").append(nvl(item.getUnit()))
|
|
||||||
.append("</td>");
|
|
||||||
itemRows.append("<td>").append(nvl(item.getDescription())).append("</td>");
|
itemRows.append("<td>").append(nvl(item.getDescription())).append("</td>");
|
||||||
itemRows.append("<td style='text-align: right;'>")
|
itemRows.append("<td style='text-align: right;'>").append(formatCurrency(item.getUnitPrice()))
|
||||||
.append(formatCurrency(item.getUnitPrice()))
|
.append("</td>");
|
||||||
.append("</td>");
|
itemRows.append("<td style='text-align: right;'>").append(formatCurrency(item.getNetTotal()))
|
||||||
itemRows.append("<td style='text-align: right;'>")
|
.append("</td>");
|
||||||
.append(formatCurrency(item.getNetTotal()))
|
|
||||||
.append("</td>");
|
|
||||||
itemRows.append("</tr>");
|
itemRows.append("</tr>");
|
||||||
}
|
}
|
||||||
filledHtml = filledHtml.replace("<!-- ITEM_ROWS -->", itemRows.toString());
|
filledHtml = filledHtml.replace("<!-- ITEM_ROWS -->", itemRows.toString());
|
||||||
@@ -231,12 +223,14 @@ public class CustomerInvoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String formatCurrency(BigDecimal amount) {
|
private String formatCurrency(BigDecimal amount) {
|
||||||
if (amount == null) return "0,00 €";
|
if (amount == null)
|
||||||
|
return "0,00 €";
|
||||||
return NumberFormat.getCurrencyInstance(Locale.GERMANY).format(amount);
|
return NumberFormat.getCurrencyInstance(Locale.GERMANY).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String formatDecimal(BigDecimal value) {
|
private String formatDecimal(BigDecimal value) {
|
||||||
if (value == null) return "0";
|
if (value == null)
|
||||||
|
return "0";
|
||||||
return NumberFormat.getNumberInstance(Locale.GERMANY).format(value);
|
return NumberFormat.getNumberInstance(Locale.GERMANY).format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -285,8 +285,8 @@ public class EmailService {
|
|||||||
body.append("ein neuer Job wurde erfolgreich erstellt:\n\n");
|
body.append("ein neuer Job wurde erfolgreich erstellt:\n\n");
|
||||||
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||||
|
|
||||||
if (job.getDeliveryCompany() != null) {
|
if (job.getCustomerSelection() != null && !job.getCustomerSelection().isBlank()) {
|
||||||
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
|
body.append("Auftraggeber: ").append(job.getCustomerSelection()).append("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (job.getPickupCity() != null || job.getDeliveryCity() != null) {
|
if (job.getPickupCity() != null || job.getDeliveryCity() != null) {
|
||||||
|
|||||||
@@ -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;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for job statistics and aggregations.
|
* Service for job statistics and aggregations. Provides data for MCP tools and
|
||||||
* Provides data for MCP tools and reporting.
|
* reporting.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -28,7 +28,7 @@ public class JobStatisticsService {
|
|||||||
|
|
||||||
private final JobRepository jobRepository;
|
private final JobRepository jobRepository;
|
||||||
private final TaskRepository taskRepository;
|
private final TaskRepository taskRepository;
|
||||||
|
|
||||||
public JobStatisticsService(JobRepository jobRepository, TaskRepository taskRepository) {
|
public JobStatisticsService(JobRepository jobRepository, TaskRepository taskRepository) {
|
||||||
this.jobRepository = jobRepository;
|
this.jobRepository = jobRepository;
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
@@ -83,10 +83,8 @@ public class JobStatisticsService {
|
|||||||
*/
|
*/
|
||||||
public BigDecimal getTotalRevenue() {
|
public BigDecimal getTotalRevenue() {
|
||||||
List<Job> allJobs = jobRepository.findAll();
|
List<Job> allJobs = jobRepository.findAll();
|
||||||
return allJobs.stream()
|
return allJobs.stream().map(Job::getPrice).filter(price -> price != null).reduce(BigDecimal.ZERO,
|
||||||
.map(Job::getPrice)
|
BigDecimal::add);
|
||||||
.filter(price -> price != null)
|
|
||||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,10 +108,8 @@ public class JobStatisticsService {
|
|||||||
* Get top customers by revenue.
|
* Get top customers by revenue.
|
||||||
*/
|
*/
|
||||||
public List<Map.Entry<String, BigDecimal>> getTopCustomersByRevenue(int limit) {
|
public List<Map.Entry<String, BigDecimal>> getTopCustomersByRevenue(int limit) {
|
||||||
return getRevenueByCustomer().entrySet().stream()
|
return getRevenueByCustomer().entrySet().stream().sorted((a, b) -> b.getValue().compareTo(a.getValue()))
|
||||||
.sorted((a, b) -> b.getValue().compareTo(a.getValue()))
|
.limit(limit).toList();
|
||||||
.limit(limit)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,7 +158,8 @@ public class JobStatisticsService {
|
|||||||
if (jobs.isEmpty()) {
|
if (jobs.isEmpty()) {
|
||||||
String containsRegex = ".*" + escapedCustomer + ".*";
|
String containsRegex = ".*" + escapedCustomer + ".*";
|
||||||
jobs = jobRepository.findByCustomerSelectionIgnoreCase(containsRegex);
|
jobs = jobRepository.findByCustomerSelectionIgnoreCase(containsRegex);
|
||||||
log.debug("getJobsByCustomer('{}') - contains regex: '{}' - found {} jobs", customer, containsRegex, jobs.size());
|
log.debug("getJobsByCustomer('{}') - contains regex: '{}' - found {} jobs", customer, containsRegex,
|
||||||
|
jobs.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
return jobs;
|
return jobs;
|
||||||
@@ -227,12 +224,8 @@ public class JobStatisticsService {
|
|||||||
* Get all available customer names for autocomplete/filtering.
|
* Get all available customer names for autocomplete/filtering.
|
||||||
*/
|
*/
|
||||||
public List<String> getAllCustomerNames() {
|
public List<String> getAllCustomerNames() {
|
||||||
return jobRepository.findAll().stream()
|
return jobRepository.findAll().stream().map(Job::getCustomerSelection).filter(c -> c != null && !c.isBlank())
|
||||||
.map(Job::getCustomerSelection)
|
.distinct().sorted().toList();
|
||||||
.filter(c -> c != null && !c.isBlank())
|
|
||||||
.distinct()
|
|
||||||
.sorted()
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,9 +254,7 @@ public class JobStatisticsService {
|
|||||||
* Get total revenue for a customer.
|
* Get total revenue for a customer.
|
||||||
*/
|
*/
|
||||||
public BigDecimal getTotalRevenueForCustomer(String customer) {
|
public BigDecimal getTotalRevenueForCustomer(String customer) {
|
||||||
return getJobsByCustomer(customer).stream()
|
return getJobsByCustomer(customer).stream().map(Job::getPrice).filter(price -> price != null)
|
||||||
.map(Job::getPrice)
|
|
||||||
.filter(price -> price != null)
|
|
||||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,9 +266,7 @@ public class JobStatisticsService {
|
|||||||
if (customerJobs.isEmpty()) {
|
if (customerJobs.isEmpty()) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
long completed = customerJobs.stream()
|
long completed = customerJobs.stream().filter(j -> j.getStatus() == JobStatus.COMPLETED).count();
|
||||||
.filter(j -> j.getStatus() == JobStatus.COMPLETED)
|
|
||||||
.count();
|
|
||||||
return (double) completed / customerJobs.size() * 100.0;
|
return (double) completed / customerJobs.size() * 100.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,11 +278,8 @@ public class JobStatisticsService {
|
|||||||
LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
|
LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
|
||||||
LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
|
LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
|
||||||
|
|
||||||
List<Job> customerJobs = getJobsByCustomer(customer).stream()
|
List<Job> customerJobs = getJobsByCustomer(customer).stream().filter(j -> j.getCreatedAt() != null
|
||||||
.filter(j -> j.getCreatedAt() != null
|
&& !j.getCreatedAt().isBefore(yearStart) && !j.getCreatedAt().isAfter(yearEnd)).toList();
|
||||||
&& !j.getCreatedAt().isBefore(yearStart)
|
|
||||||
&& !j.getCreatedAt().isAfter(yearEnd))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// Initialize all months with 0
|
// Initialize all months with 0
|
||||||
for (Month month : Month.values()) {
|
for (Month month : Month.values()) {
|
||||||
@@ -317,9 +303,7 @@ public class JobStatisticsService {
|
|||||||
if (status == null) {
|
if (status == null) {
|
||||||
return customerJobs;
|
return customerJobs;
|
||||||
}
|
}
|
||||||
return customerJobs.stream()
|
return customerJobs.stream().filter(j -> j.getStatus() == status).toList();
|
||||||
.filter(j -> j.getStatus() == status)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -351,7 +335,8 @@ public class JobStatisticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Third: extract potential customer name from query and check if it matches a customer
|
// Third: extract potential customer name from query and check if it matches a
|
||||||
|
// customer
|
||||||
// This handles cases like "cAPPacity GmbH" matching "cAPPacity GmbH & Co. KG"
|
// This handles cases like "cAPPacity GmbH" matching "cAPPacity GmbH & Co. KG"
|
||||||
String extractedName = extractCustomerNameFromQuery(query);
|
String extractedName = extractCustomerNameFromQuery(query);
|
||||||
if (extractedName != null) {
|
if (extractedName != null) {
|
||||||
@@ -360,13 +345,15 @@ public class JobStatisticsService {
|
|||||||
String lowerCustomer = customer.toLowerCase();
|
String lowerCustomer = customer.toLowerCase();
|
||||||
// Check if customer name starts with the extracted name, or contains it
|
// Check if customer name starts with the extracted name, or contains it
|
||||||
if (lowerCustomer.startsWith(lowerExtracted) || lowerCustomer.contains(lowerExtracted)) {
|
if (lowerCustomer.startsWith(lowerExtracted) || lowerCustomer.contains(lowerExtracted)) {
|
||||||
log.debug("findMatchingCustomer - Extracted name '{}' matches customer: '{}'", extractedName, customer);
|
log.debug("findMatchingCustomer - Extracted name '{}' matches customer: '{}'", extractedName,
|
||||||
|
customer);
|
||||||
return customer;
|
return customer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fourth: word match (any significant word from the query matches customer name)
|
// Fourth: word match (any significant word from the query matches customer
|
||||||
|
// name)
|
||||||
String[] queryWords = lowerQuery.split("\\s+");
|
String[] queryWords = lowerQuery.split("\\s+");
|
||||||
for (String customer : allCustomers) {
|
for (String customer : allCustomers) {
|
||||||
String lowerCustomer = customer.toLowerCase();
|
String lowerCustomer = customer.toLowerCase();
|
||||||
@@ -384,18 +371,15 @@ public class JobStatisticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract potential customer name from a query string.
|
* Extract potential customer name from a query string. Looks for patterns like
|
||||||
* Looks for patterns like "firma X", "kunde X", "für X", etc.
|
* "firma X", "kunde X", "für X", etc.
|
||||||
*/
|
*/
|
||||||
private String extractCustomerNameFromQuery(String query) {
|
private String extractCustomerNameFromQuery(String query) {
|
||||||
String lowerQuery = query.toLowerCase();
|
String lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
// Patterns that typically precede a customer name
|
// Patterns that typically precede a customer name
|
||||||
String[] patterns = {
|
String[] patterns = { "firma ", "kunde ", "kunden ", "unternehmen ", "für die firma ", "für den kunden ",
|
||||||
"firma ", "kunde ", "kunden ", "unternehmen ",
|
"von der firma ", "vom kunden ", "der firma ", "des kunden ", "bei " };
|
||||||
"für die firma ", "für den kunden ", "von der firma ", "vom kunden ",
|
|
||||||
"der firma ", "des kunden ", "bei "
|
|
||||||
};
|
|
||||||
|
|
||||||
for (String pattern : patterns) {
|
for (String pattern : patterns) {
|
||||||
int idx = lowerQuery.indexOf(pattern);
|
int idx = lowerQuery.indexOf(pattern);
|
||||||
@@ -421,10 +405,8 @@ public class JobStatisticsService {
|
|||||||
* Remove common trailing words from extracted customer name.
|
* Remove common trailing words from extracted customer name.
|
||||||
*/
|
*/
|
||||||
private String removeTrailingCommonWords(String text) {
|
private String removeTrailingCommonWords(String text) {
|
||||||
String[] trailingPatterns = {
|
String[] trailingPatterns = { " an$", " anzeigen$", " zeigen$", " auflisten$", " liste$", " status$",
|
||||||
" an$", " anzeigen$", " zeigen$", " auflisten$", " liste$",
|
" mit status$", " die$", " der$", " das$" };
|
||||||
" status$", " mit status$", " die$", " der$", " das$"
|
|
||||||
};
|
|
||||||
|
|
||||||
String result = text;
|
String result = text;
|
||||||
for (String pattern : trailingPatterns) {
|
for (String pattern : trailingPatterns) {
|
||||||
@@ -435,12 +417,11 @@ public class JobStatisticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a word is a common German/English word that should be ignored in matching.
|
* Check if a word is a common German/English word that should be ignored in
|
||||||
|
* matching.
|
||||||
*/
|
*/
|
||||||
private boolean isCommonWord(String word) {
|
private boolean isCommonWord(String word) {
|
||||||
return Set.of(
|
return Set.of("zeige", "alle", "jobs", "der", "die", "das", "für", "von", "mit", "und", "firma", "kunde",
|
||||||
"zeige", "alle", "jobs", "der", "die", "das", "für", "von", "mit", "und",
|
"status", "welche", "sind", "gmbh", "show", "all", "the").contains(word.toLowerCase());
|
||||||
"firma", "kunde", "status", "welche", "sind", "gmbh", "show", "all", "the"
|
|
||||||
).contains(word.toLowerCase());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,26 +12,27 @@ import java.util.concurrent.Executor;
|
|||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service that listens for message-related events and notifies registered UI components
|
* Service that listens for message-related events and notifies registered UI
|
||||||
* to update their message badges (e.g., in the sidebar navigation)
|
* components to update their message badges (e.g., in the sidebar navigation)
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class MessageBadgeUpdateService {
|
public class MessageBadgeUpdateService {
|
||||||
|
|
||||||
private final Executor executor = Executors.newSingleThreadExecutor();
|
private final Executor executor = Executors.newSingleThreadExecutor();
|
||||||
private final LinkedHashSet<Runnable> listeners = new LinkedHashSet<>();
|
private final LinkedHashSet<Runnable> listeners = new LinkedHashSet<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a listener that will be called when message badge should be updated
|
* Register a listener that will be called when message badge should be updated
|
||||||
*
|
*
|
||||||
* @param listener Runnable that will be called when badge update is needed
|
* @param listener
|
||||||
|
* Runnable that will be called when badge update is needed
|
||||||
* @return Registration object that can be used to unregister the listener
|
* @return Registration object that can be used to unregister the listener
|
||||||
*/
|
*/
|
||||||
public synchronized Registration register(Runnable listener) {
|
public synchronized Registration register(Runnable listener) {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
log.debug("Registered badge update listener. Total listeners: {}", listeners.size());
|
log.debug("Registered badge update listener. Total listeners: {}", listeners.size());
|
||||||
|
|
||||||
return () -> {
|
return () -> {
|
||||||
synchronized (MessageBadgeUpdateService.this) {
|
synchronized (MessageBadgeUpdateService.this) {
|
||||||
listeners.remove(listener);
|
listeners.remove(listener);
|
||||||
@@ -39,7 +40,7 @@ public class MessageBadgeUpdateService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify all registered listeners that badge should be updated
|
* Notify all registered listeners that badge should be updated
|
||||||
*/
|
*/
|
||||||
@@ -55,7 +56,7 @@ public class MessageBadgeUpdateService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring event listener for message read status changes
|
* Spring event listener for message read status changes
|
||||||
*/
|
*/
|
||||||
@@ -64,7 +65,7 @@ public class MessageBadgeUpdateService {
|
|||||||
log.debug("MessageBadgeUpdateService received MessageReadStatusChangedEvent");
|
log.debug("MessageBadgeUpdateService received MessageReadStatusChangedEvent");
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring event listener for new messages received
|
* Spring event listener for new messages received
|
||||||
*/
|
*/
|
||||||
@@ -74,4 +75,3 @@ public class MessageBadgeUpdateService {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,26 +13,27 @@ import java.util.concurrent.Executors;
|
|||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcaster service that manages listeners for incoming messages
|
* Broadcaster service that manages listeners for incoming messages and notifies
|
||||||
* and notifies UI components in a thread-safe manner
|
* UI components in a thread-safe manner
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class MessageBroadcaster {
|
public class MessageBroadcaster {
|
||||||
|
|
||||||
private final Executor executor = Executors.newSingleThreadExecutor();
|
private final Executor executor = Executors.newSingleThreadExecutor();
|
||||||
private final LinkedHashSet<Consumer<Message>> listeners = new LinkedHashSet<>();
|
private final LinkedHashSet<Consumer<Message>> listeners = new LinkedHashSet<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a listener for incoming messages
|
* Register a listener for incoming messages
|
||||||
*
|
*
|
||||||
* @param listener Consumer that will be called when a new message arrives
|
* @param listener
|
||||||
|
* Consumer that will be called when a new message arrives
|
||||||
* @return Registration object that can be used to unregister the listener
|
* @return Registration object that can be used to unregister the listener
|
||||||
*/
|
*/
|
||||||
public synchronized Registration register(Consumer<Message> listener) {
|
public synchronized Registration register(Consumer<Message> listener) {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
log.debug("Registered message listener. Total listeners: {}", listeners.size());
|
log.debug("Registered message listener. Total listeners: {}", listeners.size());
|
||||||
|
|
||||||
return () -> {
|
return () -> {
|
||||||
synchronized (MessageBroadcaster.this) {
|
synchronized (MessageBroadcaster.this) {
|
||||||
listeners.remove(listener);
|
listeners.remove(listener);
|
||||||
@@ -40,10 +41,10 @@ public class MessageBroadcaster {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast a message to all registered listeners
|
* Broadcast a message to all registered listeners This is called asynchronously
|
||||||
* This is called asynchronously to avoid blocking the message reception
|
* to avoid blocking the message reception
|
||||||
*/
|
*/
|
||||||
private synchronized void broadcast(Message message) {
|
private synchronized void broadcast(Message message) {
|
||||||
log.debug("Broadcasting message to {} listeners", listeners.size());
|
log.debug("Broadcasting message to {} listeners", listeners.size());
|
||||||
@@ -57,15 +58,16 @@ public class MessageBroadcaster {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring event listener that gets called when a MessageReceivedEvent is published
|
* Spring event listener that gets called when a MessageReceivedEvent is
|
||||||
|
* published
|
||||||
*/
|
*/
|
||||||
@EventListener
|
@EventListener
|
||||||
public void onMessageReceived(MessageReceivedEvent event) {
|
public void onMessageReceived(MessageReceivedEvent event) {
|
||||||
Message message = event.getMessage();
|
Message message = event.getMessage();
|
||||||
log.info("MessageBroadcaster received event for message with origin {} for receiver {}",
|
log.info("MessageBroadcaster received event for message with origin {} for receiver {}", message.getOrigin(),
|
||||||
message.getOrigin(), message.getReceiver());
|
message.getReceiver());
|
||||||
broadcast(message);
|
broadcast(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ public class MessageService {
|
|||||||
private final JobRepository jobRepository;
|
private final JobRepository jobRepository;
|
||||||
private final MqttPublisher mqttPublisher;
|
private final MqttPublisher mqttPublisher;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
public MessageService(MessageRepository messageRepository, JobRepository jobRepository,
|
public MessageService(MessageRepository messageRepository, JobRepository jobRepository, MqttPublisher mqttPublisher,
|
||||||
MqttPublisher mqttPublisher, ApplicationEventPublisher eventPublisher) {
|
ApplicationEventPublisher eventPublisher) {
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.jobRepository = jobRepository;
|
this.jobRepository = jobRepository;
|
||||||
this.mqttPublisher = mqttPublisher;
|
this.mqttPublisher = mqttPublisher;
|
||||||
@@ -47,15 +47,17 @@ public class MessageService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a general message to a client via MQTT
|
* Send a general message to a client via MQTT
|
||||||
* @param content Message content
|
*
|
||||||
* @param receiver AppUser ID (clientId)
|
* @param content
|
||||||
|
* Message content
|
||||||
|
* @param receiver
|
||||||
|
* AppUser ID (clientId)
|
||||||
*/
|
*/
|
||||||
public Message sendGeneralMessageToClient(String content, String receiver) {
|
public Message sendGeneralMessageToClient(String content, String receiver) {
|
||||||
return sendGeneralMessageToClient(content, receiver, MessageContentType.TEXT);
|
return sendGeneralMessageToClient(content, receiver, MessageContentType.TEXT);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Message sendGeneralMessageToClient(String content, String receiver,
|
public Message sendGeneralMessageToClient(String content, String receiver, MessageContentType contentType) {
|
||||||
MessageContentType contentType) {
|
|
||||||
Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType);
|
Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType);
|
||||||
message = saveMessage(message);
|
message = saveMessage(message);
|
||||||
publishMessageToMqtt(message, receiver);
|
publishMessageToMqtt(message, receiver);
|
||||||
@@ -64,21 +66,25 @@ public class MessageService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a job-related message to a client via MQTT
|
* Send a job-related message to a client via MQTT
|
||||||
* @param content Message content
|
*
|
||||||
* @param receiver AppUser ID (clientId)
|
* @param content
|
||||||
* @param jobId Job ObjectId
|
* Message content
|
||||||
* @param jobNumber Job number
|
* @param receiver
|
||||||
|
* AppUser ID (clientId)
|
||||||
|
* @param jobId
|
||||||
|
* Job ObjectId
|
||||||
|
* @param jobNumber
|
||||||
|
* Job number
|
||||||
*/
|
*/
|
||||||
public Message sendJobMessageToClient(String content, String receiver,
|
public Message sendJobMessageToClient(String content, String receiver, ObjectId jobId, String jobNumber) {
|
||||||
ObjectId jobId, String jobNumber) {
|
|
||||||
return sendJobMessageToClient(content, receiver, MessageContentType.TEXT, jobId, jobNumber);
|
return sendJobMessageToClient(content, receiver, MessageContentType.TEXT, jobId, jobNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Message sendJobMessageToClient(String content, String receiver,
|
public Message sendJobMessageToClient(String content, String receiver, MessageContentType contentType,
|
||||||
MessageContentType contentType, ObjectId jobId, String jobNumber) {
|
ObjectId jobId, String jobNumber) {
|
||||||
JobContext context = resolveJobContext(jobId, jobNumber);
|
JobContext context = resolveJobContext(jobId, jobNumber);
|
||||||
Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType,
|
Message message = new Message(content, receiver, MessageOrigin.SERVER, contentType, context.jobId(),
|
||||||
context.jobId(), context.jobNumber());
|
context.jobNumber());
|
||||||
message = saveMessage(message);
|
message = saveMessage(message);
|
||||||
publishMessageToMqtt(message, receiver);
|
publishMessageToMqtt(message, receiver);
|
||||||
return message;
|
return message;
|
||||||
@@ -86,7 +92,9 @@ public class MessageService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming message from a client
|
* Handle incoming message from a client
|
||||||
* @param payload Inbound message payload where receiver = AppUser ID (clientId)
|
*
|
||||||
|
* @param payload
|
||||||
|
* Inbound message payload where receiver = AppUser ID (clientId)
|
||||||
*/
|
*/
|
||||||
public Message receiveMessageFromClient(ChatMessageInboundPayload payload) {
|
public Message receiveMessageFromClient(ChatMessageInboundPayload payload) {
|
||||||
Message message;
|
Message message;
|
||||||
@@ -94,18 +102,17 @@ public class MessageService {
|
|||||||
if (payload.hasJobContext()) {
|
if (payload.hasJobContext()) {
|
||||||
JobContext context = resolveJobContext(payload.jobId(), payload.jobNumber());
|
JobContext context = resolveJobContext(payload.jobId(), payload.jobNumber());
|
||||||
// receiver = AppUser ID (clientId)
|
// receiver = AppUser ID (clientId)
|
||||||
message = new Message(payload.content(), payload.receiver(),
|
message = new Message(payload.content(), payload.receiver(), MessageOrigin.CLIENT, contentType,
|
||||||
MessageOrigin.CLIENT, contentType, context.jobId(), context.jobNumber());
|
context.jobId(), context.jobNumber());
|
||||||
} else {
|
} else {
|
||||||
// receiver = AppUser ID (clientId)
|
// receiver = AppUser ID (clientId)
|
||||||
message = new Message(payload.content(), payload.receiver(),
|
message = new Message(payload.content(), payload.receiver(), MessageOrigin.CLIENT, contentType);
|
||||||
MessageOrigin.CLIENT, contentType);
|
|
||||||
}
|
}
|
||||||
message = saveMessage(message);
|
message = saveMessage(message);
|
||||||
|
|
||||||
// Publish event to notify UI components about the new message
|
// Publish event to notify UI components about the new message
|
||||||
log.info("Publishing MessageReceivedEvent for message with origin {} for receiver {}",
|
log.info("Publishing MessageReceivedEvent for message with origin {} for receiver {}", message.getOrigin(),
|
||||||
message.getOrigin(), message.getReceiver());
|
message.getReceiver());
|
||||||
eventPublisher.publishEvent(new MessageReceivedEvent(this, message));
|
eventPublisher.publishEvent(new MessageReceivedEvent(this, message));
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
@@ -154,8 +161,11 @@ public class MessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all messages for a specific AppUser (by receiver field), ordered by creation time ascending (oldest first)
|
* Get all messages for a specific AppUser (by receiver field), ordered by
|
||||||
* @param appUserId AppUser ID (clientId)
|
* creation time ascending (oldest first)
|
||||||
|
*
|
||||||
|
* @param appUserId
|
||||||
|
* AppUser ID (clientId)
|
||||||
*/
|
*/
|
||||||
public List<Message> getMessagesForAppUserAscending(String appUserId) {
|
public List<Message> getMessagesForAppUserAscending(String appUserId) {
|
||||||
if (appUserId == null || appUserId.isBlank()) {
|
if (appUserId == null || appUserId.isBlank()) {
|
||||||
@@ -165,8 +175,11 @@ public class MessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all messages for a specific AppUser (by receiver field), ordered by creation time descending
|
* Get all messages for a specific AppUser (by receiver field), ordered by
|
||||||
* @param appUserId AppUser ID (clientId)
|
* creation time descending
|
||||||
|
*
|
||||||
|
* @param appUserId
|
||||||
|
* AppUser ID (clientId)
|
||||||
*/
|
*/
|
||||||
public List<Message> getMessagesForAppUserDescending(String appUserId) {
|
public List<Message> getMessagesForAppUserDescending(String appUserId) {
|
||||||
if (appUserId == null || appUserId.isBlank()) {
|
if (appUserId == null || appUserId.isBlank()) {
|
||||||
@@ -225,6 +238,13 @@ public class MessageService {
|
|||||||
return messageRepository.countByReceiverAndIsReadFalse(receiver);
|
return messageRepository.countByReceiverAndIsReadFalse(receiver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count all messages in the system
|
||||||
|
*/
|
||||||
|
public int countAllMessages() {
|
||||||
|
return (int) messageRepository.count();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a message by ID
|
* Get a message by ID
|
||||||
*/
|
*/
|
||||||
@@ -326,8 +346,7 @@ public class MessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return fuzzyMatches.stream()
|
return fuzzyMatches.stream()
|
||||||
.filter(job -> normalizeJobToken(job.getJobNumber()).equalsIgnoreCase(normalizedCandidate))
|
.filter(job -> normalizeJobToken(job.getJobNumber()).equalsIgnoreCase(normalizedCandidate)).findFirst();
|
||||||
.findFirst();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeJobToken(String value) {
|
private String normalizeJobToken(String value) {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import java.time.LocalDate;
|
|||||||
import java.time.YearMonth;
|
import java.time.YearMonth;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service für monatliche Scheduler-Aufgaben, die am letzten Tag des Monats (Ultimo) ausgeführt werden.
|
* Service für monatliche Scheduler-Aufgaben, die am letzten Tag des Monats
|
||||||
|
* (Ultimo) ausgeführt werden.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class MonthlySchedulerService {
|
public class MonthlySchedulerService {
|
||||||
@@ -17,8 +18,8 @@ public class MonthlySchedulerService {
|
|||||||
private static final Logger logger = LoggerFactory.getLogger(MonthlySchedulerService.class);
|
private static final Logger logger = LoggerFactory.getLogger(MonthlySchedulerService.class);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheduler, der täglich um 23:00 Uhr läuft und prüft, ob heute der letzte Tag des Monats ist.
|
* Scheduler, der täglich um 23:00 Uhr läuft und prüft, ob heute der letzte Tag
|
||||||
* Wenn ja, wird die monatliche Aufgabe ausgeführt.
|
* des Monats ist. Wenn ja, wird die monatliche Aufgabe ausgeführt.
|
||||||
*/
|
*/
|
||||||
@Scheduled(cron = "0 0 23 * * *") // Täglich um 23:00 Uhr
|
@Scheduled(cron = "0 0 23 * * *") // Täglich um 23:00 Uhr
|
||||||
public void checkAndRunMonthlyTask() {
|
public void checkAndRunMonthlyTask() {
|
||||||
@@ -29,17 +30,16 @@ public class MonthlySchedulerService {
|
|||||||
logger.info("Heute ist der letzte Tag des Monats ({}). Führe monatliche Aufgabe aus.", today);
|
logger.info("Heute ist der letzte Tag des Monats ({}). Führe monatliche Aufgabe aus.", today);
|
||||||
runMonthlyUltimoTask();
|
runMonthlyUltimoTask();
|
||||||
} else {
|
} else {
|
||||||
logger.debug("Heute ({}) ist nicht der letzte Tag des Monats. Nächster Ultimo: {}",
|
logger.debug("Heute ({}) ist nicht der letzte Tag des Monats. Nächster Ultimo: {}", today, lastDayOfMonth);
|
||||||
today, lastDayOfMonth);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alternative Implementierung: Direkter Cron-Ausdruck für den letzten Tag des Monats.
|
* Alternative Implementierung: Direkter Cron-Ausdruck für den letzten Tag des
|
||||||
* Dieser Scheduler läuft am letzten Tag jedes Monats um 23:00 Uhr.
|
* Monats. Dieser Scheduler läuft am letzten Tag jedes Monats um 23:00 Uhr.
|
||||||
*
|
*
|
||||||
* Hinweis: Dieser Ansatz ist weniger flexibel, da er nicht alle Monate korrekt abdeckt.
|
* Hinweis: Dieser Ansatz ist weniger flexibel, da er nicht alle Monate korrekt
|
||||||
* Der obige Ansatz mit täglicher Prüfung ist robuster.
|
* abdeckt. Der obige Ansatz mit täglicher Prüfung ist robuster.
|
||||||
*/
|
*/
|
||||||
// @Scheduled(cron = "0 0 23 L * *") // Am letzten Tag des Monats um 23:00 Uhr
|
// @Scheduled(cron = "0 0 23 L * *") // Am letzten Tag des Monats um 23:00 Uhr
|
||||||
public void runMonthlyUltimoTaskDirect() {
|
public void runMonthlyUltimoTaskDirect() {
|
||||||
@@ -48,8 +48,8 @@ public class MonthlySchedulerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die eigentliche monatliche Aufgabe, die am Ultimo ausgeführt wird.
|
* Die eigentliche monatliche Aufgabe, die am Ultimo ausgeführt wird. Hier
|
||||||
* Hier können Sie Ihre spezifische Geschäftslogik implementieren.
|
* können Sie Ihre spezifische Geschäftslogik implementieren.
|
||||||
*/
|
*/
|
||||||
private void runMonthlyUltimoTask() {
|
private void runMonthlyUltimoTask() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ public class SystemInvoiceService {
|
|||||||
// Set sample data
|
// Set sample data
|
||||||
data.setInvoiceNumber("HHA-2021-007");
|
data.setInvoiceNumber("HHA-2021-007");
|
||||||
data.setInvoiceDate("19.07.2021");
|
data.setInvoiceDate("19.07.2021");
|
||||||
data.setInvoiceText("Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:");
|
data.setInvoiceText(
|
||||||
|
"Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:");
|
||||||
|
|
||||||
data.setRecipientName("Hamburger Hochbahn AG");
|
data.setRecipientName("Hamburger Hochbahn AG");
|
||||||
data.setRecipientDepartment("Kreditorenbuchhaltung");
|
data.setRecipientDepartment("Kreditorenbuchhaltung");
|
||||||
@@ -71,19 +72,25 @@ public class SystemInvoiceService {
|
|||||||
// Replace invoice data placeholders
|
// Replace invoice data placeholders
|
||||||
filledHtml = filledHtml.replace("HHA-2021-007", data.getInvoiceNumber() != null ? data.getInvoiceNumber() : "");
|
filledHtml = filledHtml.replace("HHA-2021-007", data.getInvoiceNumber() != null ? data.getInvoiceNumber() : "");
|
||||||
filledHtml = filledHtml.replace("19.07.2021", data.getInvoiceDate() != null ? data.getInvoiceDate() : "");
|
filledHtml = filledHtml.replace("19.07.2021", data.getInvoiceDate() != null ? data.getInvoiceDate() : "");
|
||||||
filledHtml = filledHtml.replace("Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:",
|
filledHtml = filledHtml.replace(
|
||||||
data.getInvoiceText() != null ? data.getInvoiceText() : "");
|
"Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:",
|
||||||
|
data.getInvoiceText() != null ? data.getInvoiceText() : "");
|
||||||
|
|
||||||
// Replace recipient address
|
// Replace recipient address
|
||||||
filledHtml = filledHtml.replace("Hamburger Hochbahn AG", data.getRecipientName() != null ? data.getRecipientName() : "");
|
filledHtml = filledHtml.replace("Hamburger Hochbahn AG",
|
||||||
filledHtml = filledHtml.replace("Kreditorenbuchhaltung", data.getRecipientDepartment() != null ? data.getRecipientDepartment() : "");
|
data.getRecipientName() != null ? data.getRecipientName() : "");
|
||||||
filledHtml = filledHtml.replace("Steinstraße 20", data.getRecipientStreet() != null ? data.getRecipientStreet() : "");
|
filledHtml = filledHtml.replace("Kreditorenbuchhaltung",
|
||||||
filledHtml = filledHtml.replace("20095 Hamburg", data.getRecipientCity() != null ? data.getRecipientCity() : "");
|
data.getRecipientDepartment() != null ? data.getRecipientDepartment() : "");
|
||||||
|
filledHtml = filledHtml.replace("Steinstraße 20",
|
||||||
|
data.getRecipientStreet() != null ? data.getRecipientStreet() : "");
|
||||||
|
filledHtml = filledHtml.replace("20095 Hamburg",
|
||||||
|
data.getRecipientCity() != null ? data.getRecipientCity() : "");
|
||||||
|
|
||||||
// Replace invoice items
|
// Replace invoice items
|
||||||
if (data.getInvoiceItems() != null && !data.getInvoiceItems().isEmpty()) {
|
if (data.getInvoiceItems() != null && !data.getInvoiceItems().isEmpty()) {
|
||||||
SystemInvoiceItem item = data.getInvoiceItems().getFirst();
|
SystemInvoiceItem item = data.getInvoiceItems().getFirst();
|
||||||
filledHtml = filledHtml.replace("Mtl. Lizenzgebühr »ILLT«", item.getDescription() != null ? item.getDescription() : "");
|
filledHtml = filledHtml.replace("Mtl. Lizenzgebühr »ILLT«",
|
||||||
|
item.getDescription() != null ? item.getDescription() : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace amounts
|
// Replace amounts
|
||||||
|
|||||||
@@ -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