Erweiterungen

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
package de.assecutor.votianlt.event;
import de.assecutor.votianlt.model.Job;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
/**
* Event published when a new job is created
*/
@Getter
public class JobCreatedEvent extends ApplicationEvent {
private final Job job;
public JobCreatedEvent(Object source, Job job) {
super(source);
this.job = job;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,8 +20,8 @@ import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* Configuration for the plugin-based messaging system.
* Initializes the selected plugin and sets up message routing.
* Configuration for the plugin-based messaging system. Initializes the selected
* plugin and sets up message routing.
*/
@Configuration
@Slf4j
@@ -54,8 +54,9 @@ public class PluginMessagingConfig {
}
/**
* Initialize the messaging plugin after application startup.
* This method is called after all beans are created, so we can safely access MessageDeliveryService.
* Initialize the messaging plugin after application startup. This method is
* called after all beans are created, so we can safely access
* MessageDeliveryService.
*/
@EventListener(ApplicationReadyEvent.class)
public void initializePlugin(ApplicationReadyEvent event) {
@@ -66,9 +67,11 @@ public class PluginMessagingConfig {
PluginConfig config = createPluginConfig(pluginType);
// Get beans from context (after all beans are created)
MessageDeliveryService deliveryService = event.getApplicationContext().getBean(MessageDeliveryService.class);
MessageDeliveryService deliveryService = event.getApplicationContext()
.getBean(MessageDeliveryService.class);
MessageController messageController = event.getApplicationContext().getBean(MessageController.class);
ClientConnectionService clientConnectionService = event.getApplicationContext().getBean(ClientConnectionService.class);
ClientConnectionService clientConnectionService = event.getApplicationContext()
.getBean(ClientConnectionService.class);
// Set up a listener to subscribe when connected
log.info("[PluginMessagingConfig] Adding state listener");
@@ -89,10 +92,12 @@ public class PluginMessagingConfig {
});
log.info("[PluginMessagingConfig] State listener added");
// Activate plugin (this will trigger connection and eventually the listener above)
// Activate plugin (this will trigger connection and eventually the listener
// above)
pluginManager.activatePlugin(plugin, config);
log.info("[PluginMessagingConfig] Plugin activation initiated, subscriptions will be set up when connected");
log.info(
"[PluginMessagingConfig] Plugin activation initiated, subscriptions will be set up when connected");
} catch (Exception e) {
log.error("[PluginMessagingConfig] Failed to initialize plugin: {}", e.getMessage(), e);
@@ -139,8 +144,7 @@ public class PluginMessagingConfig {
/**
* Setup message subscriptions using the new plugin API.
*/
private void setupSubscriptions(MessageDeliveryService deliveryService,
MessageController messageController,
private void setupSubscriptions(MessageDeliveryService deliveryService, MessageController messageController,
ClientConnectionService clientConnectionService) {
log.info("[PluginMessagingConfig] Setting up message subscriptions");
@@ -153,7 +157,8 @@ public class PluginMessagingConfig {
// ACK messages are wrapped in MessageEnvelope
MessageEnvelope envelope = objectMapper.readValue(json, MessageEnvelope.class);
AcknowledgmentMessage ack = objectMapper.convertValue(envelope.getPayload(), AcknowledgmentMessage.class);
AcknowledgmentMessage ack = objectMapper.convertValue(envelope.getPayload(),
AcknowledgmentMessage.class);
deliveryService.handleAcknowledgment(ack);
} catch (Exception e) {
log.error("[PluginMessagingConfig] Error handling ACK message: {}", e.getMessage(), e);
@@ -161,17 +166,12 @@ public class PluginMessagingConfig {
});
// Register message handlers for different message types
String[] messageTypes = {
"task_completed",
"jobs/assigned",
"message",
"login",
"pong"
};
String[] messageTypes = { "task_completed", "jobs/assigned", "message", "login", "pong" };
for (String messageType : messageTypes) {
pluginManager.registerMessageHandler(messageType, (clientId, payload) ->
handleEnvelopedMessage(clientId, payload, deliveryService, messageController, clientConnectionService));
pluginManager.registerMessageHandler(messageType,
(clientId, payload) -> handleEnvelopedMessage(clientId, payload, deliveryService,
messageController, clientConnectionService));
}
log.info("[PluginMessagingConfig] Message subscriptions initialized");
@@ -183,8 +183,8 @@ public class PluginMessagingConfig {
}
/**
* Handle incoming enveloped message.
* Supports both new envelope format and legacy format for backwards compatibility.
* Handle incoming enveloped message. Supports both new envelope format and
* legacy format for backwards compatibility.
*/
private void handleEnvelopedMessage(String clientId, byte[] payload, MessageDeliveryService deliveryService,
MessageController messageController, ClientConnectionService clientConnectionService) {
@@ -214,12 +214,12 @@ public class PluginMessagingConfig {
}
/**
* Handle legacy message format (without envelope wrapper).
* This supports older clients that don't use the envelope format.
* Handle legacy message format (without envelope wrapper). This supports older
* clients that don't use the envelope format.
*/
@SuppressWarnings("unchecked")
private void handleLegacyMessage(String clientId, String json,
MessageController messageController, ClientConnectionService clientConnectionService) {
private void handleLegacyMessage(String clientId, String json, MessageController messageController,
ClientConnectionService clientConnectionService) {
try {
Map<String, Object> payload = objectMapper.readValue(json, Map.class);
log.info("[PluginMessagingConfig] Processing legacy message from client {}: {}", clientId, payload);
@@ -263,8 +263,8 @@ public class PluginMessagingConfig {
log.warn("[PluginMessagingConfig] Unknown legacy message format from client {}: {}", clientId, json);
} catch (Exception e) {
log.error("[PluginMessagingConfig] Error handling legacy message from client {}: {}", clientId, e.getMessage(), e);
log.error("[PluginMessagingConfig] Error handling legacy message from client {}: {}", clientId,
e.getMessage(), e);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,8 @@ public class Message {
private LocalDateTime createdAt;
/**
* Origin of the message: INCOMING (from client), OUTGOING (to client), or SERVER (from server)
* Origin of the message: INCOMING (from client), OUTGOING (to client), or
* SERVER (from server)
*/
@Field("origin")
private MessageOrigin origin;
@@ -94,8 +95,7 @@ public class Message {
/**
* Constructor for general messages with explicit content type
*/
public Message(String content, String receiver, MessageOrigin origin,
MessageContentType contentType) {
public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType) {
initializeBaseFields(content, receiver, origin, contentType);
this.messageType = MessageType.GENERAL;
}
@@ -103,16 +103,15 @@ public class Message {
/**
* Constructor for job-related messages
*/
public Message(String content, String receiver, MessageOrigin origin,
ObjectId jobId, String jobNumber) {
public Message(String content, String receiver, MessageOrigin origin, ObjectId jobId, String jobNumber) {
this(content, receiver, origin, MessageContentType.TEXT, jobId, jobNumber);
}
/**
* Constructor for job-related messages with explicit content type
*/
public Message(String content, String receiver, MessageOrigin origin,
MessageContentType contentType, ObjectId jobId, String jobNumber) {
public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType,
ObjectId jobId, String jobNumber) {
initializeBaseFields(content, receiver, origin, contentType);
this.messageType = MessageType.JOB_RELATED;
this.jobId = jobId;

View File

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

View File

@@ -17,7 +17,8 @@ public class PriceTable {
public PriceTable() {
}
public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation, String statisticalEvaluation) {
public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation,
String statisticalEvaluation) {
this.monthlyBasePackage = monthlyBasePackage;
this.appUsageLicense = appUsageLicense;
this.revenueParticipation = revenueParticipation;

View File

@@ -17,7 +17,8 @@ public class 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.unit = unit;
this.description = description;

View File

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

View File

@@ -1,7 +1,12 @@
package de.assecutor.votianlt.model.task;
public enum TaskType {
CONFIRMATION("Bestätigung"), SIGNATURE("Unterschrift"), TODOLIST("To-Do Liste"), PHOTO("Foto"), BARCODE("Barcode"), COMMENT("Kommentar");
CONFIRMATION("Bestätigung"),
SIGNATURE("Unterschrift"),
TODOLIST("To-Do Liste"),
PHOTO("Foto"),
BARCODE("Barcode"),
COMMENT("Kommentar");
private final String displayName;

View File

@@ -54,13 +54,9 @@ class MqttPublisherImpl implements MqttPublisher {
String messageType = parts[3];
// Use MessageDeliveryService for reliable delivery
DeliveryOptions options = DeliveryOptions.builder()
.requiresAck(true)
.retained(retained)
.build();
DeliveryOptions options = DeliveryOptions.builder().requiresAck(true).retained(retained).build();
deliveryService.sendToClient(clientId, messageType, payload, options)
.thenAccept(receipt -> {
deliveryService.sendToClient(clientId, messageType, payload, options).thenAccept(receipt -> {
log.info("=== MESSAGE DELIVERY SUBMITTED ===");
log.info("Topic: {}", topic);
log.info("Message ID: {}", receipt.getMessageId());
@@ -76,8 +72,7 @@ class MqttPublisherImpl implements MqttPublisher {
}
log.info("=== END MESSAGE DELIVERY ===");
})
.exceptionally(ex -> {
}).exceptionally(ex -> {
log.error("Failed to submit message for delivery to topic {}: {}", topic, ex.getMessage(), ex);
return null;
});

View File

@@ -79,9 +79,12 @@ public final class AdminLayout extends AppLayout {
SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD));
SideNavItem pdfTest = new SideNavItem("PDF Test", "pdf-test", new Icon(VaadinIcon.FILE_TEXT_O));
SideNavItem priceTable = new SideNavItem("Preis-Tabelle", "admin-price-table", new Icon(VaadinIcon.COG));
//SideNavItem systemSettings = new SideNavItem("Systemeinstellungen", "admin-settings", new Icon(VaadinIcon.COG));
//SideNavItem userManagement = new SideNavItem("Benutzerverwaltung", "admin-users", new Icon(VaadinIcon.USERS));
//SideNavItem systemLogs = new SideNavItem("System-Logs", "admin-logs", new Icon(VaadinIcon.FILE_TEXT));
// SideNavItem systemSettings = new SideNavItem("Systemeinstellungen",
// "admin-settings", new Icon(VaadinIcon.COG));
// SideNavItem userManagement = new SideNavItem("Benutzerverwaltung",
// "admin-users", new Icon(VaadinIcon.USERS));
// SideNavItem systemLogs = new SideNavItem("System-Logs", "admin-logs", new
// Icon(VaadinIcon.FILE_TEXT));
nav.addItem(dashboard);
nav.addItem(pdfTest);

View File

@@ -10,10 +10,12 @@ import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.repository.CargoItemRepository;
import de.assecutor.votianlt.service.JobHistoryService;
import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.event.JobCreatedEvent;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@@ -32,6 +34,7 @@ public class AddJobService {
private final SecurityService securityService;
private final JobHistoryService jobHistoryService;
private final EmailService emailService;
private final ApplicationEventPublisher eventPublisher;
/**
* Speichert einen neuen Auftrag samt CargoItems und Tasks
@@ -118,6 +121,14 @@ public class AddJobService {
e.getMessage());
}
// Publish job created event for real-time UI updates
try {
eventPublisher.publishEvent(new JobCreatedEvent(this, savedJob));
} catch (Exception e) {
log.warn("Failed to publish job created event for job {}: {}", savedJob.getIdAsString(),
e.getMessage());
}
log.info("Auftrag erfolgreich gespeichert: {}", savedJob.getJobNumber());
return savedJob;

View File

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

View File

@@ -102,7 +102,6 @@ public class AddAppUserView extends VerticalLayout {
phoneField.setRequiredIndicatorVisible(true);
phoneField.addBlurListener(e -> validateField(phoneField, "Telefonnummer ist ein Pflichtfeld"));
emailField.setWidthFull();
emailField.setRequiredIndicatorVisible(true);
emailField.addBlurListener(e -> validateEmailField());
@@ -122,7 +121,6 @@ public class AddAppUserView extends VerticalLayout {
confirmPasswordField.setRequiredIndicatorVisible(true);
confirmPasswordField.addBlurListener(e -> validateConfirmPasswordField());
// Add fields to form
formLayout.add(designationField);
formLayout.add(nameLayout);
@@ -200,10 +198,12 @@ public class AddAppUserView extends VerticalLayout {
emailField.setInvalid(true);
emailField.setErrorMessage("E-Mail-Adresse bereits vorhanden");
} else {
Notification.show("Ein Fehler ist aufgetreten: Doppelter Wert gefunden", 5000, Notification.Position.MIDDLE);
Notification.show("Ein Fehler ist aufgetreten: Doppelter Wert gefunden", 5000,
Notification.Position.MIDDLE);
}
} catch (Exception e) {
Notification.show("Fehler beim Anlegen des App-Nutzers: " + e.getMessage(), 5000, Notification.Position.MIDDLE);
Notification.show("Fehler beim Anlegen des App-Nutzers: " + e.getMessage(), 5000,
Notification.Position.MIDDLE);
}
}
@@ -269,7 +269,6 @@ public class AddAppUserView extends VerticalLayout {
}
}
private boolean validateAllFields() {
validateField(designationField, "Kennung ist ein Pflichtfeld");
validateField(firstnameField, "Vorname ist ein Pflichtfeld");
@@ -280,7 +279,7 @@ public class AddAppUserView extends VerticalLayout {
validateConfirmPasswordField();
return !designationField.isInvalid() && !firstnameField.isInvalid() && !lastnameField.isInvalid()
&& !phoneField.isInvalid() && !emailField.isInvalid()
&& !passwordField.isInvalid() && !confirmPasswordField.isInvalid();
&& !phoneField.isInvalid() && !emailField.isInvalid() && !passwordField.isInvalid()
&& !confirmPasswordField.isInvalid();
}
}

View File

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

View File

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

View File

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

View File

@@ -66,9 +66,7 @@ public class AdminPricetableView extends VerticalLayout {
private void savePriceTable() {
try {
// Get first entry or create new one
PriceTable priceTable = priceTableRepository.findAll().stream()
.findFirst()
.orElse(new PriceTable());
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(new PriceTable());
priceTable.setMonthlyBasePackage(monthlyBasePackage.getValue());
priceTable.setAppUsageLicense(appUsageLicense.getValue());
@@ -83,17 +81,19 @@ public class AdminPricetableView extends VerticalLayout {
private void loadPriceTable() {
try {
PriceTable priceTable = priceTableRepository.findAll().stream()
.findFirst()
.orElse(null);
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(null);
if (priceTable != null) {
monthlyBasePackage.setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : "");
appUsageLicense.setValue(priceTable.getAppUsageLicense() != null ? priceTable.getAppUsageLicense() : "");
revenueParticipation.setValue(priceTable.getRevenueParticipation() != null ? priceTable.getRevenueParticipation() : "");
monthlyBasePackage
.setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : "");
appUsageLicense
.setValue(priceTable.getAppUsageLicense() != null ? priceTable.getAppUsageLicense() : "");
revenueParticipation.setValue(
priceTable.getRevenueParticipation() != null ? priceTable.getRevenueParticipation() : "");
}
} catch (Exception ex) {
Notification.show("Fehler beim Laden der Daten: " + ex.getMessage(), 5000, Notification.Position.BOTTOM_CENTER);
Notification.show("Fehler beim Laden der Daten: " + ex.getMessage(), 5000,
Notification.Position.BOTTOM_CENTER);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,12 +42,18 @@ import de.assecutor.votianlt.model.Signature;
import de.assecutor.votianlt.model.Barcode;
import de.assecutor.votianlt.model.Photo;
import de.assecutor.votianlt.model.Comment;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.pages.service.AppUserService;
import de.assecutor.votianlt.service.JobHistoryService;
import de.assecutor.votianlt.service.MessageService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import jakarta.annotation.security.RolesAllowed;
import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Value;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -66,6 +72,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
private final PhotoRepository photoRepository;
private final CommentRepository commentRepository;
private final AppUserService appUserService;
private final JobHistoryService jobHistoryService;
@Value("${app.google.maps.api-key}")
private String googleMapsApiKey;
@@ -76,7 +83,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository,
TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository,
PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService,
MessageService messageService) {
MessageService messageService, JobHistoryService jobHistoryService) {
this.jobRepository = jobRepository;
this.cargoItemRepository = cargoItemRepository;
this.taskRepository = taskRepository;
@@ -85,6 +92,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
this.photoRepository = photoRepository;
this.commentRepository = commentRepository;
this.appUserService = appUserService;
this.jobHistoryService = jobHistoryService;
setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
@@ -132,11 +140,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
sendMessageButton.addClickListener(e -> {
// Check if job has an app user assigned
if (job.getAppUser() == null || job.getAppUser().isBlank()) {
Notification.show(
"Diesem Auftrag ist kein App-Nutzer zugeordnet",
3000,
Notification.Position.MIDDLE
).addThemeVariants(NotificationVariant.LUMO_ERROR);
Notification.show("Diesem Auftrag ist kein App-Nutzer zugeordnet", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
@@ -270,6 +275,51 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
// Google Maps Karte mit Route
addRouteMap(job);
// Manual completion button for jobs without digital processing
if (!job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED
&& job.getStatus() != JobStatus.CANCELLED) {
HorizontalLayout buttonRow = new HorizontalLayout();
buttonRow.setWidthFull();
buttonRow.setJustifyContentMode(HorizontalLayout.JustifyContentMode.CENTER);
buttonRow.getStyle().set("margin-top", "var(--lumo-space-l)");
Button completeButton = new Button("Auftrag manuell abschließen", new Icon(VaadinIcon.CHECK_CIRCLE));
completeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
completeButton.addClickListener(e -> {
ConfirmDialog dialog = new ConfirmDialog();
dialog.setHeader("Auftrag abschließen");
dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + " manuell abschließen?");
dialog.setCancelable(true);
dialog.setCancelText("Abbrechen");
dialog.setConfirmText("Abschließen");
dialog.setConfirmButtonTheme("primary");
dialog.addConfirmListener(ev -> {
try {
JobStatus oldStatus = job.getStatus();
job.setStatus(JobStatus.COMPLETED);
job.setUpdatedAt(LocalDateTime.now());
jobRepository.save(job);
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
Notification
.show("Auftrag " + job.getJobNumber() + " wurde abgeschlossen.", 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
// Re-render the page
getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString()));
} catch (Exception ex) {
Notification
.show("Fehler beim Abschließen: " + ex.getMessage(), 5000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
});
dialog.open();
});
buttonRow.add(completeButton);
content.add(buttonRow);
}
}
private VerticalLayout borderedBox() {
@@ -284,9 +334,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
private String formatLocalDate(java.time.LocalDate date) {
try {
java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")
.withLocale(Locale.GERMANY);
return date.format(fmt);
return DateTimeFormatUtil.formatDate(date);
} catch (Exception e) {
return "";
}
@@ -406,8 +454,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
+ " }" + " });" + " if (!bounds.isEmpty()) { map.fitBounds(bounds); }"
+ " }});" + " }" + " if (!(window.google && window.google.maps)) {"
+ " var s=document.createElement('script');"
+ " s.src='https://maps.googleapis.com/maps/api/js?key=" + getGoogleMapsApiKey() + "&libraries=places';"
+ " s.onload=init; document.head.appendChild(s);" + " } else { init(); }" + "})();");
+ " s.src='https://maps.googleapis.com/maps/api/js?key=" + getGoogleMapsApiKey()
+ "&libraries=places';" + " s.onload=init; document.head.appendChild(s);" + " } else { init(); }"
+ "})();");
map.getElement().executeJs(js, map.getElement(), routeInfo.getElement());
}
@@ -636,13 +685,9 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
for (Comment comment : comments) {
Div commentContainer = new Div();
commentContainer.getStyle()
.set("background-color", "#f5f5f5")
.set("border", "1px solid #ddd")
.set("border-radius", "4px")
.set("padding", "8px")
.set("margin", "4px 0")
.set("font-family", "monospace")
commentContainer.getStyle().set("background-color", "#f5f5f5")
.set("border", "1px solid #ddd").set("border-radius", "4px").set("padding", "8px")
.set("margin", "4px 0").set("font-family", "monospace")
.set("white-space", "pre-wrap");
Span commentText = new Span(comment.getCommentText());
@@ -658,13 +703,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String> {
}
private String formatDateTime(java.time.LocalDateTime dateTime) {
try {
java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
.withLocale(Locale.GERMANY);
return dateTime.format(fmt);
} catch (Exception e) {
return dateTime.toString();
}
return DateTimeFormatUtil.formatDateTime(dateTime);
}
private Div createTaskCard(BaseTask task, String displayName) {

View File

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

View File

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

View File

@@ -21,11 +21,13 @@ import de.assecutor.votianlt.model.Message;
import de.assecutor.votianlt.model.MessageContentType;
import de.assecutor.votianlt.model.MessageOrigin;
import de.assecutor.votianlt.pages.service.AppUserService;
import de.assecutor.votianlt.service.MessageBroadcaster;
import de.assecutor.votianlt.service.MessageService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import java.time.format.DateTimeFormatter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
@@ -47,15 +49,19 @@ public class MessagesView extends Main {
private final MessageService messageService;
private final AppUserService appUserService;
private final MessageBroadcaster messageBroadcaster;
private Grid<ClientMessageSummary> clientGrid;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
private final AtomicBoolean loading = new AtomicBoolean(false);
private Registration pollRegistration;
private Registration broadcasterRegistration;
private int lastMessageCount = 0;
public MessagesView(MessageService messageService, AppUserService appUserService) {
public MessagesView(MessageService messageService, AppUserService appUserService,
MessageBroadcaster messageBroadcaster) {
this.messageService = messageService;
this.appUserService = appUserService;
this.messageBroadcaster = messageBroadcaster;
// Create main layout
VerticalLayout layout = new VerticalLayout();
@@ -120,9 +126,9 @@ public class MessagesView extends Main {
return new Span("0");
})).setHeader("Ungelesen").setWidth("100px").setFlexGrow(0);
grid.addColumn(summary ->
summary.getLastMessageDate() != null ? summary.getLastMessageDate().format(DATE_FORMATTER) : "-"
).setHeader("Letzte Nachricht").setAutoWidth(true);
grid.addColumn(summary -> summary.getLastMessageDate() != null
? DateTimeFormatUtil.formatDateTime(summary.getLastMessageDate())
: "-").setHeader("Letzte Nachricht").setAutoWidth(true);
grid.addColumn(new ComponentRenderer<>(summary -> {
String preview = summary.getLastMessagePreview();
@@ -199,20 +205,17 @@ public class MessagesView extends Main {
continue;
}
conversation.sort(Comparator.comparing(Message::getCreatedAt,
Comparator.nullsLast(LocalDateTime::compareTo)).reversed());
conversation.sort(Comparator
.comparing(Message::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)).reversed());
Message latest = conversation.stream()
.filter(msg -> msg.getCreatedAt() != null)
.findFirst()
Message latest = conversation.stream().filter(msg -> msg.getCreatedAt() != null).findFirst()
.orElse(conversation.get(0));
LocalDateTime lastDate = latest.getCreatedAt();
String preview = resolvePreview(latest);
int totalMessages = conversation.size();
int unreadCount = (int) conversation.stream()
.filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead())
.count();
.filter(msg -> msg.getOrigin() == MessageOrigin.CLIENT && !msg.isRead()).count();
summary.setTotalMessages(summary.getTotalMessages() + totalMessages);
summary.setUnreadCount(summary.getUnreadCount() + unreadCount);
@@ -235,8 +238,9 @@ public class MessagesView extends Main {
}
List<ClientMessageSummary> summaries = new ArrayList<>(summaryMap.values());
summaries.sort(Comparator.comparing(ClientMessageSummary::getLastMessageDate,
Comparator.nullsLast(LocalDateTime::compareTo)).reversed());
summaries.sort(Comparator
.comparing(ClientMessageSummary::getLastMessageDate, Comparator.nullsLast(LocalDateTime::compareTo))
.reversed());
return summaries;
}
@@ -249,9 +253,7 @@ public class MessagesView extends Main {
return "[Bildnachricht]";
}
return Optional.ofNullable(message.getContent())
.filter(content -> !content.isBlank())
.orElse("(kein Inhalt)");
return Optional.ofNullable(message.getContent()).filter(content -> !content.isBlank()).orElse("(kein Inhalt)");
}
private String resolveParticipantKey(Message message) {
@@ -326,6 +328,12 @@ public class MessagesView extends Main {
UI ui = attachEvent.getUI();
ui.setPollInterval(POLL_INTERVAL_MS);
pollRegistration = ui.addPollListener(event -> loadClientSummaries());
// Register broadcaster for real-time notifications
broadcasterRegistration = messageBroadcaster.register(message -> {
handleIncomingMessage(ui, message);
});
loadClientSummaries();
}
@@ -336,6 +344,96 @@ public class MessagesView extends Main {
pollRegistration.remove();
pollRegistration = null;
}
if (broadcasterRegistration != null) {
broadcasterRegistration.remove();
broadcasterRegistration = null;
}
detachEvent.getUI().setPollInterval(-1);
}
/**
* Handle incoming message for notification purposes
*/
private void handleIncomingMessage(UI ui, Message message) {
if (message == null || message.getOrigin() != MessageOrigin.CLIENT) {
return;
}
ui.access(() -> {
try {
// Count total messages to detect new ones
int currentMessageCount = messageService.countAllMessages();
if (currentMessageCount > lastMessageCount) {
lastMessageCount = currentMessageCount;
// Play notification sound and show browser notification
playNotificationSound(ui);
showBrowserNotification(ui, message);
// Refresh the grid
loadClientSummaries();
}
} catch (Exception e) {
log.error("Error handling incoming message notification", e);
}
});
}
/**
* Play a notification sound when a new message arrives
*/
private void playNotificationSound(UI ui) {
ui.getPage().executeJs(
"const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGHm7A7+OZSA0PVK3m8LRjHAU7k9nzyn0tBSd5ye/glEIKEl206O2oVhQLSKHi8r5uIgU0idT01IMzByBwwvHjmEgNDlWs5PCzYhsFO5TY88p+Kwcme8jw4JVCChNdt+jvp1QVDEih4vK+bSIGNIrV9dODMggib8Lx5JdIDQ9VrObws2IbBT6U2PXKfi0IJnzH8OCVQgoVXbfp76dVFQ5IouLyvW0jCDSL1fTSgTQJJG7C8eSWSA8RVq3m8LJgGwg/lNj0yn4tCSV7x+/glUILFl237++nVhYOSKPi8rxtIwo0i9X00oE1CiNuwvDklkkREVat5u+yXxwJP5PY9Ml+Lgoge8fv4JVCDBVct+7vqFYYEUij4vG8bSQKNIvV89GBNQshbcLw5JZJERFV\u003d\u003d'); audio.play().catch(err => console.log('Audio play failed:', err));");
}
/**
* Show a browser notification when a new message arrives
*/
private void showBrowserNotification(UI ui, Message message) {
String senderName = resolveAppUserName(message.getReceiver());
String preview = resolveNotificationPreview(message);
String title = senderName != null ? ("Nachricht von " + senderName) : "Neue Nachricht";
ui.getPage().executeJs(
"if (!('Notification' in window)) {" + " console.log('Browser does not support notifications');"
+ "} else if (Notification.permission === 'granted') {" + " new Notification($0, { body: $1 });"
+ "} else if (Notification.permission !== 'denied') {" + " Notification.requestPermission().then(permission => {"
+ " if (permission === 'granted') {" + " new Notification($0, { body: $1 });" + " }" + " });" + "}",
title, preview);
}
/**
* Resolve the AppUser name by ID
*/
private String resolveAppUserName(String appUserId) {
if (appUserId == null) {
return null;
}
try {
ObjectId id = new ObjectId(appUserId);
AppUser appUser = appUserService.findById(id);
return appUser != null ? appUser.getBezeichnung() : null;
} catch (Exception e) {
return null;
}
}
/**
* Resolve a short preview text for the notification
*/
private String resolveNotificationPreview(Message message) {
if (message == null) {
return "";
}
if (message.getContentType() == MessageContentType.IMAGE) {
return "📷 Bild";
}
String content = message.getContent();
if (content == null || content.isBlank()) {
return "(kein Inhalt)";
}
// Limit preview to 100 characters
return content.length() > 100 ? content.substring(0, 97) + "..." : content;
}
}

View File

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

View File

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

View File

@@ -1,26 +1,43 @@
package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.DetachEvent;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.server.StreamResource;
import com.vaadin.flow.shared.Registration;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.JobBroadcaster;
import de.assecutor.votianlt.service.JobHistoryService;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.LocalDateTime;
@PageTitle("Aufträge")
@Route(value = "jobs", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER" })
@Slf4j
public class ShowJobsView extends VerticalLayout {
private final DatePicker startDate = new DatePicker("Startdatum");
@@ -28,11 +45,19 @@ public class ShowJobsView extends VerticalLayout {
private final TextField searchField = new TextField("Auftragsnummer suchen");
private final ComboBox<String> statusFilter = new ComboBox<>("Status");
private final JobRepository jobRepository;
private final JobHistoryService jobHistoryService;
private final SecurityService securityService;
private final JobBroadcaster jobBroadcaster;
private final Grid<Job> grid = new Grid<>(Job.class, false);
private Registration broadcasterRegistration;
@Autowired
public ShowJobsView(JobRepository jobRepository) {
public ShowJobsView(JobRepository jobRepository, JobHistoryService jobHistoryService,
SecurityService securityService, JobBroadcaster jobBroadcaster) {
this.jobRepository = jobRepository;
this.jobHistoryService = jobHistoryService;
this.securityService = securityService;
this.jobBroadcaster = jobBroadcaster;
setSizeFull();
setPadding(true);
setSpacing(true);
@@ -78,11 +103,28 @@ public class ShowJobsView extends VerticalLayout {
endDate.addValueChangeListener(e -> loadData());
// Configure grid columns: Auftraggeber, Auftragsnummer, Auftragsdatum, Zielort
grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber").setAutoWidth(true).setFlexGrow(1).setSortable(true);
grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber")
.setAutoWidth(true).setFlexGrow(1).setSortable(true);
grid.addColumn(Job::getJobNumber).setHeader("Auftragsnummer").setAutoWidth(true).setSortable(true);
grid.addColumn(Job::getCreatedAt).setHeader("Auftragsdatum").setAutoWidth(true).setSortable(true);
grid.addColumn(Job::getDeliveryCity).setHeader("Zielort").setAutoWidth(true).setFlexGrow(1).setSortable(true);
// Action column: manual completion for jobs without digital processing
grid.addComponentColumn(job -> {
if (!job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED
&& job.getStatus() != JobStatus.CANCELLED) {
Button completeBtn = new Button(new Icon(VaadinIcon.CHECK_CIRCLE));
completeBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SUCCESS);
completeBtn.setTooltipText("Auftrag manuell abschließen");
completeBtn.addClickListener(e -> {
e.getSource().getElement().getNode(); // prevent row click
showCompleteJobDialog(job);
});
return completeBtn;
}
return new com.vaadin.flow.component.html.Span();
}).setHeader("").setAutoWidth(true).setFlexGrow(0);
grid.setMultiSort(true);
grid.setSizeFull();
@@ -103,6 +145,32 @@ public class ShowJobsView extends VerticalLayout {
loadData();
}
private void showCompleteJobDialog(Job job) {
ConfirmDialog dialog = new ConfirmDialog();
dialog.setHeader("Auftrag abschließen");
dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + " manuell abschließen?");
dialog.setCancelable(true);
dialog.setCancelText("Abbrechen");
dialog.setConfirmText("Abschließen");
dialog.setConfirmButtonTheme("primary");
dialog.addConfirmListener(e -> {
try {
JobStatus oldStatus = job.getStatus();
job.setStatus(JobStatus.COMPLETED);
job.setUpdatedAt(LocalDateTime.now());
jobRepository.save(job);
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell");
Notification.show("Auftrag " + job.getJobNumber() + " wurde abgeschlossen.", 3000,
Notification.Position.BOTTOM_END).addThemeVariants(NotificationVariant.LUMO_SUCCESS);
loadData();
} catch (Exception ex) {
Notification.show("Fehler beim Abschließen: " + ex.getMessage(), 5000, Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
});
dialog.open();
}
private void loadData() {
var start = startDate.getValue();
var end = endDate.getValue();
@@ -132,8 +200,10 @@ public class ShowJobsView extends VerticalLayout {
// wenn
// leer
// Verwende die erweiterte Suchmethode
var filteredJobs = jobRepository.findWithFilters(startDt, endDt, jobNumberPattern, statusList);
// Verwende die erweiterte Suchmethode, gefiltert nach aktuellem Benutzer
String currentUserId = securityService.getCurrentUserId().toHexString();
var filteredJobs = jobRepository.findWithFiltersByCreatedBy(currentUserId, startDt, endDt, jobNumberPattern,
statusList);
grid.setItems(filteredJobs);
}
@@ -193,4 +263,75 @@ public class ShowJobsView extends VerticalLayout {
}
return customerSelection.trim();
}
@Override
protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent);
UI ui = attachEvent.getUI();
// Register broadcaster for real-time job notifications
broadcasterRegistration = jobBroadcaster.register(job -> {
handleNewJob(ui, job);
});
log.info("ShowJobsView attached and job listener registered");
}
@Override
protected void onDetach(DetachEvent detachEvent) {
super.onDetach(detachEvent);
if (broadcasterRegistration != null) {
broadcasterRegistration.remove();
broadcasterRegistration = null;
}
log.info("ShowJobsView detached and job listener unregistered");
}
/**
* Handle new job notification
*/
private void handleNewJob(UI ui, Job job) {
if (job == null) {
return;
}
ui.access(() -> {
try {
// Play notification sound and show browser notification
playNotificationSound(ui);
showBrowserNotification(ui, job);
// Refresh the grid
loadData();
log.info("New job notification displayed for job {}", job.getJobNumber());
} catch (Exception e) {
log.error("Error handling new job notification", e);
}
});
}
/**
* Play a notification sound when a new job is created
*/
private void playNotificationSound(UI ui) {
ui.getPage().executeJs(
"const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGHm7A7+OZSA0PVK3m8LRjHAU7k9nzyn0tBSd5ye/glEIKEl206O2oVhQLSKHi8r5uIgU0idT01IMzByBwwvHjmEgNDlWs5PCzYhsFO5TY88p+Kwcme8jw4JVCChNdt+jvp1QVDEih4vK+bSIGNIrV9dODMggib8Lx5JdIDQ9VrObws2IbBT6U2PXKfi0IJnzH8OCVQgoVXbfp76dVFQ5IouLyvW0jCDSL1fTSgTQJJG7C8eSWSA8RVq3m8LJgGwg/lNj0yn4tCSV7x+/glUILFl237++nVhYOSKPi8rxtIwo0i9X00oE1CiNuwvDklkkREVat5u+yXxwJP5PY9Ml+Lgoge8fv4JVCDBVct+7vqFYYEUij4vG8bSQKNIvV89GBNQshbcLw5JZJERFV\u003d\u003d'); audio.play().catch(err => console.log('Audio play failed:', err));");
}
/**
* Show a browser notification when a new job is created
*/
private void showBrowserNotification(UI ui, Job job) {
String jobNumber = job.getJobNumber() != null ? job.getJobNumber() : "Neuer Auftrag";
String company = extractCompanyName(job.getCustomerSelection());
String message = company != null && !company.isBlank() ? ("Kunde: " + company) : "Neuer Auftrag erstellt";
ui.getPage().executeJs(
"if (!('Notification' in window)) {" + " console.log('Browser does not support notifications');"
+ "} else if (Notification.permission === 'granted') {" + " new Notification($0, { body: $1 });"
+ "} else if (Notification.permission !== 'denied') {" + " Notification.requestPermission().then(permission => {"
+ " if (permission === 'granted') {" + " new Notification($0, { body: $1 });" + " }" + " });" + "}",
jobNumber, message);
}
}

View File

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

View File

@@ -20,11 +20,11 @@ import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.ai.service.AiStatisticsService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
@PageTitle("KI-Statistiken")
@@ -37,7 +37,6 @@ public class StatisticsView extends VerticalLayout {
private final AiStatisticsService aiStatisticsService;
private final VerticalLayout chatContainer;
private final TextField promptField;
private final DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm");
public StatisticsView(AiStatisticsService aiStatisticsService) {
this.aiStatisticsService = aiStatisticsService;
@@ -69,7 +68,6 @@ public class StatisticsView extends VerticalLayout {
scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
scroller.getStyle().set("background", "var(--lumo-contrast-5pct)");
add(scroller);
setFlexGrow(1, scroller);
@@ -83,9 +81,8 @@ public class StatisticsView extends VerticalLayout {
header.setWidthFull();
header.setPadding(true);
header.setAlignItems(FlexComponent.Alignment.CENTER);
header.getStyle()
.set("background", "var(--lumo-base-color)")
.set("border-bottom", "1px solid var(--lumo-contrast-10pct)");
header.getStyle().set("background", "var(--lumo-base-color)").set("border-bottom",
"1px solid var(--lumo-contrast-10pct)");
Icon aiIcon = VaadinIcon.MAGIC.create();
aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
@@ -94,9 +91,7 @@ public class StatisticsView extends VerticalLayout {
title.getStyle().set("margin", "0").set("font-size", "var(--lumo-font-size-xl)");
Span subtitle = new Span("Frage mich zu Aufträgen, Umsätzen und Statistiken");
subtitle.getStyle()
.set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)")
subtitle.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size", "var(--lumo-font-size-s)")
.set("margin-left", "var(--lumo-space-m)");
header.add(aiIcon, title, subtitle);
@@ -109,9 +104,8 @@ public class StatisticsView extends VerticalLayout {
inputArea.setPadding(true);
inputArea.setSpacing(true);
inputArea.setAlignItems(FlexComponent.Alignment.CENTER);
inputArea.getStyle()
.set("background", "var(--lumo-base-color)")
.set("border-top", "1px solid var(--lumo-contrast-10pct)");
inputArea.getStyle().set("background", "var(--lumo-base-color)").set("border-top",
"1px solid var(--lumo-contrast-10pct)");
Button sendButton = new Button(VaadinIcon.PAPERPLANE.create());
sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
@@ -119,9 +113,11 @@ public class StatisticsView extends VerticalLayout {
sendButton.getStyle().set("min-width", "50px");
// Quick Action Buttons
Button jobCountBtn = createQuickActionButton("Aufträge zählen", "Wie viele Aufträge gibt es insgesamt und nach Status?");
Button jobCountBtn = createQuickActionButton("Aufträge zählen",
"Wie viele Aufträge gibt es insgesamt und nach Status?");
Button revenueBtn = createQuickActionButton("Umsatz", "Zeige mir den Umsatz pro Kunde.");
Button trendBtn = createQuickActionButton("Monatstrend", "Zeige mir den Monatstrend der Aufträge für dieses Jahr.");
Button trendBtn = createQuickActionButton("Monatstrend",
"Zeige mir den Monatstrend der Aufträge für dieses Jahr.");
HorizontalLayout quickActions = new HorizontalLayout(jobCountBtn, revenueBtn, trendBtn);
quickActions.setSpacing(true);
@@ -194,30 +190,22 @@ public class StatisticsView extends VerticalLayout {
Div messageDiv = new Div();
messageDiv.addClassName("chat-message");
messageDiv.addClassName("user-message");
messageDiv.getStyle()
.set("display", "flex")
.set("justify-content", "flex-end")
.set("margin-bottom", "var(--lumo-space-m)");
messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-end").set("margin-bottom",
"var(--lumo-space-m)");
Div bubble = new Div();
bubble.getStyle()
.set("background", "var(--lumo-primary-color)")
bubble.getStyle().set("background", "var(--lumo-primary-color)")
.set("color", "var(--lumo-primary-contrast-color)")
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
.set("border-radius", "var(--lumo-border-radius-l)")
.set("max-width", "70%")
.set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "70%")
.set("word-wrap", "break-word");
Paragraph text = new Paragraph(message);
text.getStyle().set("margin", "0");
Span time = new Span(LocalDateTime.now().format(timeFormatter));
time.getStyle()
.set("font-size", "var(--lumo-font-size-xs)")
.set("opacity", "0.7")
.set("display", "block")
.set("text-align", "right")
.set("margin-top", "var(--lumo-space-xs)");
Span time = new Span(DateTimeFormatUtil.formatTime(LocalDateTime.now()));
time.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("opacity", "0.7").set("display", "block")
.set("text-align", "right").set("margin-top", "var(--lumo-space-xs)");
bubble.add(text, time);
messageDiv.add(bubble);
@@ -228,18 +216,13 @@ public class StatisticsView extends VerticalLayout {
Div messageDiv = new Div();
messageDiv.addClassName("chat-message");
messageDiv.addClassName("ai-message");
messageDiv.getStyle()
.set("display", "flex")
.set("justify-content", "flex-start")
.set("margin-bottom", "var(--lumo-space-m)");
messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom",
"var(--lumo-space-m)");
Div bubble = new Div();
bubble.getStyle()
.set("background", "var(--lumo-base-color)")
.set("border", "1px solid var(--lumo-contrast-10pct)")
.set("padding", "var(--lumo-space-m)")
.set("border-radius", "var(--lumo-border-radius-l)")
.set("max-width", "85%")
bubble.getStyle().set("background", "var(--lumo-base-color)")
.set("border", "1px solid var(--lumo-contrast-10pct)").set("padding", "var(--lumo-space-m)")
.set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "85%")
.set("box-shadow", "var(--lumo-box-shadow-xs)");
// AI Icon
@@ -252,9 +235,7 @@ public class StatisticsView extends VerticalLayout {
aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
Span aiLabel = new Span("KI-Assistent");
aiLabel.getStyle()
.set("font-weight", "bold")
.set("font-size", "var(--lumo-font-size-s)");
aiLabel.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-s)");
header.add(aiIcon, aiLabel);
bubble.add(header);
@@ -273,20 +254,15 @@ public class StatisticsView extends VerticalLayout {
if (response.chartData() != null && !response.chartData().isEmpty()) {
Div chartContainer = createChart(response.chartType(), response.chartData());
if (chartContainer != null) {
chartContainer.getStyle()
.set("margin-top", "var(--lumo-space-m)")
.set("height", "300px");
chartContainer.getStyle().set("margin-top", "var(--lumo-space-m)").set("height", "300px");
bubble.add(chartContainer);
}
}
// Timestamp
Span time = new Span(LocalDateTime.now().format(timeFormatter));
time.getStyle()
.set("font-size", "var(--lumo-font-size-xs)")
.set("color", "var(--lumo-secondary-text-color)")
.set("display", "block")
.set("margin-top", "var(--lumo-space-s)");
Span time = new Span(DateTimeFormatUtil.formatTime(LocalDateTime.now()));
time.getStyle().set("font-size", "var(--lumo-font-size-xs)").set("color", "var(--lumo-secondary-text-color)")
.set("display", "block").set("margin-top", "var(--lumo-space-s)");
bubble.add(time);
messageDiv.add(bubble);
@@ -295,18 +271,14 @@ public class StatisticsView extends VerticalLayout {
private void addErrorMessage(String message) {
Div messageDiv = new Div();
messageDiv.getStyle()
.set("display", "flex")
.set("justify-content", "flex-start")
.set("margin-bottom", "var(--lumo-space-m)");
messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom",
"var(--lumo-space-m)");
Div bubble = new Div();
bubble.getStyle()
.set("background", "var(--lumo-error-color-10pct)")
bubble.getStyle().set("background", "var(--lumo-error-color-10pct)")
.set("border", "1px solid var(--lumo-error-color)")
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
.set("border-radius", "var(--lumo-border-radius-l)")
.set("max-width", "70%");
.set("border-radius", "var(--lumo-border-radius-l)").set("max-width", "70%");
Icon errorIcon = VaadinIcon.EXCLAMATION_CIRCLE.create();
errorIcon.setSize("16px");
@@ -326,22 +298,17 @@ public class StatisticsView extends VerticalLayout {
private Div createLoadingMessage() {
Div messageDiv = new Div();
messageDiv.getStyle()
.set("display", "flex")
.set("justify-content", "flex-start")
.set("margin-bottom", "var(--lumo-space-m)");
messageDiv.getStyle().set("display", "flex").set("justify-content", "flex-start").set("margin-bottom",
"var(--lumo-space-m)");
Div bubble = new Div();
bubble.getStyle()
.set("background", "var(--lumo-base-color)")
bubble.getStyle().set("background", "var(--lumo-base-color)")
.set("border", "1px solid var(--lumo-contrast-10pct)")
.set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
.set("border-radius", "var(--lumo-border-radius-l)");
Span dots = new Span("Analysiere...");
dots.getStyle()
.set("color", "var(--lumo-secondary-text-color)")
.set("font-style", "italic");
dots.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-style", "italic");
bubble.add(dots);
messageDiv.add(bubble);
@@ -356,10 +323,8 @@ public class StatisticsView extends VerticalLayout {
String canvasId = "chart-" + UUID.randomUUID().toString().substring(0, 8);
Div chartContainer = new Div();
chartContainer.addClassName("chart-wrapper");
chartContainer.getStyle()
.set("background", "var(--lumo-contrast-5pct)")
.set("border-radius", "var(--lumo-border-radius-m)")
.set("padding", "var(--lumo-space-s)");
chartContainer.getStyle().set("background", "var(--lumo-contrast-5pct)")
.set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-s)");
chartContainer.getElement().setProperty("innerHTML",
"<canvas id='" + canvasId + "' style='width: 100%; height: 100%;'></canvas>");
@@ -669,18 +634,15 @@ public class StatisticsView extends VerticalLayout {
}
private String formatMarkdown(String text) {
if (text == null) return "";
if (text == null)
return "";
// Einfache Markdown-Formatierung
return text
.replace("\n", "<br>")
.replaceAll("\\*\\*(.+?)\\*\\*", "<strong>$1</strong>")
.replaceAll("\\*(.+?)\\*", "<em>$1</em>")
.replaceAll("`(.+?)`", "<code>$1</code>");
return text.replace("\n", "<br>").replaceAll("\\*\\*(.+?)\\*\\*", "<strong>$1</strong>")
.replaceAll("\\*(.+?)\\*", "<em>$1</em>").replaceAll("`(.+?)`", "<code>$1</code>");
}
private void scrollToBottom() {
chatContainer.getElement().executeJs(
"this.parentElement.scrollTop = this.parentElement.scrollHeight");
chatContainer.getElement().executeJs("this.parentElement.scrollTop = this.parentElement.scrollHeight");
}
@Override

View File

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

View File

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

View File

@@ -13,12 +13,14 @@ import java.util.List;
public interface MessageRepository extends MongoRepository<Message, ObjectId> {
/**
* Find all messages for a specific receiver (AppUser ID), ordered by creation time ascending (oldest first)
* Find all messages for a specific receiver (AppUser ID), ordered by creation
* time ascending (oldest first)
*/
List<Message> findByReceiverOrderByCreatedAtAsc(String receiver);
/**
* Find all messages for a specific receiver (AppUser ID), ordered by creation time descending (newest first)
* Find all messages for a specific receiver (AppUser ID), ordered by creation
* time descending (newest first)
*/
List<Message> findByReceiverOrderByCreatedAtDesc(String receiver);

View File

@@ -27,7 +27,8 @@ public interface PendingDeliveryRepository extends MongoRepository<PendingDelive
List<PendingDelivery> findByStatus(DeliveryStatus status);
/**
* Find deliveries ready for retry (status = SENT and nextRetryAt is in the past)
* Find deliveries ready for retry (status = SENT and nextRetryAt is in the
* past)
*/
List<PendingDelivery> findByStatusAndNextRetryAtBefore(DeliveryStatus status, LocalDateTime dateTime);
@@ -66,4 +67,3 @@ public interface PendingDeliveryRepository extends MongoRepository<PendingDelive
*/
void deleteByCreatedAtBefore(LocalDateTime dateTime);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
package de.assecutor.votianlt.service;
import com.vaadin.flow.shared.Registration;
import de.assecutor.votianlt.event.JobCreatedEvent;
import de.assecutor.votianlt.model.Job;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import java.util.LinkedHashSet;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
/**
* Broadcaster service that manages listeners for newly created jobs and
* notifies UI components in a thread-safe manner
*/
@Service
@Slf4j
public class JobBroadcaster {
private final Executor executor = Executors.newSingleThreadExecutor();
private final LinkedHashSet<Consumer<Job>> listeners = new LinkedHashSet<>();
/**
* Register a listener for newly created jobs
*
* @param listener
* Consumer that will be called when a new job is created
* @return Registration object that can be used to unregister the listener
*/
public synchronized Registration register(Consumer<Job> listener) {
listeners.add(listener);
log.debug("Registered job listener. Total listeners: {}", listeners.size());
return () -> {
synchronized (JobBroadcaster.this) {
listeners.remove(listener);
log.debug("Unregistered job listener. Total listeners: {}", listeners.size());
}
};
}
/**
* Broadcast a job creation to all registered listeners This is called
* asynchronously to avoid blocking the job creation
*/
private synchronized void broadcast(Job job) {
log.debug("Broadcasting job creation to {} listeners", listeners.size());
for (Consumer<Job> listener : listeners) {
executor.execute(() -> {
try {
listener.accept(job);
} catch (Exception e) {
log.error("Error broadcasting job to listener", e);
}
});
}
}
/**
* Spring event listener that gets called when a JobCreatedEvent is published
*/
@EventListener
public void onJobCreated(JobCreatedEvent event) {
Job job = event.getJob();
log.info("JobBroadcaster received event for job {}", job.getJobNumber());
broadcast(job);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,8 @@ public class SystemInvoiceService {
// Set sample data
data.setInvoiceNumber("HHA-2021-007");
data.setInvoiceDate("19.07.2021");
data.setInvoiceText("Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:");
data.setInvoiceText(
"Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:");
data.setRecipientName("Hamburger Hochbahn AG");
data.setRecipientDepartment("Kreditorenbuchhaltung");
@@ -71,19 +72,25 @@ public class SystemInvoiceService {
// Replace invoice data placeholders
filledHtml = filledHtml.replace("HHA-2021-007", data.getInvoiceNumber() != null ? data.getInvoiceNumber() : "");
filledHtml = filledHtml.replace("19.07.2021", data.getInvoiceDate() != null ? data.getInvoiceDate() : "");
filledHtml = filledHtml.replace("Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:",
filledHtml = filledHtml.replace(
"Gemäß unserem Nutzungsvertrag zu der Bestellnummer 45519389 berechnen wir Ihnen für den Monat Juli 2021 wie folgt:",
data.getInvoiceText() != null ? data.getInvoiceText() : "");
// Replace recipient address
filledHtml = filledHtml.replace("Hamburger Hochbahn AG", data.getRecipientName() != null ? data.getRecipientName() : "");
filledHtml = filledHtml.replace("Kreditorenbuchhaltung", data.getRecipientDepartment() != null ? data.getRecipientDepartment() : "");
filledHtml = filledHtml.replace("Steinstraße 20", data.getRecipientStreet() != null ? data.getRecipientStreet() : "");
filledHtml = filledHtml.replace("20095 Hamburg", data.getRecipientCity() != null ? data.getRecipientCity() : "");
filledHtml = filledHtml.replace("Hamburger Hochbahn AG",
data.getRecipientName() != null ? data.getRecipientName() : "");
filledHtml = filledHtml.replace("Kreditorenbuchhaltung",
data.getRecipientDepartment() != null ? data.getRecipientDepartment() : "");
filledHtml = filledHtml.replace("Steinstraße 20",
data.getRecipientStreet() != null ? data.getRecipientStreet() : "");
filledHtml = filledHtml.replace("20095 Hamburg",
data.getRecipientCity() != null ? data.getRecipientCity() : "");
// Replace invoice items
if (data.getInvoiceItems() != null && !data.getInvoiceItems().isEmpty()) {
SystemInvoiceItem item = data.getInvoiceItems().getFirst();
filledHtml = filledHtml.replace("Mtl. Lizenzgebühr »ILLT«", item.getDescription() != null ? item.getDescription() : "");
filledHtml = filledHtml.replace("Mtl. Lizenzgebühr »ILLT«",
item.getDescription() != null ? item.getDescription() : "");
}
// Replace amounts

View File

@@ -0,0 +1,80 @@
package de.assecutor.votianlt.util;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* Utility class for consistent date and time formatting across the application.
* All dates and times are formatted in German format: "TT.MM.JJJJ, HH:MM Uhr"
*/
public class DateTimeFormatUtil {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy, HH:mm 'Uhr'");
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm 'Uhr'");
private DateTimeFormatUtil() {
// Utility class, no instantiation
}
/**
* Formats a LocalDateTime to German format: "TT.MM.JJJJ, HH:MM Uhr"
*
* @param dateTime
* the LocalDateTime to format
* @return formatted string or empty string if dateTime is null
*/
public static String formatDateTime(LocalDateTime dateTime) {
if (dateTime == null) {
return "";
}
return dateTime.format(DATE_TIME_FORMATTER);
}
/**
* Formats a LocalDate to German format: "TT.MM.JJJJ"
*
* @param date
* the LocalDate to format
* @return formatted string or empty string if date is null
*/
public static String formatDate(LocalDate date) {
if (date == null) {
return "";
}
return date.format(DATE_FORMATTER);
}
/**
* Formats only the time part: "HH:MM Uhr"
*
* @param dateTime
* the LocalDateTime to format
* @return formatted time string or empty string if dateTime is null
*/
public static String formatTime(LocalDateTime dateTime) {
if (dateTime == null) {
return "";
}
return dateTime.format(TIME_FORMATTER);
}
/**
* Returns the date time formatter for direct use
*
* @return the DateTimeFormatter instance
*/
public static DateTimeFormatter getDateTimeFormatter() {
return DATE_TIME_FORMATTER;
}
/**
* Returns the date formatter for direct use
*
* @return the DateTimeFormatter instance
*/
public static DateTimeFormatter getDateFormatter() {
return DATE_FORMATTER;
}
}