diff --git a/pom.xml b/pom.xml
index e303a17..2274684 100644
--- a/pom.xml
+++ b/pom.xml
@@ -35,6 +35,14 @@
pom
import
+
+
+ org.springframework.ai
+ spring-ai-bom
+ 1.0.0
+ pom
+ import
+
@@ -147,6 +155,18 @@
5.0.5
+
+
+ org.springframework.ai
+ spring-ai-starter-model-openai
+
+
+
+
+ org.springframework.ai
+ spring-ai-starter-mcp-server-webmvc
+
+
org.springframework.boot
@@ -321,6 +341,14 @@
false
+
+ spring-milestones
+ Spring Milestones
+ https://repo.spring.io/milestone
+
+ false
+
+
diff --git a/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java b/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java
new file mode 100644
index 0000000..1e01f60
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java
@@ -0,0 +1,36 @@
+package de.assecutor.votianlt.ai.config;
+
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Configuration for LLM integration via LM Studio.
+ * LM Studio provides an OpenAI-compatible API.
+ */
+@Configuration
+@Slf4j
+public class LlmConfig {
+
+ @Value("${spring.ai.openai.base-url:http://192.168.180.10:1234}")
+ private String baseUrl;
+
+ @Value("${spring.ai.openai.chat.options.model:local-model}")
+ private String model;
+
+ @PostConstruct
+ public void logConfig() {
+ log.info("LLM Configuration initialized:");
+ log.info(" Base URL: {}", baseUrl);
+ log.info(" Model: {}", model);
+ }
+
+ public String getBaseUrl() {
+ return baseUrl;
+ }
+
+ public String getModel() {
+ return model;
+ }
+}
diff --git a/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java
new file mode 100644
index 0000000..d2a611b
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java
@@ -0,0 +1,383 @@
+package de.assecutor.votianlt.ai.service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import de.assecutor.votianlt.model.JobStatus;
+import de.assecutor.votianlt.service.JobStatisticsService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.stereotype.Service;
+
+import java.time.Month;
+import java.time.Year;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Service for AI-assisted statistics analysis with chart visualization.
+ * Uses LM Studio via OpenAI-compatible API and local job statistics.
+ */
+@Service
+@Slf4j
+public class AiStatisticsService {
+
+ private final ChatClient chatClient;
+ private final JobStatisticsService statisticsService;
+ private final ObjectMapper objectMapper;
+
+ public AiStatisticsService(ChatModel chatModel, JobStatisticsService statisticsService) {
+ this.chatClient = ChatClient.builder(chatModel).build();
+ this.statisticsService = statisticsService;
+ this.objectMapper = new ObjectMapper();
+ log.info("AiStatisticsService initialized");
+ }
+
+ /**
+ * Response record containing text and optional chart data.
+ */
+ public record StatisticsResponse(
+ String textResponse,
+ String chartType,
+ String chartData
+ ) {}
+
+ /**
+ * Analyze a statistics query and return a response with optional visualization.
+ */
+ public StatisticsResponse analyzeStatisticsQuery(String userQuery) {
+ log.info("Processing statistics query: {}", userQuery);
+
+ // Gather current statistics
+ String statisticsContext = buildStatisticsContext();
+
+ // Determine query type and prepare chart data
+ QueryAnalysis analysis = analyzeQueryType(userQuery);
+
+ // Build prompt for LLM
+ String prompt = buildPrompt(userQuery, statisticsContext, analysis);
+
+ try {
+ // Get LLM response
+ String llmResponse = chatClient.prompt()
+ .user(prompt)
+ .call()
+ .content();
+
+ log.info("LLM response received");
+
+ // Build chart data based on query type
+ String chartType = analysis.chartType;
+ String chartData = analysis.chartData;
+
+ return new StatisticsResponse(llmResponse, chartType, chartData);
+
+ } catch (Exception e) {
+ log.error("Error calling LLM: {}", e.getMessage(), e);
+ // Fallback: Return statistics without LLM analysis
+ return new StatisticsResponse(
+ buildFallbackResponse(analysis),
+ analysis.chartType,
+ analysis.chartData
+ );
+ }
+ }
+
+ private record QueryAnalysis(
+ String queryType,
+ String chartType,
+ String chartData
+ ) {}
+
+ private QueryAnalysis analyzeQueryType(String query) {
+ String lowerQuery = query.toLowerCase();
+
+ // Status-bezogene Anfragen
+ if (lowerQuery.contains("status") || lowerQuery.contains("offen") ||
+ lowerQuery.contains("abgeschlossen") || lowerQuery.contains("zählen") ||
+ lowerQuery.contains("anzahl") || lowerQuery.contains("wie viele")) {
+ return new QueryAnalysis("status", "doughnut", buildStatusChartData());
+ }
+
+ // Umsatz-bezogene Anfragen
+ if (lowerQuery.contains("umsatz") || lowerQuery.contains("revenue") ||
+ lowerQuery.contains("kunde") || lowerQuery.contains("customer") ||
+ lowerQuery.contains("einnahmen")) {
+ return new QueryAnalysis("revenue", "bar", buildRevenueChartData());
+ }
+
+ // Trend-bezogene Anfragen
+ if (lowerQuery.contains("trend") || lowerQuery.contains("monat") ||
+ lowerQuery.contains("entwicklung") || lowerQuery.contains("jahr") ||
+ lowerQuery.contains("verlauf")) {
+ return new QueryAnalysis("trend", "line", buildTrendChartData());
+ }
+
+ // Task-bezogene Anfragen
+ if (lowerQuery.contains("task") || lowerQuery.contains("aufgabe") ||
+ lowerQuery.contains("erledigt")) {
+ return new QueryAnalysis("tasks", "doughnut", buildTaskChartData());
+ }
+
+ // Allgemeine Übersicht
+ return new QueryAnalysis("overview", "bar", buildOverviewChartData());
+ }
+
+ private String buildStatusChartData() {
+ Map statusCounts = statisticsService.getJobCountsByStatus();
+
+ List labels = new ArrayList<>();
+ List data = new ArrayList<>();
+ // Moderne Farbpalette mit satteren Farben
+ List colors = List.of(
+ "#06b6d4", // CREATED - cyan
+ "#f59e0b", // IN_PROGRESS - amber
+ "#3b82f6", // PICKUP_SCHEDULED - blau
+ "#8b5cf6", // PICKED_UP - violett
+ "#f97316", // IN_TRANSIT - orange
+ "#22c55e", // DELIVERED - grün
+ "#6366f1", // COMPLETED - indigo
+ "#ef4444" // CANCELLED - rot
+ );
+
+ for (JobStatus status : JobStatus.values()) {
+ Long count = statusCounts.getOrDefault(status, 0L);
+ if (count > 0) {
+ labels.add(status.getDisplayName());
+ data.add(count);
+ }
+ }
+
+ return buildChartJson(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Aufträge");
+ }
+
+ private String buildRevenueChartData() {
+ var topCustomers = statisticsService.getTopCustomersByRevenue(10);
+
+ List labels = new ArrayList<>();
+ List data = new ArrayList<>();
+
+ for (var entry : topCustomers) {
+ labels.add(entry.getKey() != null ? entry.getKey() : "Unbekannt");
+ data.add(entry.getValue().doubleValue());
+ }
+
+ // Gradient-ähnliche Farbpalette für Balken
+ List colors = List.of(
+ "#6366f1", "#8b5cf6", "#a855f7", "#c084fc",
+ "#d8b4fe", "#e9d5ff", "#f3e8ff", "#faf5ff",
+ "#ede9fe", "#ddd6fe"
+ );
+ return buildChartJsonDouble(labels, data, colors.subList(0, Math.min(labels.size(), colors.size())), "Umsatz (EUR)");
+ }
+
+ private String buildTrendChartData() {
+ int currentYear = Year.now().getValue();
+ Map monthlyData = statisticsService.getMonthlyJobCounts(currentYear);
+
+ List labels = List.of("Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
+ "Jul", "Aug", "Sep", "Okt", "Nov", "Dez");
+ List data = new ArrayList<>();
+
+ for (Month month : Month.values()) {
+ data.add(monthlyData.getOrDefault(month, 0L));
+ }
+
+ return String.format("""
+ {
+ "labels": %s,
+ "datasets": [{
+ "label": "Aufträge %d",
+ "data": %s,
+ "borderColor": "#6366f1",
+ "backgroundColor": "rgba(99, 102, 241, 0.15)",
+ "pointBackgroundColor": "#6366f1",
+ "pointBorderColor": "#fff",
+ "pointBorderWidth": 2,
+ "pointRadius": 5,
+ "pointHoverRadius": 7,
+ "tension": 0.4,
+ "fill": true
+ }]
+ }
+ """, toJsonArray(labels), currentYear, data);
+ }
+
+ private String buildTaskChartData() {
+ Map taskStats = statisticsService.getTaskCompletionStats();
+
+ List labels = List.of("Erledigt", "Ausstehend");
+ List data = List.of(
+ taskStats.getOrDefault("completed", 0L),
+ taskStats.getOrDefault("pending", 0L)
+ );
+ List colors = List.of("#22c55e", "#f59e0b");
+
+ return buildChartJson(labels, data, colors, "Aufgaben");
+ }
+
+ private String buildOverviewChartData() {
+ Map statusCounts = statisticsService.getJobCountsByStatus();
+
+ long total = statisticsService.getTotalJobCount();
+ long completed = statusCounts.getOrDefault(JobStatus.COMPLETED, 0L);
+ long inProgress = statusCounts.getOrDefault(JobStatus.IN_PROGRESS, 0L);
+ long open = total - completed - statusCounts.getOrDefault(JobStatus.CANCELLED, 0L);
+
+ List labels = List.of("Gesamt", "Abgeschlossen", "In Bearbeitung", "Offen");
+ List data = List.of(total, completed, inProgress, open);
+ List colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#06b6d4");
+
+ return buildChartJson(labels, data, colors, "Aufträge");
+ }
+
+ private String buildChartJson(List labels, List data, List colors, String label) {
+ return String.format("""
+ {
+ "labels": %s,
+ "datasets": [{
+ "label": "%s",
+ "data": %s,
+ "backgroundColor": %s,
+ "borderWidth": 1
+ }]
+ }
+ """, toJsonArray(labels), label, data, toJsonArray(colors));
+ }
+
+ private String buildChartJsonDouble(List labels, List data, List colors, String label) {
+ return String.format("""
+ {
+ "labels": %s,
+ "datasets": [{
+ "label": "%s",
+ "data": %s,
+ "backgroundColor": %s,
+ "borderWidth": 1
+ }]
+ }
+ """, toJsonArray(labels), label, data, toJsonArray(colors));
+ }
+
+ private String toJsonArray(List list) {
+ try {
+ return objectMapper.writeValueAsString(list);
+ } catch (JsonProcessingException e) {
+ return "[]";
+ }
+ }
+
+ private String buildStatisticsContext() {
+ StringBuilder context = new StringBuilder();
+
+ // Job counts by status
+ 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)));
+
+ // Totals
+ context.append(String.format("\n**Gesamtübersicht:**\n"));
+ context.append(String.format("- Gesamtanzahl Aufträge: %d\n", statisticsService.getTotalJobCount()));
+ context.append(String.format("- Abschlussrate: %.1f%%\n", statisticsService.getCompletionRate()));
+ context.append(String.format("- Gesamtumsatz: %.2f EUR\n", statisticsService.getTotalRevenue()));
+
+ // Task statistics
+ var taskStats = statisticsService.getTaskCompletionStats();
+ context.append(String.format("\n**Aufgaben:**\n"));
+ context.append(String.format("- Gesamt: %d\n", taskStats.get("total")));
+ context.append(String.format("- Erledigt: %d\n", taskStats.get("completed")));
+ context.append(String.format("- Ausstehend: %d\n", taskStats.get("pending")));
+
+ // Top customers
+ var topCustomers = statisticsService.getTopCustomersByRevenue(5);
+ if (!topCustomers.isEmpty()) {
+ 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()));
+ }
+ }
+
+ return context.toString();
+ }
+
+ private String buildPrompt(String userQuery, String statisticsContext, QueryAnalysis analysis) {
+ return String.format("""
+ Du bist ein hilfreicher Statistik-Assistent für ein Logistikunternehmen.
+ Beantworte die Frage des Benutzers basierend auf den aktuellen Statistiken.
+
+ %s
+
+ **Benutzerfrage:** %s
+
+ WICHTIGE FORMATIERUNGSREGELN:
+ - Verwende KEINE Tabellen (keine | oder --- Zeichen)
+ - Die Daten werden bereits als interaktives Diagramm visualisiert
+ - Fasse die wichtigsten Erkenntnisse in Fließtext oder kurzen Aufzählungen zusammen
+ - Nenne konkrete Zahlen im Text, aber liste nicht alle Werte tabellarisch auf
+
+ Antworte auf Deutsch, präzise und freundlich.
+ Erkläre die Daten kurz und gib bei Bedarf Empfehlungen.
+ Halte die Antwort kompakt (max. 3-4 Sätze für einfache Fragen, mehr für komplexe).
+ """, statisticsContext, userQuery);
+ }
+
+ private String buildFallbackResponse(QueryAnalysis analysis) {
+ return switch (analysis.queryType) {
+ case "status" -> {
+ var counts = statisticsService.getJobCountsByStatus();
+ StringBuilder sb = new StringBuilder("**Auftragsübersicht nach Status:**\n\n");
+ counts.forEach((status, count) -> {
+ if (count > 0) {
+ sb.append(String.format("- **%s:** %d Aufträge\n", status.getDisplayName(), count));
+ }
+ });
+ sb.append(String.format("\n**Gesamt:** %d Aufträge", statisticsService.getTotalJobCount()));
+ yield sb.toString();
+ }
+ case "revenue" -> {
+ var topCustomers = statisticsService.getTopCustomersByRevenue(5);
+ StringBuilder sb = new StringBuilder("**Top Kunden nach Umsatz:**\n\n");
+ int rank = 1;
+ for (var entry : topCustomers) {
+ sb.append(String.format("%d. **%s:** %.2f EUR\n",
+ rank++,
+ entry.getKey() != null ? entry.getKey() : "Unbekannt",
+ entry.getValue()));
+ }
+ sb.append(String.format("\n**Gesamtumsatz:** %.2f EUR", statisticsService.getTotalRevenue()));
+ yield sb.toString();
+ }
+ case "trend" -> {
+ int year = Year.now().getValue();
+ var monthly = statisticsService.getMonthlyJobCounts(year);
+ long total = monthly.values().stream().mapToLong(Long::longValue).sum();
+ yield String.format("**Monatstrend %d:**\n\nInsgesamt wurden %d Aufträge erstellt. " +
+ "Die Verteilung ist im Diagramm ersichtlich.", year, total);
+ }
+ case "tasks" -> {
+ var taskStats = statisticsService.getTaskCompletionStats();
+ long total = taskStats.getOrDefault("total", 0L);
+ long completed = taskStats.getOrDefault("completed", 0L);
+ double rate = total > 0 ? (double) completed / total * 100 : 0;
+ yield String.format("**Aufgabenstatistik:**\n\n" +
+ "- **Gesamt:** %d Aufgaben\n" +
+ "- **Erledigt:** %d (%.1f%%)\n" +
+ "- **Ausstehend:** %d",
+ total, completed, rate, taskStats.getOrDefault("pending", 0L));
+ }
+ default -> {
+ yield String.format("**Übersicht:**\n\n" +
+ "- **Aufträge gesamt:** %d\n" +
+ "- **Abschlussrate:** %.1f%%\n" +
+ "- **Gesamtumsatz:** %.2f EUR",
+ statisticsService.getTotalJobCount(),
+ statisticsService.getCompletionRate(),
+ statisticsService.getTotalRevenue());
+ }
+ };
+ }
+}
diff --git a/src/main/java/de/assecutor/votianlt/mcp/config/McpServerConfig.java b/src/main/java/de/assecutor/votianlt/mcp/config/McpServerConfig.java
new file mode 100644
index 0000000..ae7d91e
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/mcp/config/McpServerConfig.java
@@ -0,0 +1,37 @@
+package de.assecutor.votianlt.mcp.config;
+
+import de.assecutor.votianlt.mcp.tools.JobQueryTool;
+import de.assecutor.votianlt.mcp.tools.JobStatisticsTool;
+import de.assecutor.votianlt.mcp.tools.TaskCompletionTool;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.ToolCallbackProvider;
+import org.springframework.ai.tool.method.MethodToolCallbackProvider;
+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
+@Slf4j
+public class McpServerConfig {
+
+ @Bean
+ public ToolCallbackProvider jobStatisticsToolProvider(JobStatisticsTool jobStatisticsTool) {
+ log.info("Registering JobStatisticsTool for MCP server");
+ return MethodToolCallbackProvider.builder().toolObjects(jobStatisticsTool).build();
+ }
+
+ @Bean
+ public ToolCallbackProvider jobQueryToolProvider(JobQueryTool jobQueryTool) {
+ log.info("Registering JobQueryTool for MCP server");
+ return MethodToolCallbackProvider.builder().toolObjects(jobQueryTool).build();
+ }
+
+ @Bean
+ public ToolCallbackProvider taskCompletionToolProvider(TaskCompletionTool taskCompletionTool) {
+ log.info("Registering TaskCompletionTool for MCP server");
+ return MethodToolCallbackProvider.builder().toolObjects(taskCompletionTool).build();
+ }
+}
diff --git a/src/main/java/de/assecutor/votianlt/mcp/dto/CustomerRevenueResult.java b/src/main/java/de/assecutor/votianlt/mcp/dto/CustomerRevenueResult.java
new file mode 100644
index 0000000..f952025
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/mcp/dto/CustomerRevenueResult.java
@@ -0,0 +1,22 @@
+package de.assecutor.votianlt.mcp.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+/**
+ * DTO for customer revenue results.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CustomerRevenueResult {
+
+ private String customer;
+ private BigDecimal revenue;
+ private long jobCount;
+}
diff --git a/src/main/java/de/assecutor/votianlt/mcp/dto/JobQueryResult.java b/src/main/java/de/assecutor/votianlt/mcp/dto/JobQueryResult.java
new file mode 100644
index 0000000..88adb4b
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/mcp/dto/JobQueryResult.java
@@ -0,0 +1,34 @@
+package de.assecutor.votianlt.mcp.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * DTO for job query results returned by MCP tools.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class JobQueryResult {
+
+ private String jobId;
+ private String jobNumber;
+ private String status;
+ private String statusDisplayName;
+ private String customer;
+ private String pickupCity;
+ private String deliveryCity;
+ private LocalDate pickupDate;
+ private LocalDate deliveryDate;
+ private BigDecimal price;
+ private LocalDateTime createdAt;
+ private String assignedAppUser;
+ private boolean digitalProcessing;
+}
diff --git a/src/main/java/de/assecutor/votianlt/mcp/dto/JobStatisticsResult.java b/src/main/java/de/assecutor/votianlt/mcp/dto/JobStatisticsResult.java
new file mode 100644
index 0000000..32ebcea
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/mcp/dto/JobStatisticsResult.java
@@ -0,0 +1,29 @@
+package de.assecutor.votianlt.mcp.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.Map;
+
+/**
+ * DTO for job statistics results returned by MCP tools.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class JobStatisticsResult {
+
+ private Map countsByStatus;
+ private long totalJobs;
+ private long completedJobs;
+ private long cancelledJobs;
+ private long inProgressJobs;
+ private double completionRate;
+ private BigDecimal totalRevenue;
+ private LocalDateTime queryTimestamp;
+}
diff --git a/src/main/java/de/assecutor/votianlt/mcp/dto/TaskCompletionResult.java b/src/main/java/de/assecutor/votianlt/mcp/dto/TaskCompletionResult.java
new file mode 100644
index 0000000..8805d1d
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/mcp/dto/TaskCompletionResult.java
@@ -0,0 +1,21 @@
+package de.assecutor.votianlt.mcp.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * DTO for task completion statistics.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TaskCompletionResult {
+
+ private long totalTasks;
+ private long completedTasks;
+ private long pendingTasks;
+ private double completionRate;
+}
diff --git a/src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java b/src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java
new file mode 100644
index 0000000..75009cd
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java
@@ -0,0 +1,128 @@
+package de.assecutor.votianlt.mcp.tools;
+
+import de.assecutor.votianlt.mcp.dto.JobQueryResult;
+import de.assecutor.votianlt.model.Job;
+import de.assecutor.votianlt.model.JobStatus;
+import de.assecutor.votianlt.service.JobStatisticsService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * MCP Tool for querying jobs with various filters.
+ */
+@Component
+@Slf4j
+public class JobQueryTool {
+
+ private final JobStatisticsService statisticsService;
+
+ public JobQueryTool(JobStatisticsService statisticsService) {
+ this.statisticsService = statisticsService;
+ }
+
+ @Tool(description = "Query jobs with optional filters. Returns a list of jobs matching the criteria.")
+ public List queryJobs(
+ @ToolParam(description = "Optional: Job status filter (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)")
+ String status,
+ @ToolParam(description = "Optional: Customer name filter") String customer,
+ @ToolParam(description = "Optional: Pickup city filter") String pickupCity,
+ @ToolParam(description = "Optional: Delivery city filter") String deliveryCity,
+ @ToolParam(description = "Maximum results to return (default 50)") Integer limit) {
+ log.info("MCP Tool: Querying jobs with filters - status: {}, customer: {}, pickupCity: {}, deliveryCity: {}",
+ status, customer, pickupCity, deliveryCity);
+
+ int actualLimit = limit != null ? limit : 50;
+ List jobs;
+
+ if (status != null && !status.isBlank()) {
+ JobStatus jobStatus = JobStatus.valueOf(status.toUpperCase());
+ jobs = statisticsService.getJobsByStatus(jobStatus);
+ } else if (customer != null && !customer.isBlank()) {
+ jobs = statisticsService.getJobsByCustomer(customer);
+ } else if (pickupCity != null && !pickupCity.isBlank()) {
+ jobs = statisticsService.getJobsByPickupCity(pickupCity);
+ } else if (deliveryCity != null && !deliveryCity.isBlank()) {
+ jobs = statisticsService.getJobsByDeliveryCity(deliveryCity);
+ } else {
+ jobs = statisticsService.getLatestJobs(actualLimit);
+ }
+
+ return jobs.stream()
+ .limit(actualLimit)
+ .map(this::toQueryResult)
+ .toList();
+ }
+
+ @Tool(description = "Get detailed information about a specific job by its job number")
+ public JobQueryResult getJobByNumber(
+ @ToolParam(description = "The job number to look up (e.g., JOB-2024-0001)") String jobNumber) {
+ log.info("MCP Tool: Getting job by number: {}", jobNumber);
+
+ Job job = statisticsService.getJobByNumber(jobNumber);
+ if (job == null) {
+ return null;
+ }
+ return toQueryResult(job);
+ }
+
+ @Tool(description = "Get jobs assigned to a specific mobile app user")
+ public List getJobsByAppUser(
+ @ToolParam(description = "App user identifier") String appUser) {
+ log.info("MCP Tool: Getting jobs for app user: {}", appUser);
+
+ return statisticsService.getJobsByAppUser(appUser).stream()
+ .map(this::toQueryResult)
+ .toList();
+ }
+
+ @Tool(description = "Get the most recent jobs, sorted by creation date descending")
+ public List getLatestJobs(
+ @ToolParam(description = "Number of jobs to return (default 10)") Integer limit) {
+ log.info("MCP Tool: Getting latest jobs, limit: {}", limit);
+
+ int actualLimit = limit != null ? limit : 10;
+ return statisticsService.getLatestJobs(actualLimit).stream()
+ .map(this::toQueryResult)
+ .toList();
+ }
+
+ @Tool(description = "Get jobs created within a specific date range")
+ public List getJobsByDateRange(
+ @ToolParam(description = "Start date in ISO format (e.g., 2024-01-01T00:00:00)") String startDate,
+ @ToolParam(description = "End date in ISO format (e.g., 2024-12-31T23:59:59)") String endDate,
+ @ToolParam(description = "Maximum results to return (default 100)") Integer limit) {
+ log.info("MCP Tool: Getting jobs for date range: {} to {}", startDate, endDate);
+
+ LocalDateTime start = LocalDateTime.parse(startDate);
+ LocalDateTime end = LocalDateTime.parse(endDate);
+ int actualLimit = limit != null ? limit : 100;
+
+ 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())
+ .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();
+ }
+}
diff --git a/src/main/java/de/assecutor/votianlt/mcp/tools/JobStatisticsTool.java b/src/main/java/de/assecutor/votianlt/mcp/tools/JobStatisticsTool.java
new file mode 100644
index 0000000..700cbd8
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/mcp/tools/JobStatisticsTool.java
@@ -0,0 +1,128 @@
+package de.assecutor.votianlt.mcp.tools;
+
+import de.assecutor.votianlt.mcp.dto.CustomerRevenueResult;
+import de.assecutor.votianlt.mcp.dto.JobStatisticsResult;
+import de.assecutor.votianlt.model.JobStatus;
+import de.assecutor.votianlt.service.JobStatisticsService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * MCP Tool for job statistics queries.
+ * Provides various statistics and aggregations about jobs.
+ */
+@Component
+@Slf4j
+public class JobStatisticsTool {
+
+ private final JobStatisticsService statisticsService;
+
+ public JobStatisticsTool(JobStatisticsService statisticsService) {
+ this.statisticsService = statisticsService;
+ }
+
+ @Tool(description = "Get comprehensive job statistics including counts by status, completion rates, and revenue metrics")
+ public JobStatisticsResult getJobStatistics() {
+ log.info("MCP Tool: Getting job statistics");
+
+ Map countsByStatus = statisticsService.getJobCountsByStatus();
+ Map statusCounts = countsByStatus.entrySet().stream()
+ .collect(Collectors.toMap(e -> e.getKey().name(), Map.Entry::getValue));
+
+ long completed = countsByStatus.getOrDefault(JobStatus.COMPLETED, 0L);
+ 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();
+ }
+
+ @Tool(description = "Get job counts grouped by status (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)")
+ public Map getJobCountsByStatus() {
+ log.info("MCP Tool: Getting job counts by status");
+
+ Map counts = statisticsService.getJobCountsByStatus();
+ return counts.entrySet().stream()
+ .collect(Collectors.toMap(
+ e -> e.getKey().name() + " (" + e.getKey().getDisplayName() + ")",
+ Map.Entry::getValue));
+ }
+
+ @Tool(description = "Get the completion rate as a percentage (completed jobs / total jobs * 100)")
+ public String getCompletionRate() {
+ log.info("MCP Tool: Getting completion rate");
+
+ double rate = statisticsService.getCompletionRate();
+ return String.format("%.2f%%", rate);
+ }
+
+ @Tool(description = "Get revenue statistics grouped by customer, sorted by revenue descending")
+ public List getRevenueByCustomer(
+ @ToolParam(description = "Maximum number of customers to return (default 10)") Integer limit) {
+ log.info("MCP Tool: Getting revenue by customer, limit: {}", limit);
+
+ int actualLimit = limit != null ? limit : 10;
+ Map revenueMap = statisticsService.getRevenueByCustomer();
+ List allJobs = statisticsService.getJobsByStatus(null);
+
+ return statisticsService.getTopCustomersByRevenue(actualLimit).stream()
+ .map(entry -> {
+ String customer = entry.getKey();
+ long jobCount = statisticsService.getJobsByCustomer(customer).size();
+ return CustomerRevenueResult.builder()
+ .customer(customer)
+ .revenue(entry.getValue())
+ .jobCount(jobCount)
+ .build();
+ })
+ .toList();
+ }
+
+ @Tool(description = "Get monthly job trend data for a specific year showing job counts per month")
+ public Map getMonthlyJobTrend(
+ @ToolParam(description = "Year for the trend data (e.g., 2024)") int year) {
+ log.info("MCP Tool: Getting monthly job trend for year: {}", year);
+
+ Map monthlyData = statisticsService.getMonthlyJobCounts(year);
+ return monthlyData.entrySet().stream()
+ .collect(Collectors.toMap(
+ e -> e.getKey().toString(),
+ Map.Entry::getValue));
+ }
+
+ @Tool(description = "Get total revenue from all jobs")
+ public String getTotalRevenue() {
+ log.info("MCP Tool: Getting total revenue");
+
+ BigDecimal revenue = statisticsService.getTotalRevenue();
+ return String.format("%.2f EUR", revenue);
+ }
+
+ @Tool(description = "Get job count for a specific date range")
+ public long getJobCountByDateRange(
+ @ToolParam(description = "Start date in ISO format (e.g., 2024-01-01T00:00:00)") String startDate,
+ @ToolParam(description = "End date in ISO format (e.g., 2024-12-31T23:59:59)") String endDate) {
+ log.info("MCP Tool: Getting job count for date range: {} to {}", startDate, endDate);
+
+ LocalDateTime start = LocalDateTime.parse(startDate);
+ LocalDateTime end = LocalDateTime.parse(endDate);
+ return statisticsService.getJobCountByDateRange(start, end);
+ }
+}
diff --git a/src/main/java/de/assecutor/votianlt/mcp/tools/TaskCompletionTool.java b/src/main/java/de/assecutor/votianlt/mcp/tools/TaskCompletionTool.java
new file mode 100644
index 0000000..65cad94
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/mcp/tools/TaskCompletionTool.java
@@ -0,0 +1,58 @@
+package de.assecutor.votianlt.mcp.tools;
+
+import de.assecutor.votianlt.mcp.dto.TaskCompletionResult;
+import de.assecutor.votianlt.service.JobStatisticsService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * MCP Tool for task completion statistics and data.
+ */
+@Component
+@Slf4j
+public class TaskCompletionTool {
+
+ private final JobStatisticsService statisticsService;
+
+ public TaskCompletionTool(JobStatisticsService statisticsService) {
+ this.statisticsService = statisticsService;
+ }
+
+ @Tool(description = "Get overall task completion statistics including total, completed, pending tasks and completion rate")
+ public TaskCompletionResult getTaskCompletionStats() {
+ log.info("MCP Tool: Getting task completion statistics");
+
+ Map stats = statisticsService.getTaskCompletionStats();
+ long total = stats.getOrDefault("total", 0L);
+ long completed = stats.getOrDefault("completed", 0L);
+ long pending = stats.getOrDefault("pending", 0L);
+
+ double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0;
+
+ return TaskCompletionResult.builder()
+ .totalTasks(total)
+ .completedTasks(completed)
+ .pendingTasks(pending)
+ .completionRate(completionRate)
+ .build();
+ }
+
+ @Tool(description = "Get a summary of task completion as a formatted string")
+ public String getTaskCompletionSummary() {
+ log.info("MCP Tool: Getting task completion summary");
+
+ Map stats = statisticsService.getTaskCompletionStats();
+ long total = stats.getOrDefault("total", 0L);
+ long completed = stats.getOrDefault("completed", 0L);
+ long pending = stats.getOrDefault("pending", 0L);
+
+ 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);
+ }
+}
diff --git a/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java b/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java
index 20f926f..9254780 100644
--- a/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java
+++ b/src/main/java/de/assecutor/votianlt/pages/view/StatisticsView.java
@@ -1,303 +1,596 @@
package de.assecutor.votianlt.pages.view;
-import com.vaadin.flow.component.Html;
+import com.vaadin.flow.component.AttachEvent;
+import com.vaadin.flow.component.Key;
+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.dependency.JavaScript;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H2;
-import com.vaadin.flow.component.html.H3;
+import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
+import com.vaadin.flow.component.icon.Icon;
+import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
+import com.vaadin.flow.component.orderedlayout.Scroller;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
+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 jakarta.annotation.security.RolesAllowed;
+import lombok.extern.slf4j.Slf4j;
-@PageTitle("Statistiken")
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.UUID;
+
+@PageTitle("KI-Statistiken")
@Route(value = "statistics", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" })
@JavaScript("https://cdn.jsdelivr.net/npm/chart.js")
+@Slf4j
public class StatisticsView extends VerticalLayout {
- public StatisticsView() {
+ 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;
+
+ // Prompt Field initialisieren
+ this.promptField = new TextField();
+ this.promptField.setPlaceholder("Stelle eine Frage zu deinen Statistiken...");
+ this.promptField.setWidthFull();
+ this.promptField.setClearButtonVisible(true);
+ this.promptField.addKeyPressListener(Key.ENTER, e -> sendPrompt());
+
setSizeFull();
- setPadding(true);
- setSpacing(true);
+ setPadding(false);
+ setSpacing(false);
- H2 title = new H2("Statistiken");
- add(title);
+ // Header
+ HorizontalLayout header = createHeader();
+ add(header);
- // KPI Cards
- HorizontalLayout kpiLayout = createKpiCards();
- add(kpiLayout);
+ // Chat Container mit Scroll
+ chatContainer = new VerticalLayout();
+ chatContainer.setWidthFull();
+ chatContainer.setPadding(true);
+ chatContainer.setSpacing(true);
+ chatContainer.getStyle().set("padding-bottom", "20px");
- // Charts Layout
- HorizontalLayout chartsLayout = new HorizontalLayout();
- chartsLayout.setWidthFull();
- chartsLayout.setHeight("400px");
- chartsLayout.setSpacing(true);
+ Scroller scroller = new Scroller(chatContainer);
+ scroller.setSizeFull();
+ scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
+ scroller.getStyle().set("background", "var(--lumo-contrast-5pct)");
- // Aufträge pro Monat (Liniendiagramm)
- Div monthlyOrdersChart = createMonthlyOrdersChart();
- monthlyOrdersChart.setWidth("50%");
- monthlyOrdersChart.setHeight("100%");
- chartsLayout.add(monthlyOrdersChart);
+ // Willkommensnachricht
+ addSystemMessage("Willkommen bei der KI-Statistik-Analyse! Stelle mir Fragen zu deinen Aufträgen, " +
+ "z.B. \"Wie viele Aufträge sind offen?\" oder \"Zeige mir den Umsatz pro Kunde.\"");
- // Aufträge nach Status (Kreisdiagramm)
- Div statusChart = createStatusPieChart();
- statusChart.setWidth("50%");
- statusChart.setHeight("100%");
- chartsLayout.add(statusChart);
+ add(scroller);
+ setFlexGrow(1, scroller);
- add(chartsLayout);
-
- // Umsatz nach Kunden (Balkendiagramm)
- VerticalLayout revenueContainer = new VerticalLayout();
- revenueContainer.setWidthFull();
- revenueContainer.setHeight("400px");
- revenueContainer.setPadding(false);
-
- Div revenueChart = createRevenueByCustomerChart();
- revenueChart.setSizeFull();
- revenueContainer.add(revenueChart);
-
- add(revenueContainer);
+ // Input Area
+ HorizontalLayout inputArea = createInputArea();
+ add(inputArea);
}
- private HorizontalLayout createKpiCards() {
- HorizontalLayout kpiLayout = new HorizontalLayout();
- kpiLayout.setWidthFull();
- kpiLayout.setSpacing(true);
- kpiLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.EVENLY);
+ private HorizontalLayout createHeader() {
+ HorizontalLayout header = new HorizontalLayout();
+ 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)");
- // Gesamtaufträge
- Div totalOrdersCard = createKpiCard("Gesamtaufträge", "247", "success");
+ Icon aiIcon = VaadinIcon.MAGIC.create();
+ aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
- // Offene Aufträge
- Div openOrdersCard = createKpiCard("Offene Aufträge", "34", "warning");
+ H2 title = new H2("KI-Statistik-Assistent");
+ title.getStyle().set("margin", "0").set("font-size", "var(--lumo-font-size-xl)");
- // Umsatz diesen Monat
- Div revenueCard = createKpiCard("Umsatz (Monat)", "€ 24.500", "primary");
+ 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)")
+ .set("margin-left", "var(--lumo-space-m)");
- // Neue Kunden
- Div newCustomersCard = createKpiCard("Neue Kunden", "12", "success");
-
- kpiLayout.add(totalOrdersCard, openOrdersCard, revenueCard, newCustomersCard);
- return kpiLayout;
+ header.add(aiIcon, title, subtitle);
+ return header;
}
- private Div createKpiCard(String title, String value, String theme) {
- Div card = new Div();
- card.addClassName("kpi-card");
- card.getStyle().set("background", "var(--lumo-base-color)")
+ private HorizontalLayout createInputArea() {
+ HorizontalLayout inputArea = new HorizontalLayout();
+ inputArea.setWidthFull();
+ 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)");
+
+ Button sendButton = new Button(VaadinIcon.PAPERPLANE.create());
+ sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
+ sendButton.addClickListener(e -> sendPrompt());
+ 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 revenueBtn = createQuickActionButton("Umsatz", "Zeige mir den Umsatz pro Kunde.");
+ 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);
+
+ VerticalLayout inputWrapper = new VerticalLayout();
+ inputWrapper.setPadding(false);
+ inputWrapper.setSpacing(true);
+ inputWrapper.setWidthFull();
+
+ HorizontalLayout inputRow = new HorizontalLayout(promptField, sendButton);
+ inputRow.setWidthFull();
+ inputRow.setFlexGrow(1, promptField);
+
+ inputWrapper.add(quickActions, inputRow);
+
+ inputArea.add(inputWrapper);
+ inputArea.setFlexGrow(1, inputWrapper);
+
+ return inputArea;
+ }
+
+ private Button createQuickActionButton(String text, String prompt) {
+ Button button = new Button(text);
+ button.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_TERTIARY);
+ button.addClickListener(e -> {
+ promptField.setValue(prompt);
+ sendPrompt();
+ });
+ return button;
+ }
+
+ private void sendPrompt() {
+ String prompt = promptField.getValue();
+ if (prompt == null || prompt.isBlank()) {
+ return;
+ }
+
+ // User Message anzeigen
+ addUserMessage(prompt);
+ promptField.clear();
+
+ // Loading Indicator
+ Div loadingMessage = createLoadingMessage();
+ chatContainer.add(loadingMessage);
+ scrollToBottom();
+
+ // Async Anfrage an KI
+ UI ui = UI.getCurrent();
+ new Thread(() -> {
+ try {
+ AiStatisticsService.StatisticsResponse response = aiStatisticsService.analyzeStatisticsQuery(prompt);
+
+ ui.access(() -> {
+ chatContainer.remove(loadingMessage);
+ addAiResponse(response);
+ scrollToBottom();
+ });
+ } catch (Exception e) {
+ log.error("Error processing AI request", e);
+ ui.access(() -> {
+ chatContainer.remove(loadingMessage);
+ addErrorMessage("Entschuldigung, es gab einen Fehler bei der Verarbeitung: " + e.getMessage());
+ scrollToBottom();
+ });
+ }
+ }).start();
+ }
+
+ private void addUserMessage(String message) {
+ 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)");
+
+ Div bubble = new Div();
+ 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("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)");
+
+ bubble.add(text, time);
+ messageDiv.add(bubble);
+ chatContainer.add(messageDiv);
+ }
+
+ private void addSystemMessage(String message) {
+ Div messageDiv = new Div();
+ messageDiv.getStyle()
+ .set("text-align", "center")
+ .set("margin", "var(--lumo-space-m) 0");
+
+ Span text = new Span(message);
+ text.getStyle()
+ .set("background", "var(--lumo-contrast-10pct)")
+ .set("padding", "var(--lumo-space-s) var(--lumo-space-m)")
+ .set("border-radius", "var(--lumo-border-radius-m)")
+ .set("font-size", "var(--lumo-font-size-s)")
+ .set("color", "var(--lumo-secondary-text-color)");
+
+ messageDiv.add(text);
+ chatContainer.add(messageDiv);
+ }
+
+ private void addAiResponse(AiStatisticsService.StatisticsResponse response) {
+ 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)");
+
+ Div bubble = new Div();
+ bubble.getStyle()
+ .set("background", "var(--lumo-base-color)")
.set("border", "1px solid var(--lumo-contrast-10pct)")
- .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)")
- .set("text-align", "center").set("box-shadow", "var(--lumo-box-shadow-xs)").set("min-width", "150px");
+ .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)");
- H3 titleElement = new H3(title);
- titleElement.getStyle().set("margin", "0 0 var(--lumo-space-s) 0").set("font-size", "var(--lumo-font-size-s)");
+ // AI Icon
+ HorizontalLayout header = new HorizontalLayout();
+ header.setAlignItems(FlexComponent.Alignment.CENTER);
+ header.setSpacing(true);
- Span valueElement = new Span(value);
- valueElement.getStyle().set("font-size", "var(--lumo-font-size-xl)").set("font-weight", "bold").set("color",
- getThemeColor(theme));
+ Icon aiIcon = VaadinIcon.MAGIC.create();
+ aiIcon.setSize("16px");
+ aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
- card.add(titleElement, valueElement);
- return card;
+ Span aiLabel = new Span("KI-Assistent");
+ aiLabel.getStyle()
+ .set("font-weight", "bold")
+ .set("font-size", "var(--lumo-font-size-s)");
+
+ header.add(aiIcon, aiLabel);
+ bubble.add(header);
+
+ // Text Response
+ Div textDiv = new Div();
+ textDiv.getStyle().set("margin-top", "var(--lumo-space-s)");
+ textDiv.getElement().setProperty("innerHTML", formatMarkdown(response.textResponse()));
+ bubble.add(textDiv);
+
+ // Chart wenn vorhanden
+ 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");
+ 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)");
+ bubble.add(time);
+
+ messageDiv.add(bubble);
+ chatContainer.add(messageDiv);
}
- private String getThemeColor(String theme) {
- return switch (theme) {
- case "success" -> "var(--lumo-success-color)";
- case "warning" -> "var(--lumo-warning-color)";
- case "error" -> "var(--lumo-error-color)";
- default -> "var(--lumo-primary-color)";
+ 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)");
+
+ Div bubble = new Div();
+ 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%");
+
+ Icon errorIcon = VaadinIcon.EXCLAMATION_CIRCLE.create();
+ errorIcon.setSize("16px");
+ errorIcon.getStyle().set("color", "var(--lumo-error-color)");
+
+ Span text = new Span(message);
+ text.getStyle().set("color", "var(--lumo-error-text-color)");
+
+ HorizontalLayout content = new HorizontalLayout(errorIcon, text);
+ content.setAlignItems(FlexComponent.Alignment.CENTER);
+ content.setSpacing(true);
+
+ bubble.add(content);
+ messageDiv.add(bubble);
+ chatContainer.add(messageDiv);
+ }
+
+ private Div createLoadingMessage() {
+ Div messageDiv = new Div();
+ 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-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");
+
+ bubble.add(dots);
+ messageDiv.add(bubble);
+ return messageDiv;
+ }
+
+ private Div createChart(String chartType, String chartData) {
+ if (chartType == null || chartData == null) {
+ return null;
+ }
+
+ 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.getElement().setProperty("innerHTML",
+ "");
+
+ // Moderne Chart.js Konfiguration mit Animationen und Styling
+ String chartOptions = getChartOptions(chartType);
+
+ // JavaScript direkt mit eingebetteter Konfiguration ausführen
+ String script = String.format("""
+ (function() {
+ function initChart() {
+ if (typeof Chart === 'undefined') {
+ console.log('Chart.js not loaded yet, retrying...');
+ setTimeout(initChart, 100);
+ return;
+ }
+ const canvas = document.getElementById('%s');
+ if (!canvas) {
+ console.log('Canvas not found, retrying...');
+ setTimeout(initChart, 50);
+ return;
+ }
+ // Vorheriges Chart zerstören falls vorhanden
+ if (canvas.chartInstance) {
+ canvas.chartInstance.destroy();
+ }
+ const ctx = canvas.getContext('2d');
+ try {
+ canvas.chartInstance = new Chart(ctx, {
+ type: '%s',
+ data: %s,
+ options: %s
+ });
+ console.log('Chart created successfully');
+ } catch (e) {
+ console.error('Chart creation error:', e);
+ }
+ }
+ initChart();
+ })();
+ """, canvasId, chartType, chartData, chartOptions);
+
+ chartContainer.getElement().executeJs(script);
+
+ return chartContainer;
+ }
+
+ private String getChartOptions(String chartType) {
+ // Gradient und moderne Farben für verschiedene Chart-Typen
+ return switch (chartType) {
+ case "line" -> """
+ {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: {
+ intersect: false,
+ mode: 'index'
+ },
+ plugins: {
+ legend: {
+ position: 'bottom',
+ labels: {
+ usePointStyle: true,
+ padding: 20
+ }
+ },
+ tooltip: {
+ backgroundColor: 'rgba(0,0,0,0.8)',
+ titleFont: { size: 14, weight: 'bold' },
+ bodyFont: { size: 13 },
+ padding: 12,
+ cornerRadius: 8
+ }
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ grid: {
+ color: 'rgba(0,0,0,0.05)'
+ }
+ },
+ x: {
+ grid: {
+ display: false
+ }
+ }
+ },
+ elements: {
+ line: {
+ tension: 0.4,
+ borderWidth: 3
+ },
+ point: {
+ radius: 4,
+ hoverRadius: 6,
+ hitRadius: 10
+ }
+ },
+ animation: {
+ duration: 1000,
+ easing: 'easeOutQuart'
+ }
+ }
+ """;
+ case "bar" -> """
+ {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'bottom',
+ labels: {
+ usePointStyle: true,
+ padding: 20
+ }
+ },
+ tooltip: {
+ backgroundColor: 'rgba(0,0,0,0.8)',
+ titleFont: { size: 14, weight: 'bold' },
+ bodyFont: { size: 13 },
+ padding: 12,
+ cornerRadius: 8
+ }
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ grid: {
+ color: 'rgba(0,0,0,0.05)'
+ }
+ },
+ x: {
+ grid: {
+ display: false
+ }
+ }
+ },
+ elements: {
+ bar: {
+ borderRadius: 6,
+ borderSkipped: false
+ }
+ },
+ animation: {
+ duration: 800,
+ easing: 'easeOutQuart'
+ }
+ }
+ """;
+ case "doughnut", "pie" -> """
+ {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'right',
+ labels: {
+ usePointStyle: true,
+ padding: 15,
+ font: { size: 12 }
+ }
+ },
+ tooltip: {
+ backgroundColor: 'rgba(0,0,0,0.8)',
+ titleFont: { size: 14, weight: 'bold' },
+ bodyFont: { size: 13 },
+ padding: 12,
+ cornerRadius: 8
+ }
+ },
+ cutout: '60%',
+ animation: {
+ animateRotate: true,
+ animateScale: true,
+ duration: 1000,
+ easing: 'easeOutQuart'
+ }
+ }
+ """;
+ default -> """
+ {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'bottom'
+ }
+ },
+ animation: {
+ duration: 800
+ }
+ }
+ """;
};
}
- private Div createMonthlyOrdersChart() {
- Div chartContainer = new Div();
- chartContainer.setId("monthlyOrdersChart");
-
- String canvasHtml = "";
- Html canvas = new Html(canvasHtml);
- chartContainer.add(canvas);
-
- String script = """
-
- """;
-
- Html scriptElement = new Html(script);
- chartContainer.add(scriptElement);
-
- return chartContainer;
+ private String formatMarkdown(String text) {
+ if (text == null) return "";
+ // Einfache Markdown-Formatierung
+ return text
+ .replace("\n", "
")
+ .replaceAll("\\*\\*(.+?)\\*\\*", "$1")
+ .replaceAll("\\*(.+?)\\*", "$1")
+ .replaceAll("`(.+?)`", "$1");
}
- private Div createStatusPieChart() {
- Div chartContainer = new Div();
- chartContainer.setId("statusPieChart");
-
- String canvasHtml = "";
- Html canvas = new Html(canvasHtml);
- chartContainer.add(canvas);
-
- String script = """
-
- """;
-
- Html scriptElement = new Html(script);
- chartContainer.add(scriptElement);
-
- return chartContainer;
+ private void scrollToBottom() {
+ chatContainer.getElement().executeJs(
+ "this.parentElement.scrollTop = this.parentElement.scrollHeight");
}
- private Div createRevenueByCustomerChart() {
- Div chartContainer = new Div();
- chartContainer.setId("revenueByCustomerChart");
-
- String canvasHtml = "";
- Html canvas = new Html(canvasHtml);
- chartContainer.add(canvas);
-
- String script = """
-
- """;
-
- Html scriptElement = new Html(script);
- chartContainer.add(scriptElement);
-
- return chartContainer;
+ @Override
+ protected void onAttach(AttachEvent attachEvent) {
+ super.onAttach(attachEvent);
+ scrollToBottom();
}
}
diff --git a/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java b/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java
index f2b29d2..43fb777 100644
--- a/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java
+++ b/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java
@@ -26,7 +26,8 @@ public class SecurityConfig extends VaadinWebSecurity {
new AntPathRequestMatcher("/sw.js"), new AntPathRequestMatcher("/offline.html"),
new AntPathRequestMatcher("/frontend/**"), new AntPathRequestMatcher("/webjars/**"),
new AntPathRequestMatcher("/h2-console/**"),
- new AntPathRequestMatcher("/frontend-es5/**", "/frontend-es6/**"))
+ new AntPathRequestMatcher("/frontend-es5/**", "/frontend-es6/**"),
+ new AntPathRequestMatcher("/mcp/**"))
.permitAll());
// Standard-CSRF-Konfiguration
diff --git a/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java
new file mode 100644
index 0000000..e0c0559
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/service/JobStatisticsService.java
@@ -0,0 +1,202 @@
+package de.assecutor.votianlt.service;
+
+import de.assecutor.votianlt.model.Job;
+import de.assecutor.votianlt.model.JobStatus;
+import de.assecutor.votianlt.repository.JobRepository;
+import de.assecutor.votianlt.repository.TaskRepository;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Service for job statistics and aggregations.
+ * Provides data for MCP tools and reporting.
+ */
+@Service
+@Slf4j
+public class JobStatisticsService {
+
+ private final JobRepository jobRepository;
+ private final TaskRepository taskRepository;
+
+ public JobStatisticsService(JobRepository jobRepository, TaskRepository taskRepository) {
+ this.jobRepository = jobRepository;
+ this.taskRepository = taskRepository;
+ }
+
+ /**
+ * Get job counts grouped by status.
+ */
+ public Map getJobCountsByStatus() {
+ Map counts = new EnumMap<>(JobStatus.class);
+ for (JobStatus status : JobStatus.values()) {
+ counts.put(status, jobRepository.countByStatus(status));
+ }
+ return counts;
+ }
+
+ /**
+ * Get total number of jobs.
+ */
+ public long getTotalJobCount() {
+ return jobRepository.count();
+ }
+
+ /**
+ * Get jobs created within a date range.
+ */
+ public List getJobsByDateRange(LocalDateTime start, LocalDateTime end) {
+ return jobRepository.findByCreatedAtBetween(start, end);
+ }
+
+ /**
+ * Get count of jobs created within a date range.
+ */
+ public long getJobCountByDateRange(LocalDateTime start, LocalDateTime end) {
+ return jobRepository.findByCreatedAtBetween(start, end).size();
+ }
+
+ /**
+ * Calculate completion rate (completed jobs / total jobs).
+ */
+ public double getCompletionRate() {
+ long total = jobRepository.count();
+ if (total == 0) {
+ return 0.0;
+ }
+ long completed = jobRepository.countByStatus(JobStatus.COMPLETED);
+ return (double) completed / total * 100.0;
+ }
+
+ /**
+ * Get total revenue from all jobs.
+ */
+ public BigDecimal getTotalRevenue() {
+ List allJobs = jobRepository.findAll();
+ return allJobs.stream()
+ .map(Job::getPrice)
+ .filter(price -> price != null)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ }
+
+ /**
+ * Get revenue grouped by customer.
+ */
+ public Map getRevenueByCustomer() {
+ Map revenueByCustomer = new HashMap<>();
+ List allJobs = jobRepository.findAll();
+
+ for (Job job : allJobs) {
+ String customer = job.getCustomerSelection();
+ if (customer != null && job.getPrice() != null) {
+ revenueByCustomer.merge(customer, job.getPrice(), BigDecimal::add);
+ }
+ }
+
+ return revenueByCustomer;
+ }
+
+ /**
+ * Get top customers by revenue.
+ */
+ public List> getTopCustomersByRevenue(int limit) {
+ return getRevenueByCustomer().entrySet().stream()
+ .sorted((a, b) -> b.getValue().compareTo(a.getValue()))
+ .limit(limit)
+ .toList();
+ }
+
+ /**
+ * Get monthly job counts for a specific year.
+ */
+ public Map getMonthlyJobCounts(int year) {
+ Map monthlyCounts = new LinkedHashMap<>();
+ LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
+ LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
+
+ List yearJobs = jobRepository.findByCreatedAtBetween(yearStart, yearEnd);
+
+ // Initialize all months with 0
+ for (Month month : Month.values()) {
+ monthlyCounts.put(month, 0L);
+ }
+
+ // Count jobs per month
+ for (Job job : yearJobs) {
+ if (job.getCreatedAt() != null) {
+ Month month = job.getCreatedAt().getMonth();
+ monthlyCounts.merge(month, 1L, Long::sum);
+ }
+ }
+
+ return monthlyCounts;
+ }
+
+ /**
+ * Get jobs by customer selection.
+ */
+ public List getJobsByCustomer(String customer) {
+ return jobRepository.findByCustomerSelection(customer);
+ }
+
+ /**
+ * Get task completion statistics.
+ */
+ public Map getTaskCompletionStats() {
+ Map stats = new HashMap<>();
+ stats.put("completed", taskRepository.countByCompleted(true));
+ stats.put("pending", taskRepository.countByCompleted(false));
+ stats.put("total", taskRepository.count());
+ return stats;
+ }
+
+ /**
+ * Get jobs by status.
+ */
+ public List getJobsByStatus(JobStatus status) {
+ return jobRepository.findByStatus(status);
+ }
+
+ /**
+ * Get jobs assigned to an app user.
+ */
+ public List getJobsByAppUser(String appUser) {
+ return jobRepository.findByAppUser(appUser);
+ }
+
+ /**
+ * Get job by job number.
+ */
+ public Job getJobByNumber(String jobNumber) {
+ return jobRepository.findByJobNumber(jobNumber).orElse(null);
+ }
+
+ /**
+ * Get jobs by pickup city.
+ */
+ public List getJobsByPickupCity(String city) {
+ return jobRepository.findByPickupCity(city);
+ }
+
+ /**
+ * Get jobs by delivery city.
+ */
+ public List getJobsByDeliveryCity(String city) {
+ return jobRepository.findByDeliveryCity(city);
+ }
+
+ /**
+ * Get latest jobs.
+ */
+ public List getLatestJobs(int limit) {
+ return jobRepository.findLatestJobs().stream().limit(limit).toList();
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 45749b3..d9ab0bf 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -103,4 +103,20 @@ app.client.ping.timeout-seconds=5
app.version=@project.version@
# Google Maps API Key
-app.google.maps.api-key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE
\ No newline at end of file
+app.google.maps.api-key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE
+
+# ===========================================
+# LLM Configuration (LM Studio)
+# ===========================================
+spring.ai.openai.base-url=http://192.168.180.10:1234
+spring.ai.openai.api-key=not-used
+spring.ai.openai.chat.options.model=local-model
+spring.ai.openai.chat.options.temperature=0.7
+
+# ===========================================
+# MCP Server Configuration
+# ===========================================
+spring.ai.mcp.server.enabled=true
+spring.ai.mcp.server.name=votianlt-mcp-server
+spring.ai.mcp.server.version=1.0.0
+spring.ai.mcp.server.sse-message-endpoint=/mcp/message
\ No newline at end of file