Erweiterungen
This commit is contained in:
28
pom.xml
28
pom.xml
@@ -35,6 +35,14 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<!-- Spring AI BOM -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-bom</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
@@ -147,6 +155,18 @@
|
||||
<version>5.0.5</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring AI OpenAI (LM Studio kompatibel) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- MCP Server mit WebMVC Transport -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test Dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
@@ -321,6 +341,14 @@
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>spring-milestones</id>
|
||||
<name>Spring Milestones</name>
|
||||
<url>https://repo.spring.io/milestone</url>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<pluginRepositories>
|
||||
|
||||
36
src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java
Normal file
36
src/main/java/de/assecutor/votianlt/ai/config/LlmConfig.java
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<JobStatus, Long> statusCounts = statisticsService.getJobCountsByStatus();
|
||||
|
||||
List<String> labels = new ArrayList<>();
|
||||
List<Long> data = new ArrayList<>();
|
||||
// Moderne Farbpalette mit satteren Farben
|
||||
List<String> colors = List.of(
|
||||
"#06b6d4", // CREATED - cyan
|
||||
"#f59e0b", // IN_PROGRESS - amber
|
||||
"#3b82f6", // PICKUP_SCHEDULED - blau
|
||||
"#8b5cf6", // PICKED_UP - violett
|
||||
"#f97316", // IN_TRANSIT - orange
|
||||
"#22c55e", // DELIVERED - grün
|
||||
"#6366f1", // COMPLETED - indigo
|
||||
"#ef4444" // CANCELLED - rot
|
||||
);
|
||||
|
||||
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<String> labels = new ArrayList<>();
|
||||
List<Double> 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<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 buildTrendChartData() {
|
||||
int currentYear = Year.now().getValue();
|
||||
Map<Month, Long> monthlyData = statisticsService.getMonthlyJobCounts(currentYear);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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<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<String> colors = List.of("#22c55e", "#f59e0b");
|
||||
|
||||
return buildChartJson(labels, data, colors, "Aufgaben");
|
||||
}
|
||||
|
||||
private String buildOverviewChartData() {
|
||||
Map<JobStatus, Long> 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<String> labels = List.of("Gesamt", "Abgeschlossen", "In Bearbeitung", "Offen");
|
||||
List<Long> data = List.of(total, completed, inProgress, open);
|
||||
List<String> colors = List.of("#3b82f6", "#22c55e", "#f59e0b", "#06b6d4");
|
||||
|
||||
return buildChartJson(labels, data, colors, "Aufträge");
|
||||
}
|
||||
|
||||
private String buildChartJson(List<String> labels, List<Long> data, List<String> 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<String> labels, List<Double> data, List<String> 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<String> 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());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String, Long> countsByStatus;
|
||||
private long totalJobs;
|
||||
private long completedJobs;
|
||||
private long cancelledJobs;
|
||||
private long inProgressJobs;
|
||||
private double completionRate;
|
||||
private BigDecimal totalRevenue;
|
||||
private LocalDateTime queryTimestamp;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
128
src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java
Normal file
128
src/main/java/de/assecutor/votianlt/mcp/tools/JobQueryTool.java
Normal file
@@ -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<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: 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<Job> 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<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();
|
||||
}
|
||||
|
||||
@Tool(description = "Get the most recent jobs, sorted by creation date descending")
|
||||
public List<JobQueryResult> 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<JobQueryResult> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<JobStatus, Long> countsByStatus = statisticsService.getJobCountsByStatus();
|
||||
Map<String, Long> 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<String, Long> getJobCountsByStatus() {
|
||||
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));
|
||||
}
|
||||
|
||||
@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<CustomerRevenueResult> 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<String, BigDecimal> revenueMap = statisticsService.getRevenueByCustomer();
|
||||
List<de.assecutor.votianlt.model.Job> 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<String, Long> 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<Month, Long> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Long> 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<String, Long> 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
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)";
|
||||
// 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 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",
|
||||
"<canvas id='" + canvasId + "' style='width: 100%; height: 100%;'></canvas>");
|
||||
|
||||
// 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 = "<canvas id='monthlyOrdersCanvas' style='width: 100%; height: 100%;'></canvas>";
|
||||
Html canvas = new Html(canvasHtml);
|
||||
chartContainer.add(canvas);
|
||||
|
||||
String script = """
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
const ctx = document.getElementById('monthlyOrdersCanvas');
|
||||
if (ctx) {
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'],
|
||||
datasets: [{
|
||||
label: '2024',
|
||||
data: [15, 18, 22, 28, 32, 35, 42, 38, 41, 35, 28, 25],
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||
tension: 0.1
|
||||
}, {
|
||||
label: '2023',
|
||||
data: [12, 15, 18, 25, 28, 30, 35, 32, 36, 30, 25, 22],
|
||||
borderColor: 'rgb(135, 206, 235)',
|
||||
backgroundColor: 'rgba(135, 206, 235, 0.2)',
|
||||
tension: 0.1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Aufträge pro Monat'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Anzahl Aufträge'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
</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", "<br>")
|
||||
.replaceAll("\\*\\*(.+?)\\*\\*", "<strong>$1</strong>")
|
||||
.replaceAll("\\*(.+?)\\*", "<em>$1</em>")
|
||||
.replaceAll("`(.+?)`", "<code>$1</code>");
|
||||
}
|
||||
|
||||
private Div createStatusPieChart() {
|
||||
Div chartContainer = new Div();
|
||||
chartContainer.setId("statusPieChart");
|
||||
|
||||
String canvasHtml = "<canvas id='statusPieCanvas' style='width: 100%; height: 100%;'></canvas>";
|
||||
Html canvas = new Html(canvasHtml);
|
||||
chartContainer.add(canvas);
|
||||
|
||||
String script = """
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
const ctx = document.getElementById('statusPieCanvas');
|
||||
if (ctx) {
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Abgeschlossen', 'In Bearbeitung', 'Geplant', 'Storniert'],
|
||||
datasets: [{
|
||||
data: [156, 34, 28, 12],
|
||||
backgroundColor: [
|
||||
'rgba(54, 162, 235, 0.8)',
|
||||
'rgba(255, 206, 86, 0.8)',
|
||||
'rgba(75, 192, 192, 0.8)',
|
||||
'rgba(255, 99, 132, 0.8)'
|
||||
],
|
||||
borderColor: [
|
||||
'rgba(54, 162, 235, 1)',
|
||||
'rgba(255, 206, 86, 1)',
|
||||
'rgba(75, 192, 192, 1)',
|
||||
'rgba(255, 99, 132, 1)'
|
||||
],
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Aufträge nach Status'
|
||||
},
|
||||
legend: {
|
||||
position: 'right'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
</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 = "<canvas id='revenueByCustomerCanvas' style='width: 100%; height: 100%;'></canvas>";
|
||||
Html canvas = new Html(canvasHtml);
|
||||
chartContainer.add(canvas);
|
||||
|
||||
String script = """
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
const ctx = document.getElementById('revenueByCustomerCanvas');
|
||||
if (ctx) {
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ['Firma A GmbH', 'Logistics B', 'Transport C', 'Spediteur D', 'Handel E',
|
||||
'Industrie F', 'Service G', 'Vertrieb H', 'Export I', 'Import J'],
|
||||
datasets: [{
|
||||
label: 'Umsatz (€)',
|
||||
data: [8500, 7200, 6800, 5900, 5400, 4800, 4200, 3900, 3500, 3100],
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.8)',
|
||||
borderColor: 'rgba(75, 192, 192, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Top 10 Kunden nach Umsatz'
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 45,
|
||||
minRotation: 45
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Umsatz (€)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
</script>
|
||||
""";
|
||||
|
||||
Html scriptElement = new Html(script);
|
||||
chartContainer.add(scriptElement);
|
||||
|
||||
return chartContainer;
|
||||
@Override
|
||||
protected void onAttach(AttachEvent attachEvent) {
|
||||
super.onAttach(attachEvent);
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<JobStatus, Long> getJobCountsByStatus() {
|
||||
Map<JobStatus, Long> 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<Job> 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<Job> 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<String, BigDecimal> getRevenueByCustomer() {
|
||||
Map<String, BigDecimal> revenueByCustomer = new HashMap<>();
|
||||
List<Job> 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<Map.Entry<String, BigDecimal>> 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<Month, Long> getMonthlyJobCounts(int year) {
|
||||
Map<Month, Long> monthlyCounts = new LinkedHashMap<>();
|
||||
LocalDateTime yearStart = LocalDateTime.of(year, 1, 1, 0, 0);
|
||||
LocalDateTime yearEnd = LocalDateTime.of(year, 12, 31, 23, 59, 59);
|
||||
|
||||
List<Job> 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<Job> getJobsByCustomer(String customer) {
|
||||
return jobRepository.findByCustomerSelection(customer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task completion statistics.
|
||||
*/
|
||||
public Map<String, Long> getTaskCompletionStats() {
|
||||
Map<String, Long> 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<Job> getJobsByStatus(JobStatus status) {
|
||||
return jobRepository.findByStatus(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get jobs assigned to an app user.
|
||||
*/
|
||||
public List<Job> 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<Job> getJobsByPickupCity(String city) {
|
||||
return jobRepository.findByPickupCity(city);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get jobs by delivery city.
|
||||
*/
|
||||
public List<Job> getJobsByDeliveryCity(String city) {
|
||||
return jobRepository.findByDeliveryCity(city);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest jobs.
|
||||
*/
|
||||
public List<Job> getLatestJobs(int limit) {
|
||||
return jobRepository.findLatestJobs().stream().limit(limit).toList();
|
||||
}
|
||||
}
|
||||
@@ -104,3 +104,19 @@ app.version=@project.version@
|
||||
|
||||
# Google Maps API Key
|
||||
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
|
||||
Reference in New Issue
Block a user