1. Import
This commit is contained in:
94
CLAUDE.md
94
CLAUDE.md
@@ -1,83 +1,61 @@
|
|||||||
# AI TOOL GUIDANCE
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
This is a Vaadin application built with:
|
- **Java 21**
|
||||||
- Java
|
- **Vaadin 25.0.3** (server-side Java UI framework with Lumo theme)
|
||||||
- Spring Boot
|
- **Spring Boot 4.0.1**
|
||||||
- Spring Data JPA with H2 database
|
- **Maven build system**
|
||||||
- Maven build system
|
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
### Running the Application
|
|
||||||
```bash
|
```bash
|
||||||
./mvnw # Start in development mode (default goal: spring-boot:run)
|
./mvnw # Start in development mode (default goal)
|
||||||
./mvnw spring-boot:run # Explicit development mode
|
./mvnw spring-boot:run # Explicit development mode
|
||||||
```
|
|
||||||
|
|
||||||
The application will be available at http://localhost:8080
|
|
||||||
|
|
||||||
### Building for Production
|
|
||||||
```bash
|
|
||||||
./mvnw -Pproduction package # Build production JAR
|
|
||||||
docker build -t my-application:latest . # Build Docker image
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
```bash
|
|
||||||
./mvnw test # Run all tests
|
./mvnw test # Run all tests
|
||||||
./mvnw test -Dtest=TaskServiceTest # Run a single test class
|
./mvnw test -Dtest=MyTest # Run a single test class
|
||||||
./mvnw test -Dtest=TaskServiceTest#tasks_are_stored_in_the_database_with_the_current_timestamp # Run a single test method
|
./mvnw test -Dtest=MyTest#method # Run a single test method
|
||||||
|
./mvnw -Pproduction package # Build production JAR
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
Application runs at http://localhost:8080 (auto-launches browser in dev mode).
|
||||||
|
|
||||||
This project follows a **feature-based package structure** rather than traditional layered architecture. Code is organized by functional units (features), not by technical layers.
|
## Project Structure
|
||||||
|
|
||||||
### Package Structure
|
```
|
||||||
|
de.assecutor.aimailassistant/
|
||||||
|
├── Application.java # Entry point with @SpringBootApplication
|
||||||
|
└── [feature packages] # Add feature packages here
|
||||||
|
```
|
||||||
|
|
||||||
- **`de.assecutor.aimailassistant.base`**: Reusable components and base classes for all features
|
This project uses **feature-based packaging**: each feature is self-contained with its own entities, repositories, services, and UI views.
|
||||||
- `base.ui.MainLayout`: AppLayout with drawer navigation using SideNav, automatically populated from @Menu annotations
|
|
||||||
- `base.ui.component.ViewToolbar`: Reusable toolbar component for views
|
|
||||||
|
|
||||||
- **`de.assecutor.aimailassistant.examplefeature`**: Example feature demonstrating the structure
|
## Architecture Guidelines
|
||||||
- `Task.java`: JPA entity with validation
|
|
||||||
- `TaskRepository.java`: Spring Data JPA repository
|
|
||||||
- `TaskService.java`: Service layer with @Transactional methods
|
|
||||||
- `ui.TaskListView.java`: Vaadin Flow view component (server-side UI)
|
|
||||||
- `TaskServiceTest.java`: Integration test using @SpringBootTest
|
|
||||||
|
|
||||||
- **`Application.java`**: Main entry point, annotated with @SpringBootApplication and @Theme("default")
|
### Adding Features
|
||||||
|
|
||||||
### Key Architecture Patterns
|
Create new packages under `de.assecutor.aimailassistant` (e.g., `de.assecutor.aimailassistant.mail`). Each feature package should contain:
|
||||||
|
- JPA entities
|
||||||
|
- Spring Data repositories
|
||||||
|
- Service classes with `@Transactional`
|
||||||
|
- Vaadin UI views
|
||||||
|
|
||||||
1. **Feature Packages**: Each feature is self-contained with its own UI, business logic, data access, and tests
|
### Vaadin Patterns
|
||||||
2. **Navigation**: Views use `@Route` and `@Menu` annotations. MainLayout automatically builds navigation from menu entries
|
|
||||||
3. **Service Layer**: Use `@Transactional` for write operations and `@Transactional(readOnly = true)` for read operations
|
|
||||||
4. **Validation**: Domain validation in entity setters (see Task.setDescription)
|
|
||||||
5. **Dependency Injection**: Constructor injection throughout (no @Autowired on fields)
|
|
||||||
|
|
||||||
## Adding New Features
|
- **Server-side rendering**: UI components are Java classes
|
||||||
|
- **Routing**: Use `@Route("path")` annotation on view classes
|
||||||
|
- **Navigation**: Use `@Menu` annotation to add views to automatic navigation
|
||||||
|
- **Lazy loading**: Use `VaadinSpringDataHelpers.toSpringPageRequest(query)` for Grid pagination
|
||||||
|
- **Styling**: Custom styles in `src/main/resources/META-INF/resources/styles.css`
|
||||||
|
|
||||||
When creating a new feature:
|
### Spring Patterns
|
||||||
1. Create a new package under `de.assecutor.aimailassistant` (e.g., `de.assecutor.aimailassistant.myfeature`)
|
|
||||||
2. Include: Entity, Repository, Service, and UI view classes
|
|
||||||
3. Use the `examplefeature` package as a reference
|
|
||||||
4. Once your features are complete, **delete the `examplefeature` package entirely**
|
|
||||||
|
|
||||||
## Vaadin-Specific Notes
|
- Constructor injection (no `@Autowired` on fields)
|
||||||
|
- `@Transactional` for write operations
|
||||||
- **Server-side rendering**: UI components are Java classes extending Vaadin components
|
- `@Transactional(readOnly = true)` for read operations
|
||||||
- **Grid lazy loading**: Use `VaadinSpringDataHelpers.toSpringPageRequest(query)` for pagination
|
|
||||||
- **Themes**: Located in `src/main/frontend/themes/default/`, based on Lumo theme
|
|
||||||
- **Routing**: `@Route("")` for root path, `@Route("path")` for specific paths
|
|
||||||
- **Menu**: `@Menu` annotation controls navigation items (order, icon, title)
|
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
- H2 in-memory database for development
|
No database configured yet. To add persistence, include Spring Data JPA and a database driver in pom.xml.
|
||||||
- JPA entities use `@GeneratedValue(strategy = GenerationType.SEQUENCE)`
|
|
||||||
- Entity equality based on ID (see Task.equals/hashCode pattern)
|
|
||||||
|
|||||||
34
pom.xml
34
pom.xml
@@ -45,6 +45,40 @@
|
|||||||
<groupId>com.vaadin</groupId>
|
<groupId>com.vaadin</groupId>
|
||||||
<artifactId>vaadin-spring-boot-starter</artifactId>
|
<artifactId>vaadin-spring-boot-starter</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Data JPA + H2 Database -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Mail for IMAP/SMTP -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.eclipse.angus</groupId>
|
||||||
|
<artifactId>angus-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring WebFlux for LLM API calls -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JSON processing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
23
src/main/frontend/index.html
Normal file
23
src/main/frontend/index.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
This file is auto-generated by Vaadin.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<style>
|
||||||
|
html, body, #outlet {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- This outlet div is where the views are rendered -->
|
||||||
|
<div id="outlet"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
package de.assecutor.aimailassistant;
|
package de.assecutor.aimailassistant;
|
||||||
|
|
||||||
import com.vaadin.flow.theme.lumo.Lumo;
|
|
||||||
import org.springframework.boot.SpringApplication;
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|
||||||
|
|
||||||
import com.vaadin.flow.component.dependency.StyleSheet;
|
import com.vaadin.flow.component.dependency.StyleSheet;
|
||||||
import com.vaadin.flow.component.page.AppShellConfigurator;
|
import com.vaadin.flow.component.page.AppShellConfigurator;
|
||||||
|
import com.vaadin.flow.component.page.Push;
|
||||||
|
import com.vaadin.flow.theme.lumo.Lumo;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@StyleSheet(Lumo.STYLESHEET) // Use Aura.STYLESHEET to use Aura instead
|
@EnableScheduling
|
||||||
|
@Push
|
||||||
|
@StyleSheet(Lumo.STYLESHEET)
|
||||||
@StyleSheet(Lumo.UTILITY_STYLESHEET)
|
@StyleSheet(Lumo.UTILITY_STYLESHEET)
|
||||||
@StyleSheet("styles.css") // Your custom styles
|
@StyleSheet("styles.css")
|
||||||
public class Application implements AppShellConfigurator {
|
public class Application implements AppShellConfigurator {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(Application.class, args);
|
SpringApplication.run(Application.class, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.assecutor.aimailassistant.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class JacksonConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ObjectMapper objectMapper() {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
return mapper;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.domain;
|
||||||
|
|
||||||
|
public enum EmailType {
|
||||||
|
ORDER("Auftrag"),
|
||||||
|
QUOTE_REQUEST("Angebotsanfrage"),
|
||||||
|
UNKNOWN("Unbekannt");
|
||||||
|
|
||||||
|
private final String displayName;
|
||||||
|
|
||||||
|
EmailType(String displayName) {
|
||||||
|
this.displayName = displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDisplayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.domain;
|
||||||
|
|
||||||
|
public enum Gender {
|
||||||
|
MALE("Herr"),
|
||||||
|
FEMALE("Frau"),
|
||||||
|
UNKNOWN("Unbekannt");
|
||||||
|
|
||||||
|
private final String displayName;
|
||||||
|
|
||||||
|
Gender(String displayName) {
|
||||||
|
this.displayName = displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDisplayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "order_emails")
|
||||||
|
public class OrderEmail {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_email_seq")
|
||||||
|
@SequenceGenerator(name = "order_email_seq", sequenceName = "order_email_seq", allocationSize = 1)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String fromAddress;
|
||||||
|
|
||||||
|
private String fromName;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String subject;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime receivedDate;
|
||||||
|
|
||||||
|
private boolean processed;
|
||||||
|
|
||||||
|
private boolean confirmed;
|
||||||
|
|
||||||
|
private boolean deleted;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String summaryJson;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private EmailType type = EmailType.UNKNOWN;
|
||||||
|
|
||||||
|
@Column(unique = true)
|
||||||
|
private String messageId;
|
||||||
|
|
||||||
|
public OrderEmail() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFromAddress() {
|
||||||
|
return fromAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFromAddress(String fromAddress) {
|
||||||
|
this.fromAddress = fromAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFromName() {
|
||||||
|
return fromName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFromName(String fromName) {
|
||||||
|
this.fromName = fromName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSubject() {
|
||||||
|
return subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSubject(String subject) {
|
||||||
|
this.subject = subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getReceivedDate() {
|
||||||
|
return receivedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReceivedDate(LocalDateTime receivedDate) {
|
||||||
|
this.receivedDate = receivedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isProcessed() {
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProcessed(boolean processed) {
|
||||||
|
this.processed = processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConfirmed() {
|
||||||
|
return confirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConfirmed(boolean confirmed) {
|
||||||
|
this.confirmed = confirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDeleted() {
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeleted(boolean deleted) {
|
||||||
|
this.deleted = deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSummaryJson() {
|
||||||
|
return summaryJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSummaryJson(String summaryJson) {
|
||||||
|
this.summaryJson = summaryJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EmailType getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(EmailType type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessageId() {
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessageId(String messageId) {
|
||||||
|
this.messageId = messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDisplaySender() {
|
||||||
|
if (fromName != null && !fromName.isBlank()) {
|
||||||
|
return fromName;
|
||||||
|
}
|
||||||
|
return fromAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (o == null || getClass() != o.getClass()) return false;
|
||||||
|
OrderEmail that = (OrderEmail) o;
|
||||||
|
return id != null && Objects.equals(id, that.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return getClass().hashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.domain;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface OrderEmailRepository extends JpaRepository<OrderEmail, Long> {
|
||||||
|
|
||||||
|
List<OrderEmail> findAllByDeletedFalseOrderByReceivedDateDesc();
|
||||||
|
|
||||||
|
// Sortierung: Neue Emails zuerst (processed=false), dann verarbeitet (confirmed=false), dann bestätigt
|
||||||
|
// Nur nicht-gelöschte Emails anzeigen
|
||||||
|
@Query("SELECT e FROM OrderEmail e WHERE e.deleted = false ORDER BY " +
|
||||||
|
"CASE WHEN e.processed = false THEN 0 " +
|
||||||
|
"WHEN e.confirmed = false THEN 1 " +
|
||||||
|
"ELSE 2 END, " +
|
||||||
|
"e.receivedDate DESC")
|
||||||
|
List<OrderEmail> findAllSortedByStatusAndDate();
|
||||||
|
|
||||||
|
List<OrderEmail> findByProcessedFalseAndDeletedFalseOrderByReceivedDateDesc();
|
||||||
|
|
||||||
|
Optional<OrderEmail> findByMessageId(String messageId);
|
||||||
|
|
||||||
|
boolean existsByMessageId(String messageId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.domain;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class OrderSummary {
|
||||||
|
private EmailType orderType;
|
||||||
|
private String requesterName;
|
||||||
|
private String requesterCompany;
|
||||||
|
private Gender requesterGender = Gender.UNKNOWN;
|
||||||
|
private List<Station> stations = new ArrayList<>();
|
||||||
|
private String remarks;
|
||||||
|
|
||||||
|
public OrderSummary() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public EmailType getOrderType() {
|
||||||
|
return orderType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOrderType(EmailType orderType) {
|
||||||
|
this.orderType = orderType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequesterName() {
|
||||||
|
return requesterName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequesterName(String requesterName) {
|
||||||
|
this.requesterName = requesterName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequesterCompany() {
|
||||||
|
return requesterCompany;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequesterCompany(String requesterCompany) {
|
||||||
|
this.requesterCompany = requesterCompany;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Gender getRequesterGender() {
|
||||||
|
return requesterGender;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequesterGender(Gender requesterGender) {
|
||||||
|
this.requesterGender = requesterGender;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Station> getStations() {
|
||||||
|
return stations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStations(List<Station> stations) {
|
||||||
|
this.stations = stations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRemarks() {
|
||||||
|
return remarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRemarks(String remarks) {
|
||||||
|
this.remarks = remarks;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.domain;
|
||||||
|
|
||||||
|
public class Station {
|
||||||
|
private String name;
|
||||||
|
private String address;
|
||||||
|
private StationAction action;
|
||||||
|
|
||||||
|
public Station() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Station(String name, String address, StationAction action) {
|
||||||
|
this.name = name;
|
||||||
|
this.address = address;
|
||||||
|
this.action = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAddress() {
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAddress(String address) {
|
||||||
|
this.address = address;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StationAction getAction() {
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAction(StationAction action) {
|
||||||
|
this.action = action;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.domain;
|
||||||
|
|
||||||
|
public enum StationAction {
|
||||||
|
PICKUP("Abholung"),
|
||||||
|
DELIVERY("Zustellung"),
|
||||||
|
BOTH("Abholung & Zustellung");
|
||||||
|
|
||||||
|
private final String displayName;
|
||||||
|
|
||||||
|
StationAction(String displayName) {
|
||||||
|
this.displayName = displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDisplayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.event;
|
||||||
|
|
||||||
|
import com.vaadin.flow.shared.Registration;
|
||||||
|
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
public class EmailBroadcaster {
|
||||||
|
|
||||||
|
private static final CopyOnWriteArrayList<Consumer<Void>> listeners = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
public static Registration register(Consumer<Void> listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () -> listeners.remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void broadcast() {
|
||||||
|
listeners.forEach(listener -> listener.accept(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.event;
|
||||||
|
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.OrderEmail;
|
||||||
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
|
||||||
|
public class NewEmailEvent extends ApplicationEvent {
|
||||||
|
|
||||||
|
private final OrderEmail orderEmail;
|
||||||
|
|
||||||
|
public NewEmailEvent(Object source, OrderEmail orderEmail) {
|
||||||
|
super(source);
|
||||||
|
this.orderEmail = orderEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrderEmail getOrderEmail() {
|
||||||
|
return orderEmail;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.service;
|
||||||
|
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.OrderEmail;
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.OrderEmailRepository;
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.OrderSummary;
|
||||||
|
import de.assecutor.aimailassistant.mail.event.EmailBroadcaster;
|
||||||
|
import jakarta.mail.*;
|
||||||
|
import jakarta.mail.internet.InternetAddress;
|
||||||
|
import jakarta.mail.internet.MimeMultipart;
|
||||||
|
import jakarta.mail.search.FlagTerm;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ImapEmailService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ImapEmailService.class);
|
||||||
|
|
||||||
|
private final OrderEmailRepository orderEmailRepository;
|
||||||
|
private final LlmService llmService;
|
||||||
|
|
||||||
|
private final String host;
|
||||||
|
private final int port;
|
||||||
|
private final String username;
|
||||||
|
private final String password;
|
||||||
|
private final boolean ssl;
|
||||||
|
private final String folderName;
|
||||||
|
|
||||||
|
public ImapEmailService(
|
||||||
|
OrderEmailRepository orderEmailRepository,
|
||||||
|
LlmService llmService,
|
||||||
|
@Value("${mail.imap.host}") String host,
|
||||||
|
@Value("${mail.imap.port}") int port,
|
||||||
|
@Value("${mail.imap.username}") String username,
|
||||||
|
@Value("${mail.imap.password}") String password,
|
||||||
|
@Value("${mail.imap.ssl}") boolean ssl,
|
||||||
|
@Value("${mail.imap.folder}") String folderName) {
|
||||||
|
this.orderEmailRepository = orderEmailRepository;
|
||||||
|
this.llmService = llmService;
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
this.ssl = ssl;
|
||||||
|
this.folderName = folderName;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${mail.imap.poll-interval-seconds:60}000")
|
||||||
|
public void pollEmails() {
|
||||||
|
log.info("Polling IMAP mailbox for new emails...");
|
||||||
|
fetchAndProcessEmails();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void fetchAndProcessEmails() {
|
||||||
|
Store store = null;
|
||||||
|
Folder folder = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.put("mail.store.protocol", "imaps");
|
||||||
|
props.put("mail.imaps.host", host);
|
||||||
|
props.put("mail.imaps.port", String.valueOf(port));
|
||||||
|
props.put("mail.imaps.ssl.enable", String.valueOf(ssl));
|
||||||
|
props.put("mail.imaps.ssl.trust", "*");
|
||||||
|
|
||||||
|
Session session = Session.getInstance(props);
|
||||||
|
store = session.getStore("imaps");
|
||||||
|
store.connect(host, port, username, password);
|
||||||
|
|
||||||
|
folder = store.getFolder(folderName);
|
||||||
|
// READ_WRITE um Nachrichten als gelesen markieren zu können
|
||||||
|
folder.open(Folder.READ_WRITE);
|
||||||
|
|
||||||
|
// Nur ungelesene Nachrichten abrufen
|
||||||
|
FlagTerm unseenFlagTerm = new FlagTerm(new Flags(Flags.Flag.SEEN), false);
|
||||||
|
Message[] messages = folder.search(unseenFlagTerm);
|
||||||
|
log.info("Found {} unread messages in mailbox", messages.length);
|
||||||
|
|
||||||
|
for (Message message : messages) {
|
||||||
|
processMessage(message);
|
||||||
|
// Nach dem Verarbeiten als gelesen markieren
|
||||||
|
message.setFlag(Flags.Flag.SEEN, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error fetching emails from IMAP server", e);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (folder != null && folder.isOpen()) {
|
||||||
|
folder.close(false);
|
||||||
|
}
|
||||||
|
if (store != null) {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
} catch (MessagingException e) {
|
||||||
|
log.error("Error closing IMAP connection", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processMessage(Message message) {
|
||||||
|
try {
|
||||||
|
String subject = message.getSubject();
|
||||||
|
if (subject == null) {
|
||||||
|
subject = "(Kein Betreff)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique message ID
|
||||||
|
String[] messageIdHeaders = message.getHeader("Message-ID");
|
||||||
|
String messageId = messageIdHeaders != null && messageIdHeaders.length > 0
|
||||||
|
? messageIdHeaders[0]
|
||||||
|
: generateMessageId(message);
|
||||||
|
|
||||||
|
// Skip if already processed
|
||||||
|
if (orderEmailRepository.existsByMessageId(messageId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Processing new email: {}", subject);
|
||||||
|
|
||||||
|
OrderEmail orderEmail = new OrderEmail();
|
||||||
|
orderEmail.setMessageId(messageId);
|
||||||
|
orderEmail.setSubject(subject);
|
||||||
|
orderEmail.setContent(extractTextContent(message));
|
||||||
|
orderEmail.setReceivedDate(
|
||||||
|
message.getReceivedDate() != null
|
||||||
|
? LocalDateTime.ofInstant(message.getReceivedDate().toInstant(), ZoneId.systemDefault())
|
||||||
|
: LocalDateTime.now()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract sender information
|
||||||
|
Address[] fromAddresses = message.getFrom();
|
||||||
|
if (fromAddresses != null && fromAddresses.length > 0) {
|
||||||
|
if (fromAddresses[0] instanceof InternetAddress internetAddress) {
|
||||||
|
orderEmail.setFromAddress(internetAddress.getAddress());
|
||||||
|
orderEmail.setFromName(internetAddress.getPersonal());
|
||||||
|
} else {
|
||||||
|
orderEmail.setFromAddress(fromAddresses[0].toString());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
orderEmail.setFromAddress("unknown@unknown.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save first to get ID
|
||||||
|
orderEmail = orderEmailRepository.save(orderEmail);
|
||||||
|
|
||||||
|
// Process with LLM
|
||||||
|
try {
|
||||||
|
OrderSummary summary = llmService.summarizeEmail(orderEmail);
|
||||||
|
orderEmail.setSummaryJson(llmService.serializeSummary(summary));
|
||||||
|
orderEmail.setType(summary.getOrderType());
|
||||||
|
// processed bleibt false - wird erst gesetzt wenn der Benutzer die Email öffnet
|
||||||
|
orderEmailRepository.save(orderEmail);
|
||||||
|
log.info("Email processed successfully: {} (Type: {})", subject, summary.getOrderType());
|
||||||
|
|
||||||
|
// Notify UI about new email
|
||||||
|
EmailBroadcaster.broadcast();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error processing email with LLM: {}", subject, e);
|
||||||
|
// Email is saved but not processed - still notify UI
|
||||||
|
EmailBroadcaster.broadcast();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error processing message", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateMessageId(Message message) {
|
||||||
|
try {
|
||||||
|
return "gen-" + message.getSubject().hashCode() + "-" +
|
||||||
|
(message.getReceivedDate() != null ? message.getReceivedDate().getTime() : System.currentTimeMillis());
|
||||||
|
} catch (MessagingException e) {
|
||||||
|
return "gen-" + System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractTextContent(Message message) throws MessagingException, IOException {
|
||||||
|
Object content = message.getContent();
|
||||||
|
|
||||||
|
if (content instanceof String) {
|
||||||
|
return (String) content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content instanceof MimeMultipart multipart) {
|
||||||
|
return extractTextFromMultipart(multipart);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractTextFromMultipart(MimeMultipart multipart) throws MessagingException, IOException {
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
|
||||||
|
for (int i = 0; i < multipart.getCount(); i++) {
|
||||||
|
BodyPart bodyPart = multipart.getBodyPart(i);
|
||||||
|
String contentType = bodyPart.getContentType().toLowerCase();
|
||||||
|
|
||||||
|
if (contentType.contains("text/plain")) {
|
||||||
|
result.append(bodyPart.getContent().toString());
|
||||||
|
} else if (contentType.contains("text/html") && result.isEmpty()) {
|
||||||
|
// Use HTML content only if no plain text is available
|
||||||
|
String html = bodyPart.getContent().toString();
|
||||||
|
result.append(stripHtml(html));
|
||||||
|
} else if (bodyPart.getContent() instanceof MimeMultipart nestedMultipart) {
|
||||||
|
result.append(extractTextFromMultipart(nestedMultipart));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stripHtml(String html) {
|
||||||
|
// Basic HTML stripping - remove tags and decode common entities
|
||||||
|
return html
|
||||||
|
.replaceAll("<br\\s*/?>", "\n")
|
||||||
|
.replaceAll("<p>", "\n")
|
||||||
|
.replaceAll("</p>", "\n")
|
||||||
|
.replaceAll("<[^>]+>", "")
|
||||||
|
.replaceAll(" ", " ")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll(""", "\"")
|
||||||
|
.replaceAll("\\s+\n", "\n")
|
||||||
|
.replaceAll("\n{3,}", "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean deleteEmailFromServer(String messageId) {
|
||||||
|
if (messageId == null || messageId.isBlank()) {
|
||||||
|
log.warn("Cannot delete email: messageId is null or blank");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Store store = null;
|
||||||
|
Folder folder = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.put("mail.store.protocol", "imaps");
|
||||||
|
props.put("mail.imaps.host", host);
|
||||||
|
props.put("mail.imaps.port", String.valueOf(port));
|
||||||
|
props.put("mail.imaps.ssl.enable", String.valueOf(ssl));
|
||||||
|
props.put("mail.imaps.ssl.trust", "*");
|
||||||
|
|
||||||
|
Session session = Session.getInstance(props);
|
||||||
|
store = session.getStore("imaps");
|
||||||
|
store.connect(host, port, username, password);
|
||||||
|
|
||||||
|
folder = store.getFolder(folderName);
|
||||||
|
folder.open(Folder.READ_WRITE);
|
||||||
|
|
||||||
|
Message[] messages = folder.getMessages();
|
||||||
|
boolean found = false;
|
||||||
|
|
||||||
|
for (Message message : messages) {
|
||||||
|
String[] messageIdHeaders = message.getHeader("Message-ID");
|
||||||
|
String currentMessageId = messageIdHeaders != null && messageIdHeaders.length > 0
|
||||||
|
? messageIdHeaders[0]
|
||||||
|
: generateMessageId(message);
|
||||||
|
|
||||||
|
if (messageId.equals(currentMessageId)) {
|
||||||
|
message.setFlag(Flags.Flag.DELETED, true);
|
||||||
|
found = true;
|
||||||
|
log.info("Marked email for deletion on IMAP server: {}", message.getSubject());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
// Expunge to permanently delete marked messages
|
||||||
|
folder.close(true);
|
||||||
|
folder = null;
|
||||||
|
log.info("Email deleted from IMAP server");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
log.warn("Email not found on IMAP server with messageId: {}", messageId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error deleting email from IMAP server", e);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (folder != null && folder.isOpen()) {
|
||||||
|
folder.close(false);
|
||||||
|
}
|
||||||
|
if (store != null) {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
} catch (MessagingException e) {
|
||||||
|
log.error("Error closing IMAP connection", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.*;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class LlmService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LlmService.class);
|
||||||
|
|
||||||
|
private final WebClient webClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final String model;
|
||||||
|
|
||||||
|
public LlmService(
|
||||||
|
@Value("${llm.api.url}") String apiUrl,
|
||||||
|
@Value("${llm.api.model}") String model,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.webClient = WebClient.builder()
|
||||||
|
.baseUrl(apiUrl)
|
||||||
|
.build();
|
||||||
|
this.model = model;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrderSummary summarizeEmail(OrderEmail email) {
|
||||||
|
String prompt = buildPrompt(email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Map<String, Object> request = Map.of(
|
||||||
|
"model", model,
|
||||||
|
"messages", List.of(
|
||||||
|
Map.of("role", "system", "content", getSystemPrompt()),
|
||||||
|
Map.of("role", "user", "content", prompt)
|
||||||
|
),
|
||||||
|
"temperature", 0.3,
|
||||||
|
"max_tokens", 2000
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info("Sending request to LLM API...");
|
||||||
|
String response = webClient.post()
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(request)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block();
|
||||||
|
|
||||||
|
log.debug("LLM Response: {}", response);
|
||||||
|
OrderSummary summary = parseResponse(response);
|
||||||
|
log.info("Classified email as: {}", summary.getOrderType());
|
||||||
|
return summary;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error calling LLM API", e);
|
||||||
|
return createFallbackSummary(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getSystemPrompt() {
|
||||||
|
return """
|
||||||
|
Du bist ein Assistent für die Analyse von Auftrags-Emails eines Kurier- und Transportdienstes.
|
||||||
|
Deine Aufgabe ist es, Emails zu analysieren und strukturierte Informationen zu extrahieren.
|
||||||
|
|
||||||
|
Antworte IMMER im folgenden JSON-Format:
|
||||||
|
{
|
||||||
|
"orderType": "ORDER" oder "QUOTE_REQUEST",
|
||||||
|
"requesterName": "Name des Anforderers (mit Anrede wenn vorhanden, z.B. 'Herr Max Müller' oder 'Frau Anna Schmidt')",
|
||||||
|
"requesterCompany": "Firma des Anforderers",
|
||||||
|
"stations": [
|
||||||
|
{
|
||||||
|
"name": "Name der Station/Person",
|
||||||
|
"address": "Vollständige Adresse",
|
||||||
|
"action": "PICKUP" oder "DELIVERY" oder "BOTH"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"remarks": "Sonderwünsche oder Bemerkungen"
|
||||||
|
}
|
||||||
|
|
||||||
|
WICHTIG - Extraktion des Anforderer-Namens (requesterName):
|
||||||
|
- Suche den Namen in der Email-Signatur (z.B. "Mit freundlichen Grüßen, Max Müller")
|
||||||
|
- Oder im Absender-Feld der Email
|
||||||
|
- Wenn eine Anrede erkennbar ist (Herr/Frau), füge diese hinzu
|
||||||
|
- Format: "Herr Vorname Nachname" oder "Frau Vorname Nachname"
|
||||||
|
- Wenn kein Geschlecht erkennbar, nur den Namen extrahieren
|
||||||
|
|
||||||
|
WICHTIG - Unterscheidung zwischen ORDER und QUOTE_REQUEST:
|
||||||
|
|
||||||
|
Setze "QUOTE_REQUEST" wenn:
|
||||||
|
- Nach einem Preis, Kosten oder Angebot gefragt wird
|
||||||
|
- Formulierungen wie "Was würde es kosten...", "Können Sie mir ein Angebot machen...", "Preisanfrage", "Kostenvoranschlag"
|
||||||
|
- Unverbindliche Anfragen ohne konkreten Auftrag
|
||||||
|
- Der Absender noch keine Entscheidung getroffen hat
|
||||||
|
- Worte wie "würde", "könnte", "möchte wissen", "interessiert an"
|
||||||
|
|
||||||
|
Setze "ORDER" NUR wenn:
|
||||||
|
- Ein konkreter, verbindlicher Auftrag erteilt wird
|
||||||
|
- Feste Termine und Daten genannt werden
|
||||||
|
- Formulierungen wie "Bitte holen Sie ab...", "Wir beauftragen Sie...", "Hiermit bestellen wir..."
|
||||||
|
- Der Kunde klar eine Leistung beauftragt (nicht nur anfragt)
|
||||||
|
|
||||||
|
Im Zweifel: Wähle "QUOTE_REQUEST"
|
||||||
|
|
||||||
|
Weitere Regeln:
|
||||||
|
- Extrahiere alle Stationen (Abholung und Zustellung)
|
||||||
|
- Bei unklaren Informationen, setze null
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildPrompt(OrderEmail email) {
|
||||||
|
return String.format("""
|
||||||
|
Analysiere die folgende Email und extrahiere die relevanten Informationen:
|
||||||
|
|
||||||
|
Von: %s <%s>
|
||||||
|
Betreff: %s
|
||||||
|
|
||||||
|
Inhalt:
|
||||||
|
%s
|
||||||
|
""",
|
||||||
|
email.getFromName() != null ? email.getFromName() : "",
|
||||||
|
email.getFromAddress(),
|
||||||
|
email.getSubject(),
|
||||||
|
email.getContent()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OrderSummary parseResponse(String response) {
|
||||||
|
try {
|
||||||
|
JsonNode root = objectMapper.readTree(response);
|
||||||
|
String content = root.path("choices").get(0).path("message").path("content").asText();
|
||||||
|
|
||||||
|
// Extract JSON from response (handle markdown code blocks)
|
||||||
|
String jsonContent = extractJson(content);
|
||||||
|
|
||||||
|
JsonNode summaryNode = objectMapper.readTree(jsonContent);
|
||||||
|
|
||||||
|
OrderSummary summary = new OrderSummary();
|
||||||
|
|
||||||
|
String orderTypeStr = summaryNode.path("orderType").asText("UNKNOWN");
|
||||||
|
summary.setOrderType(parseEmailType(orderTypeStr));
|
||||||
|
|
||||||
|
summary.setRequesterName(getTextOrNull(summaryNode, "requesterName"));
|
||||||
|
summary.setRequesterCompany(getTextOrNull(summaryNode, "requesterCompany"));
|
||||||
|
summary.setRemarks(getTextOrNull(summaryNode, "remarks"));
|
||||||
|
|
||||||
|
List<Station> stations = new ArrayList<>();
|
||||||
|
JsonNode stationsNode = summaryNode.path("stations");
|
||||||
|
if (stationsNode.isArray()) {
|
||||||
|
for (JsonNode stationNode : stationsNode) {
|
||||||
|
Station station = new Station();
|
||||||
|
station.setName(getTextOrNull(stationNode, "name"));
|
||||||
|
station.setAddress(getTextOrNull(stationNode, "address"));
|
||||||
|
station.setAction(parseStationAction(stationNode.path("action").asText("PICKUP")));
|
||||||
|
stations.add(station);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary.setStations(stations);
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("Error parsing LLM response", e);
|
||||||
|
return new OrderSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractJson(String content) {
|
||||||
|
// Remove markdown code blocks if present
|
||||||
|
if (content.contains("```json")) {
|
||||||
|
int start = content.indexOf("```json") + 7;
|
||||||
|
int end = content.lastIndexOf("```");
|
||||||
|
if (end > start) {
|
||||||
|
return content.substring(start, end).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (content.contains("```")) {
|
||||||
|
int start = content.indexOf("```") + 3;
|
||||||
|
int end = content.lastIndexOf("```");
|
||||||
|
if (end > start) {
|
||||||
|
return content.substring(start, end).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getTextOrNull(JsonNode node, String field) {
|
||||||
|
JsonNode fieldNode = node.path(field);
|
||||||
|
if (fieldNode.isNull() || fieldNode.isMissingNode()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String text = fieldNode.asText();
|
||||||
|
return text.isEmpty() || "null".equals(text) ? null : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EmailType parseEmailType(String type) {
|
||||||
|
return switch (type.toUpperCase()) {
|
||||||
|
case "ORDER" -> EmailType.ORDER;
|
||||||
|
case "QUOTE_REQUEST" -> EmailType.QUOTE_REQUEST;
|
||||||
|
default -> EmailType.UNKNOWN;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private StationAction parseStationAction(String action) {
|
||||||
|
return switch (action.toUpperCase()) {
|
||||||
|
case "DELIVERY" -> StationAction.DELIVERY;
|
||||||
|
case "BOTH" -> StationAction.BOTH;
|
||||||
|
default -> StationAction.PICKUP;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private OrderSummary createFallbackSummary(OrderEmail email) {
|
||||||
|
OrderSummary summary = new OrderSummary();
|
||||||
|
summary.setOrderType(EmailType.UNKNOWN);
|
||||||
|
summary.setRemarks("Automatische Analyse fehlgeschlagen. Bitte manuell prüfen.");
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String serializeSummary(OrderSummary summary) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(summary);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("Error serializing summary", e);
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrderSummary deserializeSummary(String json) {
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return new OrderSummary();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(json, OrderSummary.class);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("Error deserializing summary", e);
|
||||||
|
return new OrderSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrderSummary reprocessEmail(OrderEmail email) {
|
||||||
|
log.info("Reprocessing email: {}", email.getSubject());
|
||||||
|
return summarizeEmail(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.service;
|
||||||
|
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.Gender;
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.OrderEmail;
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.OrderSummary;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SmtpEmailService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SmtpEmailService.class);
|
||||||
|
|
||||||
|
private final JavaMailSender mailSender;
|
||||||
|
private final String fromAddress;
|
||||||
|
|
||||||
|
public SmtpEmailService(
|
||||||
|
JavaMailSender mailSender,
|
||||||
|
@Value("${spring.mail.username}") String fromAddress) {
|
||||||
|
this.mailSender = mailSender;
|
||||||
|
this.fromAddress = fromAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendConfirmation(OrderEmail orderEmail, OrderSummary summary, String confirmationText, String recipient) {
|
||||||
|
String subject = "Re: " + orderEmail.getSubject() + " - Auftragsbestätigung";
|
||||||
|
String body = buildOrderConfirmationBody(orderEmail, summary, confirmationText);
|
||||||
|
String to = (recipient != null && !recipient.isBlank()) ? recipient : orderEmail.getFromAddress();
|
||||||
|
sendEmail(to, subject, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendOffer(OrderEmail orderEmail, OrderSummary summary, String offerText, String recipient) {
|
||||||
|
String subject = "Re: " + orderEmail.getSubject() + " - Ihr Angebot";
|
||||||
|
String body = buildOfferBody(orderEmail, summary, offerText);
|
||||||
|
String to = (recipient != null && !recipient.isBlank()) ? recipient : orderEmail.getFromAddress();
|
||||||
|
sendEmail(to, subject, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildSalutation(OrderSummary summary) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
String name = summary.getRequesterName();
|
||||||
|
Gender gender = summary.getRequesterGender();
|
||||||
|
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
String trimmedName = name.trim();
|
||||||
|
|
||||||
|
// Entferne eventuell vorhandene Anrede aus dem Namen
|
||||||
|
String cleanName = trimmedName;
|
||||||
|
if (trimmedName.toLowerCase().startsWith("herr ")) {
|
||||||
|
cleanName = trimmedName.substring(5).trim();
|
||||||
|
} else if (trimmedName.toLowerCase().startsWith("frau ")) {
|
||||||
|
cleanName = trimmedName.substring(5).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verwende das ausgewählte Geschlecht für die Anrede
|
||||||
|
if (gender == Gender.MALE) {
|
||||||
|
sb.append("Sehr geehrter Herr ").append(cleanName).append(",");
|
||||||
|
} else if (gender == Gender.FEMALE) {
|
||||||
|
sb.append("Sehr geehrte Frau ").append(cleanName).append(",");
|
||||||
|
} else {
|
||||||
|
// Geschlechtsneutrale Anrede mit Namen
|
||||||
|
sb.append("Guten Tag ").append(cleanName).append(",");
|
||||||
|
}
|
||||||
|
} else if (summary.getRequesterCompany() != null && !summary.getRequesterCompany().isBlank()) {
|
||||||
|
sb.append("Sehr geehrte Damen und Herren von ").append(summary.getRequesterCompany()).append(",");
|
||||||
|
} else {
|
||||||
|
sb.append("Sehr geehrte Damen und Herren,");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildOrderConfirmationBody(OrderEmail orderEmail, OrderSummary summary, String confirmationText) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
// Nur Anrede hinzufügen wenn der Text nicht bereits mit "Sehr geehrte" beginnt
|
||||||
|
if (confirmationText == null || !confirmationText.trim().startsWith("Sehr geehrte")) {
|
||||||
|
sb.append(buildSalutation(summary));
|
||||||
|
sb.append("\n\n");
|
||||||
|
sb.append("vielen Dank für Ihren Auftrag.\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmationText != null && !confirmationText.isBlank()) {
|
||||||
|
sb.append(confirmationText);
|
||||||
|
} else {
|
||||||
|
sb.append("Wir bestätigen hiermit den Eingang Ihres Auftrags und werden diesen schnellstmöglich bearbeiten.\n\n");
|
||||||
|
|
||||||
|
if (summary.getStations() != null && !summary.getStations().isEmpty()) {
|
||||||
|
sb.append("Auftragsdaten:\n");
|
||||||
|
summary.getStations().forEach(station -> {
|
||||||
|
sb.append("- ").append(station.getAction().getDisplayName());
|
||||||
|
if (station.getName() != null) {
|
||||||
|
sb.append(": ").append(station.getName());
|
||||||
|
}
|
||||||
|
if (station.getAddress() != null) {
|
||||||
|
sb.append("\n Adresse: ").append(station.getAddress());
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
});
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\n\nBei Rückfragen stehen wir Ihnen gerne zur Verfügung.\n\n");
|
||||||
|
sb.append("Mit freundlichen Grüßen\n");
|
||||||
|
sb.append("Ihr Kurier-Team");
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildOfferBody(OrderEmail orderEmail, OrderSummary summary, String offerText) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
// Nur Anrede hinzufügen wenn der Text nicht bereits mit "Sehr geehrte" beginnt
|
||||||
|
if (offerText == null || !offerText.trim().startsWith("Sehr geehrte")) {
|
||||||
|
sb.append(buildSalutation(summary));
|
||||||
|
sb.append("\n\n");
|
||||||
|
sb.append("vielen Dank für Ihre Anfrage.\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offerText != null && !offerText.isBlank()) {
|
||||||
|
sb.append(offerText);
|
||||||
|
} else {
|
||||||
|
sb.append("Gerne unterbreiten wir Ihnen folgendes Angebot:\n\n");
|
||||||
|
sb.append("[Angebotdetails hier einfügen]\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\n\nDieses Angebot ist freibleibend und unverbindlich.\n\n");
|
||||||
|
sb.append("Bei Rückfragen stehen wir Ihnen gerne zur Verfügung.\n\n");
|
||||||
|
sb.append("Mit freundlichen Grüßen\n");
|
||||||
|
sb.append("Ihr Kurier-Team");
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendEmail(String to, String subject, String body) {
|
||||||
|
try {
|
||||||
|
MimeMessage message = mailSender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
|
||||||
|
|
||||||
|
helper.setFrom(fromAddress);
|
||||||
|
helper.setTo(to);
|
||||||
|
helper.setSubject(subject);
|
||||||
|
helper.setText(body, false);
|
||||||
|
helper.setReplyTo(fromAddress);
|
||||||
|
|
||||||
|
mailSender.send(message);
|
||||||
|
log.info("Email sent successfully to {}", to);
|
||||||
|
} catch (MessagingException e) {
|
||||||
|
log.error("Failed to send email to {}", to, e);
|
||||||
|
throw new RuntimeException("Failed to send email", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDefaultOfferTemplate(OrderSummary summary) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(buildSalutation(summary));
|
||||||
|
sb.append("\n\n");
|
||||||
|
sb.append("vielen Dank für Ihre Anfrage.\n\n");
|
||||||
|
sb.append("""
|
||||||
|
Gerne unterbreiten wir Ihnen folgendes Angebot:
|
||||||
|
|
||||||
|
Leistung: [Beschreibung der Leistung]
|
||||||
|
Preis: [Preis] EUR zzgl. MwSt.
|
||||||
|
|
||||||
|
Lieferzeit: Nach Vereinbarung
|
||||||
|
Gültigkeit: 14 Tage ab heute
|
||||||
|
""");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDefaultConfirmationTemplate(OrderSummary summary) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(buildSalutation(summary));
|
||||||
|
sb.append("\n\n");
|
||||||
|
sb.append("vielen Dank für Ihren Auftrag.\n\n");
|
||||||
|
sb.append("Wir bestätigen hiermit den Eingang Ihres Auftrags und werden diesen schnellstmöglich bearbeiten.\n\n");
|
||||||
|
|
||||||
|
if (summary.getStations() != null && !summary.getStations().isEmpty()) {
|
||||||
|
sb.append("Auftragsdaten:\n");
|
||||||
|
summary.getStations().forEach(station -> {
|
||||||
|
sb.append("- ").append(station.getAction().getDisplayName());
|
||||||
|
if (station.getName() != null) {
|
||||||
|
sb.append(": ").append(station.getName());
|
||||||
|
}
|
||||||
|
if (station.getAddress() != null) {
|
||||||
|
sb.append("\n Adresse: ").append(station.getAddress());
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
301
src/main/java/de/assecutor/aimailassistant/mail/ui/MainView.java
Normal file
301
src/main/java/de/assecutor/aimailassistant/mail/ui/MainView.java
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.ui;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.AttachEvent;
|
||||||
|
import com.vaadin.flow.component.Component;
|
||||||
|
import com.vaadin.flow.component.DetachEvent;
|
||||||
|
import com.vaadin.flow.component.UI;
|
||||||
|
import com.vaadin.flow.component.button.Button;
|
||||||
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
|
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
|
||||||
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
|
import com.vaadin.flow.component.grid.GridVariant;
|
||||||
|
import com.vaadin.flow.component.html.H1;
|
||||||
|
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.notification.Notification;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
import com.vaadin.flow.data.renderer.ComponentRenderer;
|
||||||
|
import com.vaadin.flow.router.Menu;
|
||||||
|
import com.vaadin.flow.router.PageTitle;
|
||||||
|
import com.vaadin.flow.router.Route;
|
||||||
|
import com.vaadin.flow.shared.Registration;
|
||||||
|
import com.vaadin.flow.theme.lumo.LumoUtility;
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.OrderEmail;
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.OrderEmailRepository;
|
||||||
|
import de.assecutor.aimailassistant.mail.event.EmailBroadcaster;
|
||||||
|
import de.assecutor.aimailassistant.mail.service.ImapEmailService;
|
||||||
|
import de.assecutor.aimailassistant.mail.service.LlmService;
|
||||||
|
import de.assecutor.aimailassistant.mail.service.SmtpEmailService;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Route("")
|
||||||
|
@PageTitle("Email-Assistent")
|
||||||
|
@Menu(order = 0, icon = "vaadin:envelope")
|
||||||
|
public class MainView extends VerticalLayout {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
|
||||||
|
|
||||||
|
private final OrderEmailRepository orderEmailRepository;
|
||||||
|
private final LlmService llmService;
|
||||||
|
private final SmtpEmailService smtpEmailService;
|
||||||
|
private final ImapEmailService imapEmailService;
|
||||||
|
private final String googleMapsApiKey;
|
||||||
|
|
||||||
|
private Grid<OrderEmail> grid;
|
||||||
|
private Registration broadcasterRegistration;
|
||||||
|
|
||||||
|
public MainView(
|
||||||
|
OrderEmailRepository orderEmailRepository,
|
||||||
|
LlmService llmService,
|
||||||
|
SmtpEmailService smtpEmailService,
|
||||||
|
ImapEmailService imapEmailService,
|
||||||
|
@Value("${google.maps.api.key}") String googleMapsApiKey) {
|
||||||
|
this.orderEmailRepository = orderEmailRepository;
|
||||||
|
this.llmService = llmService;
|
||||||
|
this.smtpEmailService = smtpEmailService;
|
||||||
|
this.imapEmailService = imapEmailService;
|
||||||
|
this.googleMapsApiKey = googleMapsApiKey;
|
||||||
|
|
||||||
|
setSizeFull();
|
||||||
|
setPadding(true);
|
||||||
|
setSpacing(true);
|
||||||
|
|
||||||
|
add(createHeader());
|
||||||
|
add(createGrid());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component createHeader() {
|
||||||
|
HorizontalLayout header = new HorizontalLayout();
|
||||||
|
header.setWidthFull();
|
||||||
|
header.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
header.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
|
||||||
|
|
||||||
|
HorizontalLayout titleSection = new HorizontalLayout();
|
||||||
|
titleSection.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
titleSection.setSpacing(true);
|
||||||
|
|
||||||
|
Icon mailIcon = VaadinIcon.ENVELOPE_O.create();
|
||||||
|
mailIcon.setSize("32px");
|
||||||
|
mailIcon.addClassName(LumoUtility.TextColor.PRIMARY);
|
||||||
|
|
||||||
|
H1 title = new H1("AI Email-Assistent");
|
||||||
|
title.addClassNames(
|
||||||
|
LumoUtility.Margin.NONE,
|
||||||
|
LumoUtility.FontSize.XLARGE
|
||||||
|
);
|
||||||
|
|
||||||
|
titleSection.add(mailIcon, title);
|
||||||
|
|
||||||
|
Button refreshButton = new Button("Aktualisieren", VaadinIcon.REFRESH.create(), e -> refreshGrid());
|
||||||
|
refreshButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
header.add(titleSection, refreshButton);
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component createGrid() {
|
||||||
|
grid = new Grid<>(OrderEmail.class, false);
|
||||||
|
grid.setSizeFull();
|
||||||
|
grid.addThemeVariants(GridVariant.LUMO_ROW_STRIPES);
|
||||||
|
|
||||||
|
// Date column
|
||||||
|
grid.addColumn(email -> email.getReceivedDate().format(DATE_FORMATTER))
|
||||||
|
.setHeader("Datum")
|
||||||
|
.setAutoWidth(true)
|
||||||
|
.setFlexGrow(0)
|
||||||
|
.setSortable(true);
|
||||||
|
|
||||||
|
// Sender column
|
||||||
|
grid.addColumn(OrderEmail::getDisplaySender)
|
||||||
|
.setHeader("Absender")
|
||||||
|
.setAutoWidth(true)
|
||||||
|
.setFlexGrow(1);
|
||||||
|
|
||||||
|
// Subject column
|
||||||
|
grid.addColumn(OrderEmail::getSubject)
|
||||||
|
.setHeader("Betreff")
|
||||||
|
.setFlexGrow(2);
|
||||||
|
|
||||||
|
// Type column with badge
|
||||||
|
grid.addColumn(new ComponentRenderer<>(this::createTypeBadge))
|
||||||
|
.setHeader("Typ")
|
||||||
|
.setAutoWidth(true)
|
||||||
|
.setFlexGrow(0);
|
||||||
|
|
||||||
|
// Status column with badge
|
||||||
|
grid.addColumn(new ComponentRenderer<>(this::createStatusBadge))
|
||||||
|
.setHeader("Status")
|
||||||
|
.setAutoWidth(true)
|
||||||
|
.setFlexGrow(0);
|
||||||
|
|
||||||
|
// Actions column with delete button
|
||||||
|
grid.addColumn(new ComponentRenderer<>(this::createDeleteButton))
|
||||||
|
.setHeader("")
|
||||||
|
.setAutoWidth(true)
|
||||||
|
.setFlexGrow(0);
|
||||||
|
|
||||||
|
// Click handler
|
||||||
|
grid.addItemClickListener(event -> openDetailDialog(event.getItem()));
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
refreshGrid();
|
||||||
|
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Span createTypeBadge(OrderEmail email) {
|
||||||
|
Span badge = new Span(email.getType().getDisplayName());
|
||||||
|
badge.addClassNames(
|
||||||
|
LumoUtility.Padding.Horizontal.SMALL,
|
||||||
|
LumoUtility.Padding.Vertical.XSMALL,
|
||||||
|
LumoUtility.BorderRadius.MEDIUM,
|
||||||
|
LumoUtility.FontSize.SMALL,
|
||||||
|
LumoUtility.FontWeight.SEMIBOLD
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (email.getType()) {
|
||||||
|
case ORDER -> {
|
||||||
|
badge.addClassName(LumoUtility.Background.SUCCESS_10);
|
||||||
|
badge.addClassName(LumoUtility.TextColor.SUCCESS);
|
||||||
|
}
|
||||||
|
case QUOTE_REQUEST -> {
|
||||||
|
badge.addClassName(LumoUtility.Background.PRIMARY_10);
|
||||||
|
badge.addClassName(LumoUtility.TextColor.PRIMARY);
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
badge.addClassName(LumoUtility.Background.CONTRAST_10);
|
||||||
|
badge.addClassName(LumoUtility.TextColor.SECONDARY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Span createStatusBadge(OrderEmail email) {
|
||||||
|
String statusText;
|
||||||
|
String bgClass;
|
||||||
|
String textClass;
|
||||||
|
|
||||||
|
if (email.isConfirmed()) {
|
||||||
|
statusText = "Bestätigt";
|
||||||
|
bgClass = LumoUtility.Background.SUCCESS_10;
|
||||||
|
textClass = LumoUtility.TextColor.SUCCESS;
|
||||||
|
} else if (email.isProcessed()) {
|
||||||
|
statusText = "Verarbeitet";
|
||||||
|
bgClass = LumoUtility.Background.PRIMARY_10;
|
||||||
|
textClass = LumoUtility.TextColor.PRIMARY;
|
||||||
|
} else {
|
||||||
|
statusText = "Neu";
|
||||||
|
bgClass = LumoUtility.Background.WARNING_10;
|
||||||
|
textClass = ""; // Will use custom color
|
||||||
|
}
|
||||||
|
|
||||||
|
Span badge = new Span(statusText);
|
||||||
|
badge.addClassNames(
|
||||||
|
LumoUtility.Padding.Horizontal.SMALL,
|
||||||
|
LumoUtility.Padding.Vertical.XSMALL,
|
||||||
|
LumoUtility.BorderRadius.MEDIUM,
|
||||||
|
LumoUtility.FontSize.SMALL,
|
||||||
|
LumoUtility.FontWeight.SEMIBOLD,
|
||||||
|
bgClass
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!textClass.isEmpty()) {
|
||||||
|
badge.addClassName(textClass);
|
||||||
|
} else {
|
||||||
|
badge.getStyle().set("color", "var(--lumo-warning-text-color)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button createDeleteButton(OrderEmail email) {
|
||||||
|
Button deleteButton = new Button(new Icon(VaadinIcon.TRASH));
|
||||||
|
deleteButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_SMALL);
|
||||||
|
deleteButton.getElement().setAttribute("title", "Löschen");
|
||||||
|
deleteButton.addClickListener(event -> {
|
||||||
|
event.getSource().getElement().getNode().runWhenAttached(ui -> {
|
||||||
|
// Stop event propagation to prevent row click
|
||||||
|
});
|
||||||
|
confirmDeleteEmail(email);
|
||||||
|
});
|
||||||
|
return deleteButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void confirmDeleteEmail(OrderEmail email) {
|
||||||
|
ConfirmDialog confirmDialog = new ConfirmDialog();
|
||||||
|
confirmDialog.setHeader("Email löschen");
|
||||||
|
confirmDialog.setText("Möchten Sie die Email \"" + email.getSubject() + "\" wirklich löschen?");
|
||||||
|
confirmDialog.setCancelable(true);
|
||||||
|
confirmDialog.setCancelText("Abbrechen");
|
||||||
|
confirmDialog.setConfirmText("Löschen");
|
||||||
|
confirmDialog.setConfirmButtonTheme("error primary");
|
||||||
|
confirmDialog.addConfirmListener(event -> {
|
||||||
|
onEmailDeleted(email);
|
||||||
|
Notification.show("Email wurde gelöscht", 3000, Notification.Position.BOTTOM_START);
|
||||||
|
});
|
||||||
|
confirmDialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openDetailDialog(OrderEmail email) {
|
||||||
|
OrderDetailDialog dialog = new OrderDetailDialog(
|
||||||
|
email,
|
||||||
|
llmService,
|
||||||
|
smtpEmailService,
|
||||||
|
googleMapsApiKey,
|
||||||
|
this::onEmailProcessed,
|
||||||
|
this::onEmailDeleted
|
||||||
|
);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onEmailProcessed(OrderEmail email) {
|
||||||
|
orderEmailRepository.save(email);
|
||||||
|
refreshGrid();
|
||||||
|
Notification.show("Email erfolgreich verarbeitet", 3000, Notification.Position.BOTTOM_START);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onEmailDeleted(OrderEmail email) {
|
||||||
|
// Lösche die Email vom IMAP Server
|
||||||
|
imapEmailService.deleteEmailFromServer(email.getMessageId());
|
||||||
|
|
||||||
|
// Lösche die Email aus der Datenbank
|
||||||
|
orderEmailRepository.delete(email);
|
||||||
|
refreshGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshGrid() {
|
||||||
|
List<OrderEmail> emails = orderEmailRepository.findAllSortedByStatusAndDate();
|
||||||
|
grid.setItems(emails);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onAttach(AttachEvent attachEvent) {
|
||||||
|
super.onAttach(attachEvent);
|
||||||
|
refreshGrid();
|
||||||
|
|
||||||
|
// Register for broadcast updates
|
||||||
|
UI ui = attachEvent.getUI();
|
||||||
|
broadcasterRegistration = EmailBroadcaster.register(unused -> {
|
||||||
|
ui.access(() -> {
|
||||||
|
refreshGrid();
|
||||||
|
Notification.show("Neue Email empfangen", 3000, Notification.Position.BOTTOM_START);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetach(DetachEvent detachEvent) {
|
||||||
|
super.onDetach(detachEvent);
|
||||||
|
if (broadcasterRegistration != null) {
|
||||||
|
broadcasterRegistration.remove();
|
||||||
|
broadcasterRegistration = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.ui;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.button.Button;
|
||||||
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
|
import com.vaadin.flow.component.combobox.ComboBox;
|
||||||
|
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
|
||||||
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
|
import com.vaadin.flow.component.formlayout.FormLayout;
|
||||||
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.html.H4;
|
||||||
|
import com.vaadin.flow.component.html.Pre;
|
||||||
|
import com.vaadin.flow.component.html.Span;
|
||||||
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
|
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.EmailField;
|
||||||
|
import com.vaadin.flow.component.textfield.TextArea;
|
||||||
|
import com.vaadin.flow.theme.lumo.LumoUtility;
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.EmailType;
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.Gender;
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.OrderEmail;
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.OrderSummary;
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.Station;
|
||||||
|
import de.assecutor.aimailassistant.mail.service.LlmService;
|
||||||
|
import de.assecutor.aimailassistant.mail.service.SmtpEmailService;
|
||||||
|
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
public class OrderDetailDialog extends Dialog {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
|
||||||
|
|
||||||
|
private final OrderEmail orderEmail;
|
||||||
|
private OrderSummary summary;
|
||||||
|
private final LlmService llmService;
|
||||||
|
private final SmtpEmailService smtpEmailService;
|
||||||
|
private final Consumer<OrderEmail> onProcessed;
|
||||||
|
private final Consumer<OrderEmail> onDelete;
|
||||||
|
private final String googleMapsApiKey;
|
||||||
|
private TextArea offerTextArea;
|
||||||
|
private TextArea confirmationTextArea;
|
||||||
|
private EmailField recipientField;
|
||||||
|
private HorizontalLayout contentLayout;
|
||||||
|
|
||||||
|
public OrderDetailDialog(
|
||||||
|
OrderEmail orderEmail,
|
||||||
|
LlmService llmService,
|
||||||
|
SmtpEmailService smtpEmailService,
|
||||||
|
String googleMapsApiKey,
|
||||||
|
Consumer<OrderEmail> onProcessed,
|
||||||
|
Consumer<OrderEmail> onDelete) {
|
||||||
|
this.orderEmail = orderEmail;
|
||||||
|
this.llmService = llmService;
|
||||||
|
this.summary = llmService.deserializeSummary(orderEmail.getSummaryJson());
|
||||||
|
this.smtpEmailService = smtpEmailService;
|
||||||
|
this.googleMapsApiKey = googleMapsApiKey;
|
||||||
|
this.onProcessed = onProcessed;
|
||||||
|
this.onDelete = onDelete;
|
||||||
|
|
||||||
|
setHeaderTitle("Email-Details: " + orderEmail.getSubject());
|
||||||
|
setCloseOnOutsideClick(false);
|
||||||
|
setWidth("80vw");
|
||||||
|
setHeight("85vh");
|
||||||
|
|
||||||
|
contentLayout = createContent();
|
||||||
|
add(contentLayout);
|
||||||
|
createFooter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reprocessEmail() {
|
||||||
|
try {
|
||||||
|
Notification.show("Analysiere Email erneut...", 2000, Notification.Position.MIDDLE);
|
||||||
|
|
||||||
|
OrderSummary newSummary = llmService.reprocessEmail(orderEmail);
|
||||||
|
this.summary = newSummary;
|
||||||
|
|
||||||
|
orderEmail.setSummaryJson(llmService.serializeSummary(newSummary));
|
||||||
|
orderEmail.setType(newSummary.getOrderType());
|
||||||
|
onProcessed.accept(orderEmail);
|
||||||
|
|
||||||
|
remove(contentLayout);
|
||||||
|
getFooter().removeAll();
|
||||||
|
contentLayout = createContent();
|
||||||
|
add(contentLayout);
|
||||||
|
createFooter();
|
||||||
|
|
||||||
|
Notification.show("Email neu klassifiziert als: " + newSummary.getOrderType().getDisplayName(),
|
||||||
|
3000, Notification.Position.BOTTOM_START);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Notification.show("Fehler bei der Neuanalyse: " + e.getMessage(),
|
||||||
|
5000, Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HorizontalLayout createContent() {
|
||||||
|
HorizontalLayout layout = new HorizontalLayout();
|
||||||
|
layout.setSizeFull();
|
||||||
|
layout.setSpacing(true);
|
||||||
|
layout.setPadding(false);
|
||||||
|
layout.getStyle()
|
||||||
|
.set("overflow", "hidden")
|
||||||
|
.set("gap", "var(--lumo-space-m)");
|
||||||
|
|
||||||
|
// Left side - Email content (50%)
|
||||||
|
VerticalLayout leftPanel = createLeftPanel();
|
||||||
|
leftPanel.getStyle()
|
||||||
|
.set("flex", "1 1 50%")
|
||||||
|
.set("min-width", "0")
|
||||||
|
.set("overflow", "hidden");
|
||||||
|
|
||||||
|
// Right side - Details (50%)
|
||||||
|
VerticalLayout rightPanel = createRightPanel();
|
||||||
|
rightPanel.getStyle()
|
||||||
|
.set("flex", "1 1 50%")
|
||||||
|
.set("min-width", "0")
|
||||||
|
.set("overflow", "hidden");
|
||||||
|
|
||||||
|
layout.add(leftPanel, rightPanel);
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createLeftPanel() {
|
||||||
|
VerticalLayout panel = new VerticalLayout();
|
||||||
|
panel.setPadding(false);
|
||||||
|
panel.setSpacing(false);
|
||||||
|
panel.setSizeFull();
|
||||||
|
|
||||||
|
H4 title = new H4("Email-Inhalt");
|
||||||
|
title.addClassName(LumoUtility.Margin.Bottom.SMALL);
|
||||||
|
title.addClassName(LumoUtility.Margin.Top.NONE);
|
||||||
|
|
||||||
|
Div contentBox = new Div();
|
||||||
|
contentBox.addClassNames(
|
||||||
|
LumoUtility.Background.CONTRAST_5,
|
||||||
|
LumoUtility.BorderRadius.MEDIUM,
|
||||||
|
LumoUtility.Padding.MEDIUM
|
||||||
|
);
|
||||||
|
contentBox.setWidthFull();
|
||||||
|
contentBox.getStyle()
|
||||||
|
.set("overflow-x", "hidden")
|
||||||
|
.set("overflow-y", "auto")
|
||||||
|
.set("box-sizing", "border-box");
|
||||||
|
|
||||||
|
Pre emailContent = new Pre();
|
||||||
|
emailContent.setText(orderEmail.getContent());
|
||||||
|
emailContent.getStyle()
|
||||||
|
.set("white-space", "pre-wrap")
|
||||||
|
.set("word-wrap", "break-word")
|
||||||
|
.set("overflow-wrap", "break-word")
|
||||||
|
.set("margin", "0")
|
||||||
|
.set("max-width", "100%")
|
||||||
|
.set("font-family", "var(--lumo-font-family)")
|
||||||
|
.set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
|
||||||
|
contentBox.add(emailContent);
|
||||||
|
|
||||||
|
Scroller scroller = new Scroller(contentBox);
|
||||||
|
scroller.setSizeFull();
|
||||||
|
scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
|
||||||
|
|
||||||
|
panel.add(title, scroller);
|
||||||
|
panel.setFlexGrow(1, scroller);
|
||||||
|
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createRightPanel() {
|
||||||
|
VerticalLayout panel = new VerticalLayout();
|
||||||
|
panel.setPadding(false);
|
||||||
|
panel.setSpacing(true);
|
||||||
|
panel.setSizeFull();
|
||||||
|
|
||||||
|
H4 title = new H4("Analyse & Details");
|
||||||
|
title.addClassName(LumoUtility.Margin.Bottom.SMALL);
|
||||||
|
title.addClassName(LumoUtility.Margin.Top.NONE);
|
||||||
|
|
||||||
|
VerticalLayout detailsContent = new VerticalLayout();
|
||||||
|
detailsContent.setPadding(false);
|
||||||
|
detailsContent.setSpacing(true);
|
||||||
|
detailsContent.setWidthFull();
|
||||||
|
detailsContent.getStyle().set("overflow-x", "hidden");
|
||||||
|
|
||||||
|
detailsContent.add(createMetadataSection());
|
||||||
|
detailsContent.add(createTypeComboBox());
|
||||||
|
detailsContent.add(createSummarySection());
|
||||||
|
|
||||||
|
if (summary.getStations() != null && !summary.getStations().isEmpty()) {
|
||||||
|
detailsContent.add(createStationsSection());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.getRemarks() != null && !summary.getRemarks().isBlank()) {
|
||||||
|
detailsContent.add(createRemarksSection());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderEmail.getType() == EmailType.QUOTE_REQUEST) {
|
||||||
|
detailsContent.add(createOfferSection());
|
||||||
|
} else if (orderEmail.getType() == EmailType.ORDER) {
|
||||||
|
detailsContent.add(createConfirmationSection());
|
||||||
|
}
|
||||||
|
|
||||||
|
Scroller scroller = new Scroller(detailsContent);
|
||||||
|
scroller.setSizeFull();
|
||||||
|
scroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
|
||||||
|
|
||||||
|
panel.add(title, scroller);
|
||||||
|
panel.setFlexGrow(1, scroller);
|
||||||
|
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Div createMetadataSection() {
|
||||||
|
Div section = new Div();
|
||||||
|
section.addClassName(LumoUtility.Background.CONTRAST_5);
|
||||||
|
section.addClassName(LumoUtility.BorderRadius.MEDIUM);
|
||||||
|
section.addClassName(LumoUtility.Padding.MEDIUM);
|
||||||
|
section.setWidthFull();
|
||||||
|
section.getStyle().set("box-sizing", "border-box");
|
||||||
|
|
||||||
|
FormLayout form = new FormLayout();
|
||||||
|
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1));
|
||||||
|
form.setWidthFull();
|
||||||
|
|
||||||
|
Span senderSpan = new Span(orderEmail.getDisplaySender());
|
||||||
|
senderSpan.getStyle().set("word-break", "break-word");
|
||||||
|
form.addFormItem(senderSpan, "Absender");
|
||||||
|
|
||||||
|
Span emailSpan = new Span(orderEmail.getFromAddress());
|
||||||
|
emailSpan.getStyle().set("word-break", "break-all");
|
||||||
|
form.addFormItem(emailSpan, "Email");
|
||||||
|
|
||||||
|
form.addFormItem(new Span(orderEmail.getReceivedDate().format(DATE_FORMATTER)), "Empfangen");
|
||||||
|
|
||||||
|
section.add(form);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HorizontalLayout createTypeComboBox() {
|
||||||
|
HorizontalLayout layout = new HorizontalLayout();
|
||||||
|
layout.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
layout.setSpacing(true);
|
||||||
|
layout.setWidthFull();
|
||||||
|
|
||||||
|
Span label = new Span("Klassifizierung:");
|
||||||
|
label.addClassName(LumoUtility.FontWeight.SEMIBOLD);
|
||||||
|
|
||||||
|
ComboBox<EmailType> typeComboBox = new ComboBox<>();
|
||||||
|
typeComboBox.setItems(EmailType.ORDER, EmailType.QUOTE_REQUEST, EmailType.UNKNOWN);
|
||||||
|
typeComboBox.setItemLabelGenerator(EmailType::getDisplayName);
|
||||||
|
typeComboBox.setValue(orderEmail.getType());
|
||||||
|
typeComboBox.setWidth("200px");
|
||||||
|
|
||||||
|
typeComboBox.addValueChangeListener(event -> {
|
||||||
|
if (event.getValue() != null && event.getValue() != event.getOldValue()) {
|
||||||
|
EmailType newType = event.getValue();
|
||||||
|
orderEmail.setType(newType);
|
||||||
|
summary.setOrderType(newType);
|
||||||
|
onProcessed.accept(orderEmail);
|
||||||
|
|
||||||
|
// Refresh the dialog content
|
||||||
|
remove(contentLayout);
|
||||||
|
getFooter().removeAll();
|
||||||
|
contentLayout = createContent();
|
||||||
|
add(contentLayout);
|
||||||
|
createFooter();
|
||||||
|
|
||||||
|
Notification.show("Klassifizierung geändert zu: " + newType.getDisplayName(),
|
||||||
|
3000, Notification.Position.BOTTOM_START);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
layout.add(label, typeComboBox);
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createSummarySection() {
|
||||||
|
VerticalLayout section = new VerticalLayout();
|
||||||
|
section.setPadding(false);
|
||||||
|
section.setSpacing(false);
|
||||||
|
section.setWidthFull();
|
||||||
|
|
||||||
|
H4 title = new H4("Extrahierte Daten");
|
||||||
|
title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL);
|
||||||
|
section.add(title);
|
||||||
|
|
||||||
|
FormLayout form = new FormLayout();
|
||||||
|
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1));
|
||||||
|
|
||||||
|
if (summary.getRequesterName() != null) {
|
||||||
|
form.addFormItem(new Span(summary.getRequesterName()), "Anforderer");
|
||||||
|
|
||||||
|
// Geschlecht ComboBox direkt unter Anforderer
|
||||||
|
ComboBox<Gender> genderComboBox = new ComboBox<>();
|
||||||
|
genderComboBox.setItems(Gender.MALE, Gender.FEMALE, Gender.UNKNOWN);
|
||||||
|
genderComboBox.setItemLabelGenerator(Gender::getDisplayName);
|
||||||
|
genderComboBox.setValue(summary.getRequesterGender() != null ? summary.getRequesterGender() : Gender.UNKNOWN);
|
||||||
|
genderComboBox.setWidth("150px");
|
||||||
|
genderComboBox.addValueChangeListener(event -> {
|
||||||
|
if (event.getValue() != null) {
|
||||||
|
summary.setRequesterGender(event.getValue());
|
||||||
|
orderEmail.setSummaryJson(llmService.serializeSummary(summary));
|
||||||
|
onProcessed.accept(orderEmail);
|
||||||
|
// Aktualisiere die Mail-Templates
|
||||||
|
updateMailTemplates();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
form.addFormItem(genderComboBox, "Anrede");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.getRequesterCompany() != null) {
|
||||||
|
form.addFormItem(new Span(summary.getRequesterCompany()), "Firma");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.getChildren().count() == 0) {
|
||||||
|
Span noData = new Span("Keine Daten extrahiert");
|
||||||
|
noData.addClassName(LumoUtility.TextColor.SECONDARY);
|
||||||
|
section.add(noData);
|
||||||
|
} else {
|
||||||
|
section.add(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateMailTemplates() {
|
||||||
|
if (offerTextArea != null) {
|
||||||
|
offerTextArea.setValue(smtpEmailService.getDefaultOfferTemplate(summary));
|
||||||
|
}
|
||||||
|
if (confirmationTextArea != null) {
|
||||||
|
confirmationTextArea.setValue(smtpEmailService.getDefaultConfirmationTemplate(summary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createStationsSection() {
|
||||||
|
VerticalLayout section = new VerticalLayout();
|
||||||
|
section.setPadding(false);
|
||||||
|
section.setSpacing(true);
|
||||||
|
section.setWidthFull();
|
||||||
|
|
||||||
|
H4 title = new H4("Stationen");
|
||||||
|
title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL);
|
||||||
|
section.add(title);
|
||||||
|
|
||||||
|
for (Station station : summary.getStations()) {
|
||||||
|
Div stationCard = new Div();
|
||||||
|
stationCard.addClassNames(
|
||||||
|
LumoUtility.Background.CONTRAST_5,
|
||||||
|
LumoUtility.BorderRadius.MEDIUM,
|
||||||
|
LumoUtility.Padding.SMALL
|
||||||
|
);
|
||||||
|
stationCard.setWidthFull();
|
||||||
|
|
||||||
|
HorizontalLayout stationContent = new HorizontalLayout();
|
||||||
|
stationContent.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
stationContent.setSpacing(true);
|
||||||
|
stationContent.setWidthFull();
|
||||||
|
|
||||||
|
Span actionBadge = new Span(station.getAction().getDisplayName());
|
||||||
|
actionBadge.addClassNames(
|
||||||
|
LumoUtility.Padding.Horizontal.SMALL,
|
||||||
|
LumoUtility.Padding.Vertical.XSMALL,
|
||||||
|
LumoUtility.BorderRadius.SMALL,
|
||||||
|
LumoUtility.FontSize.XSMALL,
|
||||||
|
LumoUtility.FontWeight.SEMIBOLD
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (station.getAction()) {
|
||||||
|
case PICKUP -> {
|
||||||
|
actionBadge.addClassName(LumoUtility.Background.WARNING_10);
|
||||||
|
actionBadge.getStyle().set("color", "var(--lumo-warning-text-color)");
|
||||||
|
}
|
||||||
|
case DELIVERY -> {
|
||||||
|
actionBadge.addClassName(LumoUtility.Background.SUCCESS_10);
|
||||||
|
actionBadge.addClassName(LumoUtility.TextColor.SUCCESS);
|
||||||
|
}
|
||||||
|
case BOTH -> {
|
||||||
|
actionBadge.addClassName(LumoUtility.Background.PRIMARY_10);
|
||||||
|
actionBadge.addClassName(LumoUtility.TextColor.PRIMARY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout stationInfo = new VerticalLayout();
|
||||||
|
stationInfo.setPadding(false);
|
||||||
|
stationInfo.setSpacing(false);
|
||||||
|
stationInfo.getStyle().set("min-width", "0");
|
||||||
|
|
||||||
|
if (station.getName() != null) {
|
||||||
|
Span name = new Span(station.getName());
|
||||||
|
name.addClassName(LumoUtility.FontWeight.SEMIBOLD);
|
||||||
|
stationInfo.add(name);
|
||||||
|
}
|
||||||
|
if (station.getAddress() != null) {
|
||||||
|
Span address = new Span(station.getAddress());
|
||||||
|
address.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL);
|
||||||
|
address.getStyle().set("word-break", "break-word");
|
||||||
|
stationInfo.add(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
stationContent.add(actionBadge, stationInfo);
|
||||||
|
stationContent.setFlexGrow(1, stationInfo);
|
||||||
|
stationCard.add(stationContent);
|
||||||
|
section.add(stationCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tour anzeigen Button
|
||||||
|
if (summary.getStations().size() >= 2) {
|
||||||
|
Button showTourButton = new Button("Tour anzeigen", e -> openTourMap());
|
||||||
|
showTourButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SMALL);
|
||||||
|
showTourButton.setWidthFull();
|
||||||
|
section.add(showTourButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openTourMap() {
|
||||||
|
TourMapDialog tourMapDialog = new TourMapDialog(summary.getStations(), googleMapsApiKey);
|
||||||
|
tourMapDialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createRemarksSection() {
|
||||||
|
VerticalLayout section = new VerticalLayout();
|
||||||
|
section.setPadding(false);
|
||||||
|
section.setSpacing(false);
|
||||||
|
section.setWidthFull();
|
||||||
|
|
||||||
|
H4 title = new H4("Bemerkungen");
|
||||||
|
title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL);
|
||||||
|
section.add(title);
|
||||||
|
|
||||||
|
Div remarksBox = new Div();
|
||||||
|
remarksBox.addClassNames(
|
||||||
|
LumoUtility.Background.CONTRAST_5,
|
||||||
|
LumoUtility.BorderRadius.MEDIUM,
|
||||||
|
LumoUtility.Padding.MEDIUM
|
||||||
|
);
|
||||||
|
remarksBox.setWidthFull();
|
||||||
|
remarksBox.getStyle()
|
||||||
|
.set("word-break", "break-word")
|
||||||
|
.set("box-sizing", "border-box");
|
||||||
|
remarksBox.setText(summary.getRemarks());
|
||||||
|
|
||||||
|
section.add(remarksBox);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createOfferSection() {
|
||||||
|
VerticalLayout section = new VerticalLayout();
|
||||||
|
section.setPadding(false);
|
||||||
|
section.setSpacing(false);
|
||||||
|
section.setWidthFull();
|
||||||
|
|
||||||
|
H4 title = new H4("Angebotstext");
|
||||||
|
title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL);
|
||||||
|
section.add(title);
|
||||||
|
|
||||||
|
offerTextArea = new TextArea();
|
||||||
|
offerTextArea.setWidthFull();
|
||||||
|
offerTextArea.setMinHeight("120px");
|
||||||
|
offerTextArea.setValue(smtpEmailService.getDefaultOfferTemplate(summary));
|
||||||
|
offerTextArea.setHelperText("Bearbeiten Sie den Angebotstext vor dem Versenden");
|
||||||
|
|
||||||
|
recipientField = new EmailField("Empfänger");
|
||||||
|
recipientField.setWidthFull();
|
||||||
|
recipientField.setValue(orderEmail.getFromAddress());
|
||||||
|
recipientField.setHelperText("Email-Adresse des Empfängers");
|
||||||
|
|
||||||
|
section.add(offerTextArea, recipientField);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createConfirmationSection() {
|
||||||
|
VerticalLayout section = new VerticalLayout();
|
||||||
|
section.setPadding(false);
|
||||||
|
section.setSpacing(false);
|
||||||
|
section.setWidthFull();
|
||||||
|
|
||||||
|
H4 title = new H4("Bestätigungstext");
|
||||||
|
title.addClassNames(LumoUtility.Margin.Bottom.SMALL, LumoUtility.Margin.Top.SMALL);
|
||||||
|
section.add(title);
|
||||||
|
|
||||||
|
confirmationTextArea = new TextArea();
|
||||||
|
confirmationTextArea.setWidthFull();
|
||||||
|
confirmationTextArea.setMinHeight("120px");
|
||||||
|
confirmationTextArea.setValue(smtpEmailService.getDefaultConfirmationTemplate(summary));
|
||||||
|
confirmationTextArea.setHelperText("Bearbeiten Sie den Bestätigungstext vor dem Versenden");
|
||||||
|
|
||||||
|
recipientField = new EmailField("Empfänger");
|
||||||
|
recipientField.setWidthFull();
|
||||||
|
recipientField.setValue(orderEmail.getFromAddress());
|
||||||
|
recipientField.setHelperText("Email-Adresse des Empfängers");
|
||||||
|
|
||||||
|
section.add(confirmationTextArea, recipientField);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createFooter() {
|
||||||
|
Button cancelButton = new Button("Abbrechen", e -> close());
|
||||||
|
|
||||||
|
Button deleteButton = new Button("Löschen", e -> confirmDelete());
|
||||||
|
deleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
Button reprocessButton = new Button("Neu analysieren", e -> reprocessEmail());
|
||||||
|
reprocessButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
// Spacer to push action buttons to the right
|
||||||
|
Div spacer = new Div();
|
||||||
|
spacer.getStyle().set("flex-grow", "1");
|
||||||
|
|
||||||
|
if (orderEmail.getType() == EmailType.QUOTE_REQUEST) {
|
||||||
|
Button sendOfferButton = new Button("Angebot senden", e -> sendOffer());
|
||||||
|
sendOfferButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
getFooter().add(deleteButton, spacer, cancelButton, reprocessButton, sendOfferButton);
|
||||||
|
} else {
|
||||||
|
Button acceptButton = new Button("Auftrag annehmen", e -> acceptOrder());
|
||||||
|
acceptButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
getFooter().add(deleteButton, spacer, cancelButton, reprocessButton, acceptButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void confirmDelete() {
|
||||||
|
ConfirmDialog confirmDialog = new ConfirmDialog();
|
||||||
|
confirmDialog.setHeader("Email löschen");
|
||||||
|
confirmDialog.setText("Möchten Sie diese Email wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.");
|
||||||
|
confirmDialog.setCancelable(true);
|
||||||
|
confirmDialog.setCancelText("Abbrechen");
|
||||||
|
confirmDialog.setConfirmText("Löschen");
|
||||||
|
confirmDialog.setConfirmButtonTheme("error primary");
|
||||||
|
confirmDialog.addConfirmListener(event -> {
|
||||||
|
onDelete.accept(orderEmail);
|
||||||
|
close();
|
||||||
|
Notification.show("Email wurde gelöscht", 3000, Notification.Position.BOTTOM_START);
|
||||||
|
});
|
||||||
|
confirmDialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void acceptOrder() {
|
||||||
|
try {
|
||||||
|
String confirmationText = confirmationTextArea != null ? confirmationTextArea.getValue() : null;
|
||||||
|
String recipient = recipientField != null ? recipientField.getValue() : orderEmail.getFromAddress();
|
||||||
|
smtpEmailService.sendConfirmation(orderEmail, summary, confirmationText, recipient);
|
||||||
|
orderEmail.setProcessed(true);
|
||||||
|
orderEmail.setConfirmed(true);
|
||||||
|
onProcessed.accept(orderEmail);
|
||||||
|
close();
|
||||||
|
|
||||||
|
SuccessDialog successDialog = new SuccessDialog(true);
|
||||||
|
successDialog.open();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Notification.show("Fehler beim Senden der Bestätigung: " + e.getMessage(),
|
||||||
|
5000, Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendOffer() {
|
||||||
|
try {
|
||||||
|
String offerText = offerTextArea != null ? offerTextArea.getValue() : null;
|
||||||
|
String recipient = recipientField != null ? recipientField.getValue() : orderEmail.getFromAddress();
|
||||||
|
smtpEmailService.sendOffer(orderEmail, summary, offerText, recipient);
|
||||||
|
orderEmail.setProcessed(true);
|
||||||
|
orderEmail.setConfirmed(true);
|
||||||
|
onProcessed.accept(orderEmail);
|
||||||
|
close();
|
||||||
|
|
||||||
|
SuccessDialog successDialog = new SuccessDialog(false);
|
||||||
|
successDialog.open();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Notification.show("Fehler beim Senden des Angebots: " + e.getMessage(),
|
||||||
|
5000, Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.ui;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.button.Button;
|
||||||
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
|
import com.vaadin.flow.component.html.H3;
|
||||||
|
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.VerticalLayout;
|
||||||
|
import com.vaadin.flow.theme.lumo.LumoUtility;
|
||||||
|
|
||||||
|
public class SuccessDialog extends Dialog {
|
||||||
|
|
||||||
|
public SuccessDialog(boolean isOrder) {
|
||||||
|
setHeaderTitle("Erfolgreich verarbeitet");
|
||||||
|
setCloseOnOutsideClick(false);
|
||||||
|
setWidth("450px");
|
||||||
|
|
||||||
|
VerticalLayout content = new VerticalLayout();
|
||||||
|
content.setPadding(false);
|
||||||
|
content.setSpacing(true);
|
||||||
|
content.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
|
||||||
|
// Success icon
|
||||||
|
Icon successIcon = VaadinIcon.CHECK_CIRCLE.create();
|
||||||
|
successIcon.setSize("64px");
|
||||||
|
successIcon.addClassName(LumoUtility.TextColor.SUCCESS);
|
||||||
|
|
||||||
|
H3 title = new H3(isOrder ? "Auftrag angenommen" : "Angebot versendet");
|
||||||
|
title.addClassName(LumoUtility.Margin.NONE);
|
||||||
|
|
||||||
|
content.add(successIcon, title);
|
||||||
|
|
||||||
|
// Checklist
|
||||||
|
VerticalLayout checklist = new VerticalLayout();
|
||||||
|
checklist.setPadding(true);
|
||||||
|
checklist.setSpacing(false);
|
||||||
|
checklist.addClassName(LumoUtility.Background.CONTRAST_5);
|
||||||
|
checklist.addClassName(LumoUtility.BorderRadius.MEDIUM);
|
||||||
|
checklist.setWidth("100%");
|
||||||
|
|
||||||
|
checklist.add(createChecklistItem("Bestätigungsmail versendet", true));
|
||||||
|
checklist.add(createChecklistItem("Auftrag in Votian angelegt", true));
|
||||||
|
|
||||||
|
content.add(checklist);
|
||||||
|
|
||||||
|
add(content);
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
Button closeButton = new Button("Schließen", e -> close());
|
||||||
|
closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
getFooter().add(closeButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HorizontalLayout createChecklistItem(String text, boolean checked) {
|
||||||
|
HorizontalLayout item = new HorizontalLayout();
|
||||||
|
item.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
item.setSpacing(true);
|
||||||
|
item.setPadding(false);
|
||||||
|
|
||||||
|
Icon icon;
|
||||||
|
if (checked) {
|
||||||
|
icon = VaadinIcon.CHECK.create();
|
||||||
|
icon.addClassName(LumoUtility.TextColor.SUCCESS);
|
||||||
|
} else {
|
||||||
|
icon = VaadinIcon.CLOCK.create();
|
||||||
|
icon.addClassName(LumoUtility.TextColor.SECONDARY);
|
||||||
|
}
|
||||||
|
icon.setSize("20px");
|
||||||
|
|
||||||
|
Span label = new Span(text);
|
||||||
|
if (checked) {
|
||||||
|
label.addClassName(LumoUtility.TextColor.BODY);
|
||||||
|
} else {
|
||||||
|
label.addClassName(LumoUtility.TextColor.SECONDARY);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.add(icon, label);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package de.assecutor.aimailassistant.mail.ui;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.ClientCallable;
|
||||||
|
import com.vaadin.flow.component.button.Button;
|
||||||
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.html.Span;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
import com.vaadin.flow.theme.lumo.LumoUtility;
|
||||||
|
import de.assecutor.aimailassistant.mail.domain.Station;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
public class TourMapDialog extends Dialog {
|
||||||
|
|
||||||
|
private final List<Station> stations;
|
||||||
|
private final String apiKey;
|
||||||
|
private Span distanceLabel;
|
||||||
|
private Span durationLabel;
|
||||||
|
|
||||||
|
public TourMapDialog(List<Station> stations, String apiKey) {
|
||||||
|
this.stations = stations;
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
|
||||||
|
setHeaderTitle("Tour-Übersicht");
|
||||||
|
setWidth("80vw");
|
||||||
|
setHeight("85vh");
|
||||||
|
setCloseOnOutsideClick(true);
|
||||||
|
|
||||||
|
add(createContent());
|
||||||
|
createFooter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createContent() {
|
||||||
|
VerticalLayout layout = new VerticalLayout();
|
||||||
|
layout.setSizeFull();
|
||||||
|
layout.setPadding(false);
|
||||||
|
layout.setSpacing(true);
|
||||||
|
|
||||||
|
// Info section
|
||||||
|
HorizontalLayout infoSection = createInfoSection();
|
||||||
|
layout.add(infoSection);
|
||||||
|
|
||||||
|
// Map container
|
||||||
|
Div mapContainer = createMapContainer();
|
||||||
|
layout.add(mapContainer);
|
||||||
|
layout.setFlexGrow(1, mapContainer);
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HorizontalLayout createInfoSection() {
|
||||||
|
HorizontalLayout layout = new HorizontalLayout();
|
||||||
|
layout.setWidthFull();
|
||||||
|
layout.setSpacing(true);
|
||||||
|
layout.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
|
||||||
|
// Distance info
|
||||||
|
Div distanceBox = new Div();
|
||||||
|
distanceBox.addClassNames(
|
||||||
|
LumoUtility.Background.PRIMARY_10,
|
||||||
|
LumoUtility.BorderRadius.MEDIUM,
|
||||||
|
LumoUtility.Padding.MEDIUM
|
||||||
|
);
|
||||||
|
VerticalLayout distanceContent = new VerticalLayout();
|
||||||
|
distanceContent.setPadding(false);
|
||||||
|
distanceContent.setSpacing(false);
|
||||||
|
Span distanceTitle = new Span("Entfernung");
|
||||||
|
distanceTitle.addClassNames(LumoUtility.FontSize.SMALL, LumoUtility.TextColor.SECONDARY);
|
||||||
|
distanceLabel = new Span("Wird berechnet...");
|
||||||
|
distanceLabel.addClassNames(LumoUtility.FontSize.XLARGE, LumoUtility.FontWeight.BOLD);
|
||||||
|
distanceContent.add(distanceTitle, distanceLabel);
|
||||||
|
distanceBox.add(distanceContent);
|
||||||
|
|
||||||
|
// Duration info
|
||||||
|
Div durationBox = new Div();
|
||||||
|
durationBox.addClassNames(
|
||||||
|
LumoUtility.Background.SUCCESS_10,
|
||||||
|
LumoUtility.BorderRadius.MEDIUM,
|
||||||
|
LumoUtility.Padding.MEDIUM
|
||||||
|
);
|
||||||
|
VerticalLayout durationContent = new VerticalLayout();
|
||||||
|
durationContent.setPadding(false);
|
||||||
|
durationContent.setSpacing(false);
|
||||||
|
Span durationTitle = new Span("Geschätzte Fahrzeit");
|
||||||
|
durationTitle.addClassNames(LumoUtility.FontSize.SMALL, LumoUtility.TextColor.SECONDARY);
|
||||||
|
durationLabel = new Span("Wird berechnet...");
|
||||||
|
durationLabel.addClassNames(LumoUtility.FontSize.XLARGE, LumoUtility.FontWeight.BOLD);
|
||||||
|
durationContent.add(durationTitle, durationLabel);
|
||||||
|
durationBox.add(durationContent);
|
||||||
|
|
||||||
|
// Stations info
|
||||||
|
Div stationsBox = new Div();
|
||||||
|
stationsBox.addClassNames(
|
||||||
|
LumoUtility.Background.CONTRAST_5,
|
||||||
|
LumoUtility.BorderRadius.MEDIUM,
|
||||||
|
LumoUtility.Padding.MEDIUM
|
||||||
|
);
|
||||||
|
VerticalLayout stationsContent = new VerticalLayout();
|
||||||
|
stationsContent.setPadding(false);
|
||||||
|
stationsContent.setSpacing(false);
|
||||||
|
Span stationsTitle = new Span("Stationen");
|
||||||
|
stationsTitle.addClassNames(LumoUtility.FontSize.SMALL, LumoUtility.TextColor.SECONDARY);
|
||||||
|
Span stationsCount = new Span(String.valueOf(stations.size()));
|
||||||
|
stationsCount.addClassNames(LumoUtility.FontSize.XLARGE, LumoUtility.FontWeight.BOLD);
|
||||||
|
stationsContent.add(stationsTitle, stationsCount);
|
||||||
|
stationsBox.add(stationsContent);
|
||||||
|
|
||||||
|
layout.add(distanceBox, durationBox, stationsBox);
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Div createMapContainer() {
|
||||||
|
Div mapDiv = new Div();
|
||||||
|
mapDiv.setId("tour-map");
|
||||||
|
mapDiv.setSizeFull();
|
||||||
|
mapDiv.getStyle()
|
||||||
|
.set("min-height", "400px")
|
||||||
|
.set("border-radius", "var(--lumo-border-radius-m)")
|
||||||
|
.set("overflow", "hidden");
|
||||||
|
|
||||||
|
// Build waypoints JavaScript array
|
||||||
|
String waypointsJs = stations.stream()
|
||||||
|
.map(s -> {
|
||||||
|
String addr = s.getAddress() != null ? s.getAddress() : s.getName();
|
||||||
|
return "\"" + escapeJs(addr) + "\"";
|
||||||
|
})
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
|
||||||
|
// JavaScript to initialize Google Maps
|
||||||
|
String initScript = """
|
||||||
|
(function() {
|
||||||
|
const mapDiv = document.getElementById('tour-map');
|
||||||
|
if (!mapDiv) return;
|
||||||
|
|
||||||
|
// Load Google Maps script if not already loaded
|
||||||
|
if (!window.google || !window.google.maps) {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://maps.googleapis.com/maps/api/js?key=%s&libraries=places';
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.onload = function() {
|
||||||
|
initMap();
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
} else {
|
||||||
|
initMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMap() {
|
||||||
|
const waypoints = [%s];
|
||||||
|
if (waypoints.length < 2) {
|
||||||
|
mapDiv.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%%;color:var(--lumo-secondary-text-color)">Mindestens 2 Stationen erforderlich</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new google.maps.Map(mapDiv, {
|
||||||
|
zoom: 10,
|
||||||
|
center: { lat: 51.1657, lng: 10.4515 }, // Germany center
|
||||||
|
mapTypeControl: true,
|
||||||
|
streetViewControl: false,
|
||||||
|
fullscreenControl: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const directionsService = new google.maps.DirectionsService();
|
||||||
|
const directionsRenderer = new google.maps.DirectionsRenderer({
|
||||||
|
map: map,
|
||||||
|
suppressMarkers: false,
|
||||||
|
polylineOptions: {
|
||||||
|
strokeColor: '#1676F3',
|
||||||
|
strokeWeight: 5
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const origin = waypoints[0];
|
||||||
|
const destination = waypoints[waypoints.length - 1];
|
||||||
|
const middleWaypoints = waypoints.slice(1, -1).map(wp => ({
|
||||||
|
location: wp,
|
||||||
|
stopover: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
directionsService.route({
|
||||||
|
origin: origin,
|
||||||
|
destination: destination,
|
||||||
|
waypoints: middleWaypoints,
|
||||||
|
travelMode: google.maps.TravelMode.DRIVING,
|
||||||
|
region: 'de'
|
||||||
|
}, function(result, status) {
|
||||||
|
if (status === 'OK') {
|
||||||
|
directionsRenderer.setDirections(result);
|
||||||
|
|
||||||
|
// Calculate total distance and duration
|
||||||
|
let totalDistance = 0;
|
||||||
|
let totalDuration = 0;
|
||||||
|
result.routes[0].legs.forEach(leg => {
|
||||||
|
totalDistance += leg.distance.value;
|
||||||
|
totalDuration += leg.duration.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const distanceKm = (totalDistance / 1000).toFixed(1);
|
||||||
|
const hours = Math.floor(totalDuration / 3600);
|
||||||
|
const minutes = Math.floor((totalDuration %% 3600) / 60);
|
||||||
|
const durationText = hours > 0 ? hours + ' h ' + minutes + ' min' : minutes + ' min';
|
||||||
|
|
||||||
|
// Update the labels via server
|
||||||
|
const dialogElement = mapDiv.closest('vaadin-dialog-overlay');
|
||||||
|
if (dialogElement && dialogElement.__component) {
|
||||||
|
dialogElement.__component.$server.updateRouteInfo(distanceKm, durationText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update directly via DOM as fallback
|
||||||
|
const distanceSpan = document.querySelector('[data-distance-label]');
|
||||||
|
const durationSpan = document.querySelector('[data-duration-label]');
|
||||||
|
if (distanceSpan) distanceSpan.textContent = distanceKm + ' km';
|
||||||
|
if (durationSpan) durationSpan.textContent = durationText;
|
||||||
|
} else {
|
||||||
|
console.error('Directions request failed:', status);
|
||||||
|
mapDiv.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%%;color:var(--lumo-error-text-color)">Route konnte nicht berechnet werden: ' + status + '</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
""".formatted(apiKey, waypointsJs);
|
||||||
|
|
||||||
|
// Add data attributes for JavaScript fallback update
|
||||||
|
distanceLabel.getElement().setAttribute("data-distance-label", "true");
|
||||||
|
durationLabel.getElement().setAttribute("data-duration-label", "true");
|
||||||
|
|
||||||
|
// Execute script after attach
|
||||||
|
mapDiv.getElement().executeJs(initScript);
|
||||||
|
|
||||||
|
return mapDiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapeJs(String str) {
|
||||||
|
if (str == null) return "";
|
||||||
|
return str.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ClientCallable
|
||||||
|
public void updateRouteInfo(String distance, String duration) {
|
||||||
|
getUI().ifPresent(ui -> ui.access(() -> {
|
||||||
|
distanceLabel.setText(distance + " km");
|
||||||
|
durationLabel.setText(duration);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createFooter() {
|
||||||
|
Button closeButton = new Button("Schließen", e -> close());
|
||||||
|
getFooter().add(closeButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,5 +5,38 @@ logging.level.org.atmosphere=warn
|
|||||||
vaadin.launch-browser=true
|
vaadin.launch-browser=true
|
||||||
|
|
||||||
# To improve the performance during development.
|
# To improve the performance during development.
|
||||||
# For more information https://vaadin.com/docs/latest/flow/integrations/spring/configuration#special-configuration-parameters
|
|
||||||
vaadin.allowed-packages=com.vaadin,org.vaadin,com.flowingcode,de.assecutor.aimailassistant
|
vaadin.allowed-packages=com.vaadin,org.vaadin,com.flowingcode,de.assecutor.aimailassistant
|
||||||
|
|
||||||
|
# H2 Database Configuration
|
||||||
|
spring.datasource.url=jdbc:h2:mem:mailassistant
|
||||||
|
spring.datasource.driverClassName=org.h2.Driver
|
||||||
|
spring.datasource.username=sa
|
||||||
|
spring.datasource.password=
|
||||||
|
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
|
||||||
|
spring.jpa.hibernate.ddl-auto=create-drop
|
||||||
|
spring.h2.console.enabled=true
|
||||||
|
|
||||||
|
# IMAP Configuration
|
||||||
|
mail.imap.host=mail.appcreation.de
|
||||||
|
mail.imap.port=993
|
||||||
|
mail.imap.username=sb@appcreation.de
|
||||||
|
mail.imap.password=SV1705CA!sb
|
||||||
|
mail.imap.ssl=true
|
||||||
|
mail.imap.folder=INBOX
|
||||||
|
mail.imap.poll-interval-seconds=60
|
||||||
|
|
||||||
|
# SMTP Configuration
|
||||||
|
spring.mail.host=mail.appcreation.de
|
||||||
|
spring.mail.port=465
|
||||||
|
spring.mail.username=sb@appcreation.de
|
||||||
|
spring.mail.password=SV1705CA!sb
|
||||||
|
spring.mail.properties.mail.smtp.auth=true
|
||||||
|
spring.mail.properties.mail.smtp.ssl.enable=true
|
||||||
|
spring.mail.properties.mail.smtp.ssl.required=true
|
||||||
|
|
||||||
|
# LM Studio Configuration
|
||||||
|
llm.api.url=http://192.168.180.10:1234/v1/chat/completions
|
||||||
|
llm.api.model=local-model
|
||||||
|
|
||||||
|
# Google Maps Configuration
|
||||||
|
google.maps.api.key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE
|
||||||
|
|||||||
Reference in New Issue
Block a user