1. Import

This commit is contained in:
2026-03-29 10:34:57 +02:00
parent b0e00c1259
commit a1129565af
4899 changed files with 3007593 additions and 0 deletions

View 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" />
<style>
body, #outlet {
height: 100vh;
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>

View File

@@ -0,0 +1,39 @@
/* Votian Theme - Stadtbote GmbH */
html {
--lumo-primary-color: #1b12b9;
--lumo-primary-color-50pct: rgba(27, 18, 185, 0.5);
--lumo-primary-text-color: #1b12b9;
}
/* Header */
vaadin-app-layout::part(navbar) {
background: linear-gradient(135deg, #1b12b9 0%, #2a1fd4 100%);
color: white;
}
vaadin-app-layout::part(navbar) h1 {
color: white !important;
}
/* Drawer */
vaadin-app-layout::part(drawer) {
background: #f8f9fa;
border-right: 1px solid #dee2e6;
}
/* Grid styling */
vaadin-grid {
--lumo-row-stripe-color: rgba(27, 18, 185, 0.03);
}
/* Button primary */
vaadin-button[theme~="primary"] {
background-color: #1b12b9;
}
/* Login page */
.login-form {
max-width: 400px;
margin: 0 auto;
}

View File

@@ -0,0 +1,3 @@
{
"lumoImports": ["typography", "color", "spacing", "badge", "utility"]
}

61
vaadin/pom.xml Normal file
View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>de.votian</groupId>
<artifactId>votian-web</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>Votian Web Application</name>
<description>Vaadin Flow Web Application for Votian Logistics (Stadtbote GmbH)</description>
<properties>
<java.version>21</java.version>
<vaadin.version>24.3.11</vaadin.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-bom</artifactId>
<version>${vaadin.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,15 @@
package de.votian.web;
import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.theme.Theme;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@Theme("votian")
public class VotianWebApplication implements AppShellConfigurator {
public static void main(String[] args) {
SpringApplication.run(VotianWebApplication.class, args);
}
}

View File

@@ -0,0 +1,23 @@
package de.votian.web.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestClientConfig {
@Value("${votian.services.url}")
private String servicesUrl;
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
@Bean
public String servicesBaseUrl() {
return servicesUrl;
}
}

View File

@@ -0,0 +1,389 @@
package de.votian.web.model;
import com.vaadin.flow.spring.annotation.VaadinSessionScope;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
@VaadinSessionScope
public class SessionData {
private Long userId;
private Long headquartersId;
private Long employeeId;
private Long customerId;
private Long costCenterId;
private Long courierId;
private String userName;
private String userFirstname;
private String authToken;
private String pendingTotpToken;
private Integer userType;
private String legacyRights;
private List<Long> accessibleCostCenterIds = new ArrayList<>();
private List<Long> rightIds = new ArrayList<>();
private List<String> rightNames = new ArrayList<>();
private boolean authenticated;
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getHeadquartersId() { return headquartersId; }
public void setHeadquartersId(Long headquartersId) { this.headquartersId = headquartersId; }
public Long getEmployeeId() { return employeeId; }
public void setEmployeeId(Long employeeId) { this.employeeId = employeeId; }
public Long getCustomerId() { return customerId; }
public void setCustomerId(Long customerId) { this.customerId = customerId; }
public Long getCostCenterId() { return costCenterId; }
public void setCostCenterId(Long costCenterId) { this.costCenterId = costCenterId; }
public Long getCourierId() { return courierId; }
public void setCourierId(Long courierId) { this.courierId = courierId; }
public String getUserName() { return userName; }
public void setUserName(String userName) { this.userName = userName; }
public String getUserFirstname() { return userFirstname; }
public void setUserFirstname(String userFirstname) { this.userFirstname = userFirstname; }
public String getAuthToken() { return authToken; }
public void setAuthToken(String authToken) { this.authToken = authToken; }
public String getPendingTotpToken() { return pendingTotpToken; }
public void setPendingTotpToken(String pendingTotpToken) { this.pendingTotpToken = pendingTotpToken; }
public Integer getUserType() { return userType; }
public void setUserType(Integer userType) { this.userType = userType; }
public String getLegacyRights() { return legacyRights; }
public void setLegacyRights(String legacyRights) { this.legacyRights = legacyRights; }
public List<Long> getAccessibleCostCenterIds() { return accessibleCostCenterIds; }
public void setAccessibleCostCenterIds(List<Long> accessibleCostCenterIds) {
this.accessibleCostCenterIds = accessibleCostCenterIds != null ? new ArrayList<>(accessibleCostCenterIds) : new ArrayList<>();
}
public List<Long> getRightIds() { return rightIds; }
public void setRightIds(List<Long> rightIds) {
this.rightIds = rightIds != null ? new ArrayList<>(rightIds) : new ArrayList<>();
}
public List<String> getRightNames() { return rightNames; }
public void setRightNames(List<String> rightNames) {
this.rightNames = rightNames != null ? new ArrayList<>(rightNames) : new ArrayList<>();
}
public boolean isAuthenticated() { return authenticated; }
public void setAuthenticated(boolean authenticated) { this.authenticated = authenticated; }
public boolean isHeadquartersUser() { return Integer.valueOf(1).equals(userType); }
public boolean isCustomerUser() { return Integer.valueOf(2).equals(userType); }
public boolean isCourierUser() { return Integer.valueOf(3).equals(userType); }
public boolean isWarehouseUser() { return Integer.valueOf(4).equals(userType); }
public boolean hasCostCenterAccess(Long costCenterId) {
return costCenterId == null || accessibleCostCenterIds.contains(costCenterId);
}
public boolean canViewCustomers() {
return isHeadquartersUser() && hqRight(0);
}
public boolean canManageCustomers() {
return canViewCustomers();
}
public boolean canViewCouriers() {
return isHeadquartersUser() && hqRight(1);
}
public boolean canManageCouriers() {
return canViewCouriers();
}
public boolean canViewEmployees() {
if (isHeadquartersUser()) {
return hqRight(3);
}
if (isCustomerUser()) {
return true;
}
return false;
}
public boolean canManageEmployees() {
if (isHeadquartersUser()) {
return hqRight(3);
}
return isCustomerUser() && customerRight(2);
}
public boolean canManageEmployeeMatrixRights() {
return isHeadquartersUser() && hqRight(3);
}
public boolean canManageEmployeeCostCenterMatrix() {
if (isHeadquartersUser()) {
return canManageEmployees();
}
return isCustomerUser() && customerRight(2) && customerRight(12);
}
public boolean canViewCostCenters() {
if (isHeadquartersUser()) {
return canViewCustomers();
}
return isCustomerUser() && customerRight(0);
}
public boolean canManageCostCenters() {
if (isHeadquartersUser()) {
return canManageCustomers();
}
return isCustomerUser() && customerRight(1);
}
public boolean canViewJobs() {
if (isHeadquartersUser()) {
return hqRight(4) || hqRight(7);
}
if (isCustomerUser()) {
return customerRight(4);
}
return false;
}
public boolean canManageJobs() {
if (isHeadquartersUser()) {
return hqRight(7);
}
return isCustomerUser() && customerRight(4);
}
public boolean canBatchJobs() {
return canManageJobs();
}
public boolean canExportJobs() {
if (isHeadquartersUser()) {
return canViewJobs() && hqRight(42);
}
return isCustomerUser() && canViewJobs() && customerRight(7);
}
public boolean canViewInvoices() {
if (isHeadquartersUser()) {
return hqRight(5);
}
if (isCustomerUser()) {
return customerRight(5);
}
return isCourierUser();
}
public boolean canExportInvoices() {
if (isHeadquartersUser()) {
return hqRight(5);
}
if (isCustomerUser()) {
return customerRight(7);
}
return isCourierUser();
}
public boolean canViewStatistics() {
if (isHeadquartersUser()) {
return hqRight(8);
}
return isCustomerUser() && customerRight(8);
}
public boolean canViewDisposition() {
return isHeadquartersUser() && (hqRight(7) || hqRight(19));
}
public boolean canManageDisposition() {
return isHeadquartersUser() && hqRight(7);
}
public boolean canViewLonghaul() {
return isHeadquartersUser() && hqRight(10);
}
public boolean canManageLonghaul() {
return canViewLonghaul();
}
public boolean canViewGroupware() {
return isHeadquartersUser() && hqRight(11);
}
public boolean canManageGroupware() {
return canViewGroupware();
}
public boolean canViewGenericExports() {
if (isHeadquartersUser()) {
return hqRight(6);
}
return isCustomerUser() && customerRight(7);
}
public boolean canManageGenericExports() {
return canViewGenericExports();
}
public boolean canViewCustomerReports() {
return isHeadquartersUser() && hqRight(16) && hqRight(0);
}
public boolean canViewCourierReports() {
return isHeadquartersUser() && hqRight(16) && hqRight(1);
}
public boolean canViewProspectReports() {
return canViewCustomerReports();
}
public boolean canViewReports() {
return canViewCustomerReports() || canViewCourierReports() || canViewProspectReports();
}
public boolean canManageReports() {
return canViewReports();
}
public boolean canViewConfidentialReports() {
return isHeadquartersUser() && hqRight(10);
}
public boolean canManageCourierMessageGroups() {
return isHeadquartersUser() && hqRight(1);
}
public boolean canManageDeviceMessages() {
return isHeadquartersUser() && hqRight(4);
}
public boolean canManageTicker() {
return isHeadquartersUser() && hqRight(18);
}
public boolean canManageNewsletter() {
return isHeadquartersUser() && hqRight(17);
}
public boolean canViewCommunication() {
return canManageCourierMessageGroups()
|| canManageDeviceMessages()
|| canManageTicker()
|| canManageNewsletter();
}
public boolean canManageCommunication() {
return canViewCommunication();
}
public boolean canViewGlobalMap() {
return isHeadquartersUser() && hqRight(19);
}
public boolean canViewSimilaritySearch() {
return isHeadquartersUser() && (hqRight(0) || hqRight(1));
}
public boolean canManageAddressDirectory() {
return isHeadquartersUser() && hqRight(2);
}
public boolean canManageTravelTimes() {
return canManageAddressDirectory();
}
public boolean canViewGeoOperations() {
return canViewGlobalMap()
|| canViewSimilaritySearch()
|| canManageAddressDirectory();
}
public boolean canManageGeoOperations() {
return canViewGeoOperations();
}
public boolean canManagePricing() {
return isHeadquartersUser() && hqRight(2) && hqRight(29);
}
public boolean canManageHeadquarters() {
return isHeadquartersUser() && hqRight(2);
}
public boolean canManageMetaFields() {
return canManageHeadquarters();
}
public boolean canManageImports() {
return isHeadquartersUser() && (hqRight(15) || hqRight(22) || hqRight(23));
}
public boolean canManageEmployeeWarehouseConfig() {
return isHeadquartersUser() && canManageEmployees();
}
public boolean canViewWarehouse() {
return isWarehouseUser() || (isHeadquartersUser() && hqRight(14));
}
public boolean canEditOwnEmployeeAccount() {
return isCustomerUser() && customerRight(10);
}
public boolean canViewEmployee(Long targetEmployeeId) {
if (isHeadquartersUser()) {
return canViewEmployees();
}
return isCustomerUser()
&& (canManageEmployees()
|| (employeeId != null && employeeId.equals(targetEmployeeId)));
}
public boolean canEditEmployee(Long targetEmployeeId) {
if (isHeadquartersUser()) {
return canManageEmployees();
}
return isCustomerUser()
&& (canManageEmployees()
|| ((employeeId != null && employeeId.equals(targetEmployeeId))
&& canEditOwnEmployeeAccount()));
}
public String getDisplayName() {
return (userFirstname != null ? userFirstname : "") + " " + (userName != null ? userName : "");
}
public void clear() {
userId = null;
headquartersId = null;
employeeId = null;
customerId = null;
costCenterId = null;
courierId = null;
userName = null;
userFirstname = null;
authToken = null;
pendingTotpToken = null;
userType = null;
legacyRights = null;
accessibleCostCenterIds = new ArrayList<>();
rightIds = new ArrayList<>();
rightNames = new ArrayList<>();
authenticated = false;
}
private boolean hqRight(int index) {
return legacyRight(index, true);
}
private boolean customerRight(int index) {
return legacyRight(index, false);
}
private boolean legacyRight(int index, boolean defaultIfMissing) {
if (index < 0) {
return false;
}
if (legacyRights == null || legacyRights.isBlank()) {
return defaultIfMissing;
}
return legacyRights.length() > index && legacyRights.charAt(index) == '1';
}
}

View File

@@ -0,0 +1,246 @@
package de.votian.web.service;
import com.vaadin.flow.spring.annotation.VaadinSessionScope;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Session-scoped parameter provider for the Vaadin web application.
* <p>
* Equivalent to PHP's defineGlobalParameters() which loads all parameters
* for the current headquarters and makes them available as constants.
* <p>
* Parameters are loaded once per session (at login) and cached.
* Individual parameters can also be fetched on-demand via the REST API
* with full PHP-compatible fallback logic.
* <p>
* Usage in views:
* <pre>
* &#64;Autowired
* private ParameterProvider parameterProvider;
*
* String value = parameterProvider.get("MASK_MULTI_JOBLIST");
* boolean enabled = parameterProvider.is("CS_TRACKING_ENABLED");
* int count = parameterProvider.getInt("MASK_MULTI_JOBLIST", 4);
* </pre>
*/
@Component
@VaadinSessionScope
public class ParameterProvider {
private static final Logger log = LoggerFactory.getLogger(ParameterProvider.class);
private final RestClientService restClient;
/** Cached parameters: key -> value */
private final Map<String, String> cache = new ConcurrentHashMap<>();
private boolean loaded = false;
private Long headquartersId;
private Long employeeId;
public ParameterProvider(RestClientService restClient) {
this.restClient = restClient;
}
/**
* Initialize the parameter cache for the current session.
* Should be called after successful login.
*
* @param hqId Headquarters ID of the logged-in user
* @param empId Employee ID of the logged-in user
*/
public void init(Long hqId, Long empId) {
this.headquartersId = hqId;
this.employeeId = empId != null ? empId : 0L;
reload();
}
/**
* Reload all parameters from the backend.
* Calls the /api/parameters/bulk endpoint which returns all global + employee-specific parameters.
*/
public void reload() {
if (headquartersId == null) {
log.warn("Cannot load parameters: headquartersId not set. Call init() first.");
return;
}
try {
Map<String, Object> params = restClient.getMap(
"/parameters/bulk?hqId=" + headquartersId + "&empId=" + employeeId
);
cache.clear();
if (params != null) {
for (Map.Entry<String, Object> entry : params.entrySet()) {
cache.put(entry.getKey(), entry.getValue() != null ? entry.getValue().toString() : "");
}
}
loaded = true;
log.info("Loaded {} parameters for hq={}, emp={}", cache.size(), headquartersId, employeeId);
} catch (Exception e) {
log.error("Failed to load parameters for hq={}, emp={}", headquartersId, employeeId, e);
}
}
// ========================================================================
// Parameter access methods
// ========================================================================
/**
* Get a parameter value from the cache.
*
* @param key Parameter key (use constants from ParameterKeys)
* @return Parameter value or empty string if not found
*/
public String get(String key) {
return cache.getOrDefault(key, "");
}
/**
* Get a parameter value with a default fallback.
*/
public String get(String key, String defaultValue) {
String value = cache.get(key);
return (value != null && !value.isEmpty()) ? value : defaultValue;
}
/**
* Get a parameter value as integer.
*/
public int getInt(String key, int defaultValue) {
String value = cache.get(key);
if (value != null && !value.isEmpty()) {
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
return defaultValue;
}
}
return defaultValue;
}
/**
* Get a parameter value as long.
*/
public long getLong(String key, long defaultValue) {
String value = cache.get(key);
if (value != null && !value.isEmpty()) {
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException e) {
return defaultValue;
}
}
return defaultValue;
}
/**
* Check if a parameter is enabled (value is "1").
* This is the most common boolean check in the PHP code.
*/
public boolean is(String key) {
return "1".equals(cache.get(key));
}
/**
* Check if a parameter has a non-empty value.
*/
public boolean has(String key) {
String value = cache.get(key);
return value != null && !value.isEmpty();
}
/**
* Get an object-based parameter (dynamic key with object ID suffix).
* First checks key_objId, then falls back to the base key.
*
* @param baseKey Base parameter key (e.g., "CUSTOMER_MASK_JOBLIST_FIELDS")
* @param objId Object ID (e.g., customer ID)
* @return Parameter value or empty string
*/
public String getForObject(String baseKey, Long objId) {
if (objId != null && objId > 0) {
String objValue = cache.get(baseKey + objId);
if (objValue != null && !objValue.isEmpty()) {
return objValue;
}
// Also try with underscore separator (KEY_123456)
if (!baseKey.endsWith("_")) {
objValue = cache.get(baseKey + "_" + objId);
if (objValue != null && !objValue.isEmpty()) {
return objValue;
}
}
}
// Fallback to base key
return get(baseKey);
}
/**
* Check if an object-based parameter is enabled.
*/
public boolean isForObject(String baseKey, Long objId) {
return "1".equals(getForObject(baseKey, objId));
}
// ========================================================================
// Fetch individual parameters via REST (bypassing cache)
// ========================================================================
/**
* Fetch a parameter value directly from the backend with full fallback logic.
* Use this for parameters that may not be in the bulk-loaded cache
* (e.g., employee-specific parameters for other employees).
*/
public String fetchValue(String key, Long hqId, Long empId) {
try {
String url = "/parameters/value-full-fallback?key=" + key
+ "&hqId=" + hqId + "&empId=" + (empId != null ? empId : 0);
String value = restClient.get(url, String.class);
return value != null ? value : "";
} catch (Exception e) {
log.debug("Parameter not found via REST: key={}, hq={}, emp={}", key, hqId, empId);
return "";
}
}
/**
* Fetch a parameter with the current session's headquarters.
*/
public String fetchValue(String key) {
return fetchValue(key, headquartersId, employeeId);
}
// ========================================================================
// State
// ========================================================================
public boolean isLoaded() {
return loaded;
}
public int size() {
return cache.size();
}
public Long getHeadquartersId() {
return headquartersId;
}
public Long getEmployeeId() {
return employeeId;
}
/**
* Get all cached parameters as an unmodifiable map.
*/
public Map<String, String> getAll() {
return Collections.unmodifiableMap(cache);
}
}

View File

@@ -0,0 +1,129 @@
package de.votian.web.service;
import de.votian.web.model.SessionData;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.support.ScopeNotActiveException;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Service
public class RestClientService {
private final RestTemplate restTemplate;
private final String baseUrl;
private final ObjectProvider<SessionData> sessionDataProvider;
public RestClientService(RestTemplate restTemplate,
@Qualifier("servicesBaseUrl") String baseUrl,
ObjectProvider<SessionData> sessionDataProvider) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
this.sessionDataProvider = sessionDataProvider;
}
public <T> T get(String path, Class<T> type) {
ResponseEntity<T> response = restTemplate.exchange(
baseUrl + path, HttpMethod.GET, authorizedEntity(null), type);
return response.getBody();
}
public <T> List<T> getList(String path, ParameterizedTypeReference<List<T>> typeRef) {
ResponseEntity<List<T>> response = restTemplate.exchange(
baseUrl + path, HttpMethod.GET, authorizedEntity(null), typeRef);
return response.getBody() != null ? response.getBody() : Collections.emptyList();
}
public <T> T post(String path, Object body, Class<T> type) {
ResponseEntity<T> response = restTemplate.exchange(
baseUrl + path, HttpMethod.POST, authorizedEntity(body), type);
return response.getBody();
}
public <T> T postMultipart(String path, byte[] fileBytes, String filename, String contentType,
Map<String, String> fields, Class<T> type) {
MultipartBodyBuilder builder = new MultipartBodyBuilder();
ByteArrayResource resource = new ByteArrayResource(fileBytes) {
@Override
public String getFilename() {
return filename;
}
};
builder.part("file", resource)
.filename(filename)
.contentType(contentType != null && !contentType.isBlank()
? MediaType.parseMediaType(contentType)
: MediaType.APPLICATION_OCTET_STREAM);
for (Map.Entry<String, String> entry : fields.entrySet()) {
builder.part(entry.getKey(), entry.getValue());
}
ResponseEntity<T> response = restTemplate.exchange(
baseUrl + path,
HttpMethod.POST,
authorizedMultipartEntity(builder.build()),
type
);
return response.getBody();
}
public void put(String path, Object body) {
restTemplate.exchange(baseUrl + path, HttpMethod.PUT, authorizedEntity(body), Void.class);
}
public void delete(String path) {
restTemplate.exchange(baseUrl + path, HttpMethod.DELETE, authorizedEntity(null), Void.class);
}
public Map<String, Object> getMap(String path) {
ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
baseUrl + path, HttpMethod.GET, authorizedEntity(null),
new ParameterizedTypeReference<Map<String, Object>>() {});
return response.getBody() != null ? response.getBody() : Collections.emptyMap();
}
public byte[] getBytes(String path) {
ResponseEntity<byte[]> response = restTemplate.exchange(
baseUrl + path, HttpMethod.GET, authorizedEntity(null), byte[].class);
return response.getBody() != null ? response.getBody() : new byte[0];
}
private HttpEntity<Object> authorizedEntity(Object body) {
return new HttpEntity<>(body, authorizedHeaders(MediaType.APPLICATION_JSON));
}
private HttpEntity<MultiValueMap<String, HttpEntity<?>>> authorizedMultipartEntity(MultiValueMap<String, HttpEntity<?>> body) {
return new HttpEntity<>(body, authorizedHeaders(MediaType.MULTIPART_FORM_DATA));
}
private HttpHeaders authorizedHeaders(MediaType contentType) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(contentType);
SessionData sessionData = currentSessionData();
if (sessionData != null && sessionData.getAuthToken() != null && !sessionData.getAuthToken().isBlank()) {
headers.setBearerAuth(sessionData.getAuthToken());
}
return headers;
}
private SessionData currentSessionData() {
try {
return sessionDataProvider.getIfAvailable();
} catch (ScopeNotActiveException | IllegalStateException ignored) {
return null;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
package de.votian.web.view;
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.dialog.Dialog;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import java.util.*;
@Route(value = "costcenters", layout = MainLayout.class)
@PageTitle("Votian - Kostenstellen")
public class CostCenterView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final Grid<Map<String, Object>> grid;
private final TextField customerIdField;
public CostCenterView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setSizeFull();
setPadding(true);
customerIdField = new TextField("Kunden-ID");
customerIdField.setPlaceholder("Kunden-ID eingeben...");
Button loadButton = new Button("Laden", VaadinIcon.DOWNLOAD.create(), e -> loadCostCenters());
loadButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button addButton = new Button("Neue Kostenstelle", VaadinIcon.PLUS.create(), e -> showAddDialog());
addButton.setVisible(sessionData.canManageCostCenters());
HorizontalLayout toolbar = new HorizontalLayout(customerIdField, loadButton, addButton);
toolbar.setAlignItems(Alignment.BASELINE);
grid = new Grid<>();
grid.addColumn(m -> m.get("id")).setHeader("ID").setAutoWidth(true);
grid.addColumn(m -> m.get("name")).setHeader("Name").setFlexGrow(2);
grid.addColumn(m -> m.get("path")).setHeader("Pfad").setFlexGrow(2);
grid.addColumn(m -> "1".equals(str(m.get("visible"))) ? "Ja" : "Nein").setHeader("Sichtbar").setAutoWidth(true);
grid.addColumn(m -> "1".equals(str(m.get("isExtern"))) ? "Ja" : "Nein").setHeader("Extern").setAutoWidth(true);
grid.setSizeFull();
grid.addItemDoubleClickListener(e -> showEditDialog(e.getItem()));
add(toolbar, grid);
if (sessionData.isCustomerUser() && sessionData.getCustomerId() != null) {
customerIdField.setValue(String.valueOf(sessionData.getCustomerId()));
customerIdField.setReadOnly(true);
loadCostCenters();
}
}
private void loadCostCenters() {
String csId = customerIdField.getValue().trim();
if (csId.isEmpty()) {
Notification.show("Bitte Kunden-ID eingeben.");
return;
}
try {
Object[] centers = restClient.get("/costcenters/customer/" + csId, Object[].class);
if (centers != null) {
List<Map<String, Object>> items = new ArrayList<>();
for (Object c : centers) {
if (c instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) c;
Long id = map.get("id") instanceof Number number ? number.longValue() : null;
if (!sessionData.isCustomerUser() || sessionData.hasCostCenterAccess(id)) {
items.add(map);
}
}
}
grid.setItems(items);
}
} catch (Exception e) {
Notification.show("Fehler: " + e.getMessage());
}
}
private void showAddDialog() {
if (!sessionData.canManageCostCenters()) {
Notification.show("Keine Berechtigung zum Anlegen von Kostenstellen.");
return;
}
String csId = customerIdField.getValue().trim();
if (csId.isEmpty()) {
Notification.show("Bitte zuerst Kunden-ID eingeben.");
return;
}
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Neue Kostenstelle");
dialog.setWidth("500px");
TextField nameField = new TextField("Name");
nameField.setWidthFull();
TextField pathField = new TextField("Pfad");
pathField.setWidthFull();
ComboBox<String> visibleField = new ComboBox<>("Sichtbar");
visibleField.setItems("0", "1");
visibleField.setItemLabelGenerator(v -> "1".equals(v) ? "Ja" : "Nein");
visibleField.setValue("1");
FormLayout form = new FormLayout(nameField, pathField, visibleField);
dialog.add(form);
Button saveBtn = new Button("Speichern", ev -> {
try {
Map<String, Object> csc = new HashMap<>();
csc.put("customerId", Long.parseLong(csId));
csc.put("name", nameField.getValue());
csc.put("path", pathField.getValue());
csc.put("visible", visibleField.getValue());
restClient.post("/costcenters", csc, LinkedHashMap.class);
Notification.show("Kostenstelle gespeichert!", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
dialog.close();
loadCostCenters();
} catch (Exception ex) {
Notification.show("Fehler: " + ex.getMessage());
}
});
saveBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button cancelBtn = new Button("Abbrechen", ev -> dialog.close());
dialog.getFooter().add(cancelBtn, saveBtn);
dialog.open();
}
private void showEditDialog(Map<String, Object> item) {
if (!sessionData.canManageCostCenters()) {
Notification.show("Keine Berechtigung zum Bearbeiten von Kostenstellen.");
return;
}
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Kostenstelle bearbeiten");
dialog.setWidth("500px");
TextField nameField = new TextField("Name");
nameField.setValue(str(item.get("name")));
nameField.setWidthFull();
TextField pathField = new TextField("Pfad");
pathField.setValue(str(item.get("path")));
pathField.setWidthFull();
ComboBox<String> visibleField = new ComboBox<>("Sichtbar");
visibleField.setItems("0", "1");
visibleField.setItemLabelGenerator(v -> "1".equals(v) ? "Ja" : "Nein");
visibleField.setValue(str(item.get("visible")));
FormLayout form = new FormLayout(nameField, pathField, visibleField);
dialog.add(form);
Button saveBtn = new Button("Speichern", ev -> {
try {
Map<String, Object> updates = new HashMap<>();
updates.put("name", nameField.getValue());
updates.put("path", pathField.getValue());
updates.put("visible", visibleField.getValue());
restClient.put("/costcenters/" + item.get("id"), updates);
Notification.show("Gespeichert!", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
dialog.close();
loadCostCenters();
} catch (Exception ex) {
Notification.show("Fehler: " + ex.getMessage());
}
});
saveBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button cancelBtn = new Button("Abbrechen", ev -> dialog.close());
dialog.getFooter().add(cancelBtn, saveBtn);
dialog.open();
}
private String str(Object obj) {
return obj != null ? obj.toString() : "";
}
}

View File

@@ -0,0 +1,731 @@
package de.votian.web.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.CheckboxGroup;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.tabs.Tab;
import com.vaadin.flow.component.tabs.Tabs;
import com.vaadin.flow.component.textfield.PasswordField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.*;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Route(value = "couriers/:id?", layout = MainLayout.class)
@PageTitle("Votian - Kurierdetail")
public class CourierDetailView extends VerticalLayout implements HasUrlParameter<String> {
private final RestClientService restClient;
private final SessionData sessionData;
private Long courierId;
private boolean isNew = true;
private final TextField compField = new TextField("Firma");
private final TextField comp2Field = new TextField("Firma 2");
private final TextField hsnoField = new TextField("Hausnummer");
private final TextField streetField = new TextField("Strasse");
private final TextField zipcodeField = new TextField("PLZ");
private final TextField cityField = new TextField("Ort");
private final TextField ilnField = new TextField("ILN");
private final TextField taxIdField = new TextField("Steuer-Nr.");
private final TextField eidField = new TextField("Kurier-Nr. (EID)");
private final TextField sidField = new TextField("SID");
private final ComboBox<String> availableField = new ComboBox<>("Verfuegbar");
private final TextField mobilePdaField = new TextField("Mobile/PDA");
private final TextField trackingZipcodeField = new TextField("Tracking-PLZ");
private final TextField trackingGpsTimeField = new TextField("Letzte Ortung");
private final TextField trackingGpsTypeField = new TextField("Ortungstyp");
private final TextField trackingGpsCoordinatesField = new TextField("Koordinaten");
private final TextField trackingAvailableTimeField = new TextField("Verfuegbar seit");
private final CheckboxGroup<GroupOption> groupField = new CheckboxGroup<>();
private final CheckboxGroup<FilterOption> filterField = new CheckboxGroup<>();
private final TextField accountField = new TextField("Benutzerkonto");
private final TextField nameField = new TextField("Name");
private final TextField firstnameField = new TextField("Vorname");
private final TextField emailField = new TextField("E-Mail");
private final TextField phoneField = new TextField("Telefon");
private final TextField phone2Field = new TextField("Telefon 2");
private final TextField faxField = new TextField("Fax");
private final DatePicker birthdateField = new DatePicker("Geburtsdatum");
private final TextField countryField = new TextField("Land");
private final PasswordField passwordField = new PasswordField("Passwort");
private final VerticalLayout vehicleContent = new VerticalLayout();
private final Grid<VehicleRow> vehicleGrid = new Grid<>(VehicleRow.class, false);
private final Button addVehicleButton = new Button("Fahrzeug anlegen", VaadinIcon.PLUS.create());
private final Button editVehicleButton = new Button("Fahrzeug bearbeiten", VaadinIcon.EDIT.create());
private final Button deleteVehicleButton = new Button("Fahrzeug loeschen", VaadinIcon.TRASH.create());
private final List<VehicleTypeOption> vehicleTypes = new ArrayList<>();
private final List<VehicleRow> vehicles = new ArrayList<>();
private final List<GroupOption> groupOptions = new ArrayList<>();
private final List<FilterOption> filterOptions = new ArrayList<>();
public CourierDetailView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setPadding(true);
setSpacing(true);
setWidthFull();
availableField.setItems("0", "1");
availableField.setItemLabelGenerator(v -> "1".equals(v) ? "Ja" : "Nein");
mobilePdaField.setWidthFull();
trackingZipcodeField.setReadOnly(true);
trackingGpsTimeField.setReadOnly(true);
trackingGpsTypeField.setReadOnly(true);
trackingGpsCoordinatesField.setReadOnly(true);
trackingAvailableTimeField.setReadOnly(true);
groupField.setLabel("Gruppen");
groupField.setWidthFull();
groupField.setItemLabelGenerator(GroupOption::label);
filterField.setLabel("Filter");
filterField.setWidthFull();
filterField.setItemLabelGenerator(FilterOption::label);
vehicleContent.setPadding(false);
vehicleContent.setSpacing(true);
vehicleContent.setWidthFull();
configureVehicleGrid();
configureVehicleActions();
}
@Override
public void setParameter(BeforeEvent event, @OptionalParameter String parameter) {
removeAll();
resetState();
loadVehicleTypes();
loadGroupFilterOptions();
if (parameter != null && !parameter.isEmpty() && !"new".equals(parameter)) {
try {
courierId = Long.parseLong(parameter);
isNew = false;
loadCourier();
loadVehicles();
} catch (NumberFormatException e) {
isNew = true;
}
}
buildForm();
}
private void buildForm() {
H3 title = new H3(isNew ? "Neuer Kurier" : "Kurier bearbeiten");
Tab companyTab = new Tab("Firma");
Tab contactTab = new Tab("Kontakt");
Tab courierTab = new Tab("Kurier-Daten");
Tab vehiclesTab = new Tab("Fahrzeuge");
Tabs tabs = new Tabs(companyTab, contactTab, courierTab, vehiclesTab);
FormLayout companyForm = new FormLayout();
companyForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("500px", 2));
companyForm.add(compField, comp2Field, streetField, hsnoField, zipcodeField, cityField, ilnField, taxIdField);
FormLayout contactForm = new FormLayout();
contactForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("500px", 2));
contactForm.add(nameField, firstnameField, emailField, phoneField, phone2Field, faxField,
birthdateField, countryField, accountField, passwordField);
contactForm.setVisible(false);
FormLayout courierForm = new FormLayout();
courierForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("500px", 2));
courierForm.add(
eidField,
sidField,
availableField,
mobilePdaField,
trackingZipcodeField,
trackingGpsTimeField,
trackingGpsTypeField,
trackingGpsCoordinatesField,
trackingAvailableTimeField,
groupField,
filterField
);
courierForm.setColspan(trackingGpsCoordinatesField, 2);
courierForm.setColspan(groupField, 2);
courierForm.setColspan(filterField, 2);
courierForm.setVisible(false);
buildVehicleContent();
vehicleContent.setVisible(false);
tabs.addSelectedChangeListener(e -> {
companyForm.setVisible(e.getSelectedTab() == companyTab);
contactForm.setVisible(e.getSelectedTab() == contactTab);
courierForm.setVisible(e.getSelectedTab() == courierTab);
vehicleContent.setVisible(e.getSelectedTab() == vehiclesTab);
});
Button saveButton = new Button("Speichern", VaadinIcon.CHECK.create(), e -> save());
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
saveButton.setEnabled(sessionData.canManageCouriers());
Button cancelButton = new Button("Abbrechen", e ->
getUI().ifPresent(ui -> ui.navigate(CourierListView.class)));
HorizontalLayout buttons = new HorizontalLayout(saveButton, cancelButton);
add(title, tabs, companyForm, contactForm, courierForm, vehicleContent, buttons);
}
private void configureVehicleGrid() {
vehicleGrid.addColumn(VehicleRow::sid).setHeader("SID").setAutoWidth(true);
vehicleGrid.addColumn(VehicleRow::vehicleTypeName).setHeader("Fahrzeugtyp").setAutoWidth(true);
vehicleGrid.setWidthFull();
vehicleGrid.setMinHeight("240px");
vehicleGrid.asSingleSelect().addValueChangeListener(e -> refreshVehicleActionState());
}
private void configureVehicleActions() {
addVehicleButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
editVehicleButton.addClickListener(e -> {
VehicleRow selectedVehicle = vehicleGrid.asSingleSelect().getValue();
if (selectedVehicle != null) {
openVehicleDialog(selectedVehicle);
}
});
deleteVehicleButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
addVehicleButton.addClickListener(e -> openVehicleDialog(null));
deleteVehicleButton.addClickListener(e -> deleteSelectedVehicle());
refreshVehicleActionState();
}
private void buildVehicleContent() {
vehicleContent.removeAll();
vehicleGrid.setItems(vehicles);
vehicleGrid.asSingleSelect().clear();
String message = courierId == null
? "Fahrzeuge koennen nach dem ersten Speichern des Kuriers gepflegt werden."
: "Zusatzfahrzeuge des Kuriers inklusive Fahrzeugtyp pflegen.";
Paragraph info = new Paragraph(message);
HorizontalLayout actions = new HorizontalLayout(addVehicleButton, editVehicleButton, deleteVehicleButton);
vehicleContent.add(info, actions, vehicleGrid);
refreshVehicleActionState();
}
private void openVehicleDialog(VehicleRow existingVehicle) {
if (!sessionData.canManageCouriers()) {
Notification.show("Keine Berechtigung zur Fahrzeugpflege.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
if (courierId == null) {
Notification.show("Kurier zuerst speichern.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
Dialog dialog = new Dialog();
dialog.setHeaderTitle(existingVehicle == null ? "Fahrzeug anlegen" : "Fahrzeug bearbeiten");
TextField vehicleSidField = new TextField("SID");
vehicleSidField.setWidthFull();
ComboBox<VehicleTypeOption> vehicleTypeField = new ComboBox<>("Fahrzeugtyp");
vehicleTypeField.setItems(vehicleTypes);
vehicleTypeField.setItemLabelGenerator(VehicleTypeOption::label);
vehicleTypeField.setWidthFull();
if (existingVehicle != null) {
vehicleSidField.setValue(existingVehicle.sid());
vehicleTypeField.setValue(findVehicleType(existingVehicle.vehicleTypeId()));
}
Button saveButton = new Button("Speichern", e -> {
if (vehicleTypeField.getValue() == null) {
Notification.show("Bitte einen Fahrzeugtyp waehlen.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
try {
Map<String, Object> vehicle = new HashMap<>();
vehicle.put("sid", vehicleSidField.getValue());
vehicle.put("vehicleTypeId", vehicleTypeField.getValue().id());
if (existingVehicle == null) {
restClient.post("/couriers/" + courierId + "/vehicles", vehicle, LinkedHashMap.class);
} else {
restClient.put("/couriers/" + courierId + "/vehicles/" + existingVehicle.id(), vehicle);
}
loadVehicles();
vehicleGrid.setItems(vehicles);
vehicleGrid.asSingleSelect().clear();
refreshVehicleActionState();
dialog.close();
} catch (Exception ex) {
Notification.show("Fehler: " + ex.getMessage(), 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
});
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button cancelButton = new Button("Abbrechen", e -> dialog.close());
HorizontalLayout footer = new HorizontalLayout(saveButton, cancelButton);
VerticalLayout content = new VerticalLayout(vehicleSidField, vehicleTypeField, footer);
content.setPadding(false);
content.setSpacing(true);
dialog.add(content);
dialog.open();
}
private void deleteSelectedVehicle() {
if (!sessionData.canManageCouriers()) {
return;
}
VehicleRow selectedVehicle = vehicleGrid.asSingleSelect().getValue();
if (selectedVehicle == null || courierId == null) {
return;
}
try {
restClient.delete("/couriers/" + courierId + "/vehicles/" + selectedVehicle.id());
loadVehicles();
vehicleGrid.setItems(vehicles);
vehicleGrid.asSingleSelect().clear();
refreshVehicleActionState();
Notification.show("Fahrzeug geloescht.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} catch (Exception e) {
Notification.show("Fehler: " + e.getMessage(), 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadCourier() {
try {
@SuppressWarnings("unchecked")
Map<String, Object> courier = restClient.get("/couriers/" + courierId, LinkedHashMap.class);
if (courier != null) {
eidField.setValue(str(courier.get("eid")));
sidField.setValue(str(courier.get("sid")));
String available = str(courier.get("available"));
if (!available.isBlank()) {
availableField.setValue(available);
}
mobilePdaField.setValue(str(courier.get("mobilePda")));
trackingZipcodeField.setValue(str(courier.get("locationZipcode")));
trackingGpsTimeField.setValue(dateTimeText(courier.get("gpsTime")));
trackingGpsTypeField.setValue(gpsTypeLabel(toInteger(courier.get("gpsType"))));
trackingGpsCoordinatesField.setValue(trackingCoordinates(courier));
trackingAvailableTimeField.setValue(dateTimeText(courier.get("availableTime")));
compField.setValue(str(courier.get("companyName")));
comp2Field.setValue(str(courier.get("companyName2")));
hsnoField.setValue(str(courier.get("hsno")));
streetField.setValue(str(courier.get("street")));
zipcodeField.setValue(str(courier.get("zipcode")));
cityField.setValue(str(courier.get("city")));
ilnField.setValue(str(courier.get("iln")));
taxIdField.setValue(str(courier.get("taxIdNo")));
accountField.setValue(str(courier.get("account")));
nameField.setValue(str(courier.get("name")));
firstnameField.setValue(str(courier.get("firstname")));
emailField.setValue(str(courier.get("email")));
phoneField.setValue(str(courier.get("phone")));
phone2Field.setValue(str(courier.get("phone2")));
faxField.setValue(str(courier.get("fax")));
countryField.setValue(str(courier.get("country")));
groupField.setValue(selectGroups(str(courier.get("group"))));
filterField.setValue(selectFilters(str(courier.get("filter"))));
if (courier.get("birthdate") != null) {
birthdateField.setValue(java.time.LocalDate.parse(courier.get("birthdate").toString()));
}
}
} catch (Exception e) {
Notification.show("Fehler: " + e.getMessage());
}
}
private void loadVehicleTypes() {
try {
List<LinkedHashMap<String, Object>> result = restClient.getList(
"/metatypes/vehicletypes",
new ParameterizedTypeReference<>() {}
);
for (Map<String, Object> row : result) {
Integer id = toInteger(row.get("id"));
if (id != null) {
vehicleTypes.add(new VehicleTypeOption(id, str(row.get("value"))));
}
}
} catch (Exception e) {
Notification.show("Fahrzeugtypen konnten nicht geladen werden: " + e.getMessage(),
5000, Notification.Position.MIDDLE).addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadGroupFilterOptions() {
if (sessionData.getHeadquartersId() == null) {
return;
}
try {
groupOptions.clear();
List<LinkedHashMap<String, Object>> groups = restClient.getList(
"/group-filter-admin/groups?hqId=" + sessionData.getHeadquartersId(),
new ParameterizedTypeReference<>() {}
);
for (Map<String, Object> row : groups) {
Long id = toLong(row.get("id"));
if (id != null) {
groupOptions.add(new GroupOption(id, str(row.get("name"))));
}
}
groupField.setItems(groupOptions);
filterOptions.clear();
List<LinkedHashMap<String, Object>> filters = restClient.getList(
"/group-filter-admin/filters?hqId=" + sessionData.getHeadquartersId(),
new ParameterizedTypeReference<>() {}
);
for (Map<String, Object> row : filters) {
String shortCode = str(row.get("shortCode"));
if (!shortCode.isBlank()) {
filterOptions.add(new FilterOption(shortCode, str(row.get("text"))));
}
}
filterField.setItems(filterOptions);
} catch (Exception e) {
Notification.show("Gruppen- und Filterkatalog konnte nicht geladen werden: " + e.getMessage(),
5000, Notification.Position.MIDDLE).addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadVehicles() {
vehicles.clear();
if (courierId == null) {
return;
}
try {
List<LinkedHashMap<String, Object>> result = restClient.getList(
"/couriers/" + courierId + "/vehicles",
new ParameterizedTypeReference<>() {}
);
for (Map<String, Object> row : result) {
Long id = toLong(row.get("id"));
Integer vehicleTypeId = toInteger(row.get("vehicleTypeId"));
vehicles.add(new VehicleRow(
id,
str(row.get("sid")),
vehicleTypeId,
findVehicleTypeLabel(vehicleTypeId)
));
}
} catch (Exception e) {
Notification.show("Kurierfahrzeuge konnten nicht geladen werden: " + e.getMessage(),
5000, Notification.Position.MIDDLE).addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void save() {
if (!sessionData.canManageCouriers()) {
Notification.show("Keine Berechtigung zum Bearbeiten von Kurieren.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
try {
boolean createdNow = isNew;
Map<String, Object> request = new HashMap<>();
Map<String, Object> courier = new HashMap<>();
courier.put("headquartersId", sessionData.getHeadquartersId());
courier.put("eid", eidField.getValue());
courier.put("sid", sidField.getValue());
courier.put("available", availableField.getValue());
courier.put("mobilePda", mobilePdaField.getValue());
courier.put("group", toGroupString(groupField.getValue()));
courier.put("filter", toFilterString(filterField.getValue()));
request.put("courier", courier);
Map<String, Object> company = new HashMap<>();
company.put("comp", compField.getValue());
company.put("comp2", comp2Field.getValue());
company.put("hsno", hsnoField.getValue());
company.put("iln", ilnField.getValue());
company.put("taxIdNo", taxIdField.getValue());
request.put("company", company);
Map<String, Object> address = new HashMap<>();
address.put("street", streetField.getValue());
address.put("zipcode", zipcodeField.getValue());
address.put("city", cityField.getValue());
address.put("country", countryField.getValue());
request.put("address", address);
Map<String, Object> user = new HashMap<>();
user.put("account", accountField.getValue());
user.put("name", nameField.getValue());
user.put("firstname", firstnameField.getValue());
user.put("email", emailField.getValue());
user.put("phone", phoneField.getValue());
user.put("phone2", phone2Field.getValue());
user.put("fax", faxField.getValue());
user.put("birthdate", birthdateField.getValue());
user.put("country", countryField.getValue());
request.put("user", user);
if (isNew) {
request.put("password", passwordField.getValue());
@SuppressWarnings("unchecked")
Map<String, Object> created = restClient.post("/couriers", request, LinkedHashMap.class);
courierId = created != null ? toLong(created.get("id")) : null;
isNew = false;
} else {
restClient.put("/couriers/" + courierId, request);
}
Notification.show("Kurier gespeichert!", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
if (createdNow && courierId != null && getUI().isPresent()) {
getUI().get().navigate("couriers/" + courierId);
}
} catch (Exception e) {
Notification.show("Fehler: " + e.getMessage(), 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void refreshVehicleActionState() {
VehicleRow selectedVehicle = vehicleGrid.asSingleSelect().getValue();
boolean editable = sessionData.canManageCouriers() && courierId != null && !isNew;
addVehicleButton.setEnabled(editable);
editVehicleButton.setEnabled(editable && selectedVehicle != null);
deleteVehicleButton.setEnabled(editable && selectedVehicle != null);
}
private void resetState() {
courierId = null;
isNew = true;
vehicleTypes.clear();
vehicles.clear();
groupOptions.clear();
filterOptions.clear();
compField.clear();
comp2Field.clear();
hsnoField.clear();
streetField.clear();
zipcodeField.clear();
cityField.clear();
ilnField.clear();
taxIdField.clear();
eidField.clear();
sidField.clear();
availableField.clear();
mobilePdaField.clear();
trackingZipcodeField.clear();
trackingGpsTimeField.clear();
trackingGpsTypeField.clear();
trackingGpsCoordinatesField.clear();
trackingAvailableTimeField.clear();
groupField.clear();
groupField.setItems(groupOptions);
filterField.clear();
filterField.setItems(filterOptions);
accountField.clear();
nameField.clear();
firstnameField.clear();
emailField.clear();
phoneField.clear();
phone2Field.clear();
faxField.clear();
birthdateField.clear();
countryField.clear();
passwordField.clear();
vehicleGrid.setItems(vehicles);
vehicleGrid.asSingleSelect().clear();
refreshVehicleActionState();
}
private VehicleTypeOption findVehicleType(Integer typeId) {
if (typeId == null) {
return null;
}
for (VehicleTypeOption option : vehicleTypes) {
if (option.id().equals(typeId)) {
return option;
}
}
return null;
}
private String findVehicleTypeLabel(Integer typeId) {
VehicleTypeOption option = findVehicleType(typeId);
return option != null ? option.label() : "";
}
private LinkedHashSet<GroupOption> selectGroups(String value) {
Set<Long> selectedIds = parseLongTokens(value);
LinkedHashSet<GroupOption> selected = new LinkedHashSet<>();
for (GroupOption option : groupOptions) {
if (selectedIds.contains(option.id())) {
selected.add(option);
}
}
return selected;
}
private LinkedHashSet<FilterOption> selectFilters(String value) {
Set<String> selectedCodes = parseStringTokens(value);
LinkedHashSet<FilterOption> selected = new LinkedHashSet<>();
for (FilterOption option : filterOptions) {
if (selectedCodes.contains(option.shortCode())) {
selected.add(option);
}
}
return selected;
}
private String toGroupString(Set<GroupOption> selected) {
if (selected == null || selected.isEmpty()) {
return "";
}
return "," + selected.stream()
.map(GroupOption::id)
.sorted()
.map(String::valueOf)
.reduce((left, right) -> left + "," + right)
.orElse("") + ",";
}
private String toFilterString(Set<FilterOption> selected) {
if (selected == null || selected.isEmpty()) {
return "";
}
return "," + selected.stream()
.map(FilterOption::shortCode)
.sorted(String::compareToIgnoreCase)
.reduce((left, right) -> left + "," + right)
.orElse("") + ",";
}
private Set<Long> parseLongTokens(String value) {
Set<Long> result = new LinkedHashSet<>();
if (value == null || value.isBlank()) {
return result;
}
for (String token : value.split(",")) {
if (token == null || token.isBlank()) {
continue;
}
try {
result.add(Long.parseLong(token.trim()));
} catch (NumberFormatException ignored) {
}
}
return result;
}
private Set<String> parseStringTokens(String value) {
Set<String> result = new LinkedHashSet<>();
if (value == null || value.isBlank()) {
return result;
}
for (String token : value.split(",")) {
if (token != null && !token.isBlank()) {
result.add(token.trim());
}
}
return result;
}
private Long toLong(Object obj) {
if (obj == null) {
return null;
}
if (obj instanceof Number number) {
return number.longValue();
}
try {
return Long.parseLong(obj.toString());
} catch (NumberFormatException e) {
return null;
}
}
private Integer toInteger(Object obj) {
if (obj == null) {
return null;
}
if (obj instanceof Number number) {
return number.intValue();
}
try {
return Integer.parseInt(obj.toString());
} catch (NumberFormatException e) {
return null;
}
}
private String str(Object obj) {
return obj != null ? obj.toString() : "";
}
private String dateTimeText(Object obj) {
String value = str(obj);
return value.isBlank() ? "" : value.replace("T", " ");
}
private String trackingCoordinates(Map<String, Object> courier) {
String latitude = str(courier.get("gpsLatitude"));
String longitude = str(courier.get("gpsLongitude"));
if (latitude.isBlank() || longitude.isBlank()) {
return "";
}
return latitude + ", " + longitude;
}
private String gpsTypeLabel(Integer gpsType) {
if (gpsType == null) {
return "";
}
return switch (gpsType) {
case 0 -> "unbestimmt";
case 1 -> "LBS";
case 2 -> "GPS";
case 3 -> "Network";
case 9 -> "Ortung aus";
default -> "Typ " + gpsType;
};
}
private record VehicleTypeOption(Integer id, String label) { }
private record VehicleRow(Long id, String sid, Integer vehicleTypeId, String vehicleTypeName) { }
private record GroupOption(Long id, String label) { }
private record FilterOption(String shortCode, String label) { }
}

View File

@@ -0,0 +1,401 @@
package de.votian.web.view;
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.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.H3;
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.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.StreamResource;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Route(value = "courier-invoices", layout = MainLayout.class)
@PageTitle("Votian - Kurierauftraege")
public class CourierInvoiceView extends VerticalLayout {
private final RestClientService restClient;
private final DatePicker fromDate = new DatePicker("Von");
private final DatePicker toDate = new DatePicker("Bis");
private final ComboBox<HistoryMode> modeField = new ComboBox<>("Ansicht");
private final Span totalJobs = new Span("-");
private final Span totalRevenue = new Span("-");
private final Span totalCourierPrice = new Span("-");
private final Grid<CourierRow> grid = new Grid<>(CourierRow.class, false);
private final Anchor csvExportLink = new Anchor();
private final Anchor pdfExportLink = new Anchor();
private final Button csvExportButton = new Button("CSV exportieren");
private final Button pdfExportButton = new Button("PDF exportieren");
private final Span detailJob = new Span("-");
private final Span detailCustomer = new Span("-");
private final Span detailRoute = new Span("-");
private final Span detailTime = new Span("-");
private final Span detailStatus = new Span("-");
private final Pre invoiceText = new Pre();
private CourierRow selectedRow;
public CourierInvoiceView(RestClientService restClient) {
this.restClient = restClient;
setSizeFull();
setPadding(true);
setSpacing(true);
configureFilters();
configureGrid();
configureExportLinks();
configureDetails();
HorizontalLayout filterBar = buildFilterBar();
HorizontalLayout cards = buildCards();
VerticalLayout gridPanel = buildGridPanel();
VerticalLayout detailPanel = buildDetailPanel();
HorizontalLayout workspace = new HorizontalLayout(gridPanel, detailPanel);
workspace.setSizeFull();
workspace.expand(gridPanel);
add(new H3("Kurierauftraege"), filterBar, cards, workspace);
expand(workspace);
loadRows();
}
private void configureFilters() {
fromDate.setValue(LocalDate.now().withDayOfMonth(1));
toDate.setValue(LocalDate.now());
modeField.setItems(HistoryMode.values());
modeField.setItemLabelGenerator(HistoryMode::label);
modeField.setValue(HistoryMode.FINISHED);
fromDate.addValueChangeListener(event -> updateExportLinks());
toDate.addValueChangeListener(event -> updateExportLinks());
modeField.addValueChangeListener(event -> updateExportLinks());
}
private void configureGrid() {
grid.addColumn(CourierRow::jobId).setHeader("Auftrag").setAutoWidth(true);
grid.addColumn(CourierRow::customerName).setHeader("Kunde").setFlexGrow(1);
grid.addColumn(CourierRow::pickupName).setHeader("Abholung").setFlexGrow(1);
grid.addColumn(CourierRow::deliveryName).setHeader("Zustellung").setFlexGrow(1);
grid.addColumn(CourierRow::displayTime).setHeader("Zeit").setAutoWidth(true);
grid.addColumn(CourierRow::statusLabel).setHeader("Status").setAutoWidth(true);
grid.addColumn(row -> formatAmount(row.totalPrice())).setHeader("Umsatz").setAutoWidth(true);
grid.addColumn(row -> formatAmount(row.courierPrice())).setHeader("Kurierpreis").setAutoWidth(true);
grid.addColumn(row -> row.exported() ? "Ja" : "Nein").setHeader("Export").setAutoWidth(true);
grid.setSizeFull();
grid.asSingleSelect().addValueChangeListener(event -> {
selectedRow = event.getValue();
updateDetail();
});
}
private void configureExportLinks() {
csvExportButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
pdfExportButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
csvExportLink.add(csvExportButton);
pdfExportLink.add(pdfExportButton);
csvExportLink.getElement().setAttribute("download", true);
pdfExportLink.getElement().setAttribute("download", true);
updateExportLinks();
}
private void configureDetails() {
invoiceText.setWidthFull();
invoiceText.getStyle()
.set("background", "var(--lumo-contrast-5pct)")
.set("padding", "var(--lumo-space-m)")
.set("border-radius", "var(--lumo-border-radius-m)")
.set("white-space", "pre-wrap")
.set("margin", "0");
clearDetail();
}
private HorizontalLayout buildFilterBar() {
Button reloadButton = new Button("Neu laden", event -> loadRows());
reloadButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
HorizontalLayout filterBar = new HorizontalLayout(fromDate, toDate, modeField, reloadButton, csvExportLink, pdfExportLink);
filterBar.setAlignItems(Alignment.BASELINE);
return filterBar;
}
private HorizontalLayout buildCards() {
HorizontalLayout cards = new HorizontalLayout(
createCard("Auftraege", totalJobs),
createCard("Umsatz", totalRevenue),
createCard("Kurierpreis", totalCourierPrice)
);
cards.setWidthFull();
return cards;
}
private VerticalLayout buildGridPanel() {
VerticalLayout panel = new VerticalLayout(new H4("Historie"), grid);
panel.setPadding(false);
panel.setSpacing(true);
panel.setSizeFull();
panel.expand(grid);
return panel;
}
private VerticalLayout buildDetailPanel() {
VerticalLayout panel = new VerticalLayout();
panel.setPadding(true);
panel.setSpacing(true);
panel.setWidth("420px");
panel.getStyle()
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-l)");
panel.add(
new H4("Details"),
detailLine("Auftrag", detailJob),
detailLine("Kunde", detailCustomer),
detailLine("Route", detailRoute),
detailLine("Zeit", detailTime),
detailLine("Status", detailStatus),
new H4("Rechnungstext"),
invoiceText
);
return panel;
}
private HorizontalLayout detailLine(String label, Span value) {
Span caption = new Span(label + ":");
caption.getStyle().set("font-weight", "600");
return new HorizontalLayout(caption, value);
}
private VerticalLayout createCard(String title, Span value) {
VerticalLayout card = new VerticalLayout();
card.setPadding(true);
card.setSpacing(false);
card.getStyle()
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-l)")
.set("min-width", "180px");
H3 headline = new H3(title);
headline.getStyle().set("margin", "0");
value.getStyle().set("font-size", "var(--lumo-font-size-xl)").set("font-weight", "700");
card.add(headline, value);
return card;
}
private void loadRows() {
try {
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/invoices/courier-items?from=" + fromDate.getValue()
+ "&to=" + toDate.getValue()
+ "&mode=" + modeField.getValue().apiValue(),
new ParameterizedTypeReference<>() {}
);
List<CourierRow> rows = new ArrayList<>();
BigDecimal revenueSum = BigDecimal.ZERO;
BigDecimal courierSum = BigDecimal.ZERO;
for (Map<String, Object> row : data) {
BigDecimal totalPrice = toBigDecimal(row.get("totalPrice"));
BigDecimal courierPrice = toBigDecimal(row.get("courierPrice"));
rows.add(new CourierRow(
toLong(row.get("jobId")),
str(row.get("customerName")),
str(row.get("pickupName")),
str(row.get("deliveryName")),
str(row.get("orderTime")),
str(row.get("finishTime")),
toInteger(row.get("status")),
toInteger(row.get("incomplete")),
Boolean.TRUE.equals(row.get("exported")) || "true".equalsIgnoreCase(str(row.get("exported"))),
totalPrice,
courierPrice,
str(row.get("invoiceText"))
));
if (totalPrice != null) {
revenueSum = revenueSum.add(totalPrice);
}
if (courierPrice != null) {
courierSum = courierSum.add(courierPrice);
}
}
grid.setItems(rows);
totalJobs.setText(String.valueOf(rows.size()));
totalRevenue.setText(formatAmount(revenueSum));
totalCourierPrice.setText(formatAmount(courierSum));
grid.asSingleSelect().clear();
if (!rows.isEmpty()) {
grid.select(rows.get(0));
} else {
clearDetail();
}
updateExportLinks();
} catch (Exception e) {
Notification.show("Kurierhistorie konnte nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void updateExportLinks() {
StreamResource csv = new StreamResource(
"courier_jobs_" + modeField.getValue().apiValue() + "_" + fromDate.getValue() + "_" + toDate.getValue() + ".csv",
() -> new ByteArrayInputStream(restClient.getBytes(
"/invoices/courier-export/csv?from=" + fromDate.getValue()
+ "&to=" + toDate.getValue()
+ "&mode=" + modeField.getValue().apiValue()
))
);
StreamResource pdf = new StreamResource(
"courier_jobs_" + modeField.getValue().apiValue() + "_" + fromDate.getValue() + "_" + toDate.getValue() + ".pdf",
() -> new ByteArrayInputStream(restClient.getBytes(
"/invoices/courier-export/pdf?from=" + fromDate.getValue()
+ "&to=" + toDate.getValue()
+ "&mode=" + modeField.getValue().apiValue()
))
);
csvExportLink.setHref(csv);
pdfExportLink.setHref(pdf);
}
private void updateDetail() {
if (selectedRow == null) {
clearDetail();
return;
}
detailJob.setText(String.valueOf(selectedRow.jobId()));
detailCustomer.setText(selectedRow.customerName());
detailRoute.setText(selectedRow.pickupName() + " -> " + selectedRow.deliveryName());
detailTime.setText(selectedRow.displayTime());
detailStatus.setText(selectedRow.statusLabel());
invoiceText.setText(selectedRow.invoiceText().isBlank() ? "(kein Rechnungstext)" : selectedRow.invoiceText());
}
private void clearDetail() {
detailJob.setText("-");
detailCustomer.setText("-");
detailRoute.setText("-");
detailTime.setText("-");
detailStatus.setText("-");
invoiceText.setText("(kein Auftrag ausgewaehlt)");
}
private String formatAmount(BigDecimal value) {
return value != null ? value.toPlainString() : "-";
}
private BigDecimal toBigDecimal(Object value) {
if (value == null) {
return null;
}
if (value instanceof BigDecimal decimal) {
return decimal;
}
try {
return new BigDecimal(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.longValue();
}
try {
return Long.parseLong(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
private Integer toInteger(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.intValue();
}
try {
return Integer.parseInt(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
private String str(Object value) {
return value != null ? value.toString() : "";
}
private record CourierRow(Long jobId, String customerName, String pickupName, String deliveryName,
String orderTime, String finishTime, Integer status, Integer incomplete,
boolean exported, BigDecimal totalPrice, BigDecimal courierPrice,
String invoiceText) {
private String displayTime() {
String value = finishTime != null && !finishTime.isBlank() ? finishTime : orderTime;
return value != null ? value.replace("T", " ") : "-";
}
private String statusLabel() {
String base = switch (status != null ? status : -1) {
case 0 -> "Blockiert";
case 1 -> "Zugewiesen";
case 2 -> "In Zustellung";
case 8 -> "Zugestellt";
case 9 -> "Abgeschlossen";
case 10 -> "Storniert";
default -> status != null ? String.valueOf(status) : "-";
};
if (exported) {
return base + ", exportiert";
}
if (incomplete != null && incomplete > 0) {
return base + ", unvollstaendig";
}
return base;
}
}
private enum HistoryMode {
FINISHED("Erledigt", "finished"),
RUNNING("Laufend", "running");
private final String label;
private final String apiValue;
HistoryMode(String label, String apiValue) {
this.label = label;
this.apiValue = apiValue;
}
private String label() {
return label;
}
private String apiValue() {
return apiValue;
}
}
}

View File

@@ -0,0 +1,111 @@
package de.votian.web.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import java.util.*;
@Route(value = "couriers", layout = MainLayout.class)
@PageTitle("Votian - Kuriere")
public class CourierListView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final Grid<Map<String, Object>> grid;
public CourierListView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setSizeFull();
setPadding(true);
TextField searchField = new TextField();
searchField.setPlaceholder("Kurier suchen...");
searchField.setPrefixComponent(VaadinIcon.SEARCH.create());
searchField.setValueChangeMode(ValueChangeMode.LAZY);
searchField.addValueChangeListener(e -> search(e.getValue()));
Button newButton = new Button("Neuer Kurier", VaadinIcon.PLUS.create(), e ->
getUI().ifPresent(ui -> ui.navigate(CourierDetailView.class)));
newButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
newButton.setVisible(sessionData.canManageCouriers());
HorizontalLayout toolbar = new HorizontalLayout(searchField, newButton);
toolbar.setWidthFull();
toolbar.expand(searchField);
grid = new Grid<>();
grid.addColumn(m -> m.get("eid")).setHeader("Kurier-Nr.").setSortable(true).setAutoWidth(true);
grid.addColumn(m -> m.get("sid")).setHeader("SID").setAutoWidth(true);
grid.addColumn(m -> m.get("companyName")).setHeader("Firma").setFlexGrow(2);
grid.addColumn(m -> m.get("contact")).setHeader("Ansprechpartner").setFlexGrow(1);
grid.addColumn(m -> m.get("available")).setHeader("Verfuegbar").setAutoWidth(true);
grid.setSizeFull();
grid.addItemClickListener(e -> {
Object id = e.getItem().get("id");
if (id != null) {
getUI().ifPresent(ui -> ui.navigate("couriers/" + id));
}
});
add(toolbar, grid);
loadCouriers();
}
private void loadCouriers() {
if (sessionData.getHeadquartersId() == null) return;
try {
Object[] couriers = restClient.get("/couriers/hq/" + sessionData.getHeadquartersId(), Object[].class);
if (couriers != null) {
List<Map<String, Object>> items = new ArrayList<>();
for (Object c : couriers) {
if (c instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) c;
items.add(map);
}
}
grid.setItems(items);
}
} catch (Exception e) {
Notification.show("Fehler: " + e.getMessage());
}
}
private void search(String term) {
if (term == null || term.trim().isEmpty()) {
loadCouriers();
return;
}
if (sessionData.getHeadquartersId() == null) return;
try {
Object[] results = restClient.get(
"/couriers/search?hqId=" + sessionData.getHeadquartersId() + "&term=" + term, Object[].class);
if (results != null) {
List<Map<String, Object>> items = new ArrayList<>();
for (Object c : results) {
if (c instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) c;
items.add(map);
}
}
grid.setItems(items);
}
} catch (Exception e) {
Notification.show("Suchfehler: " + e.getMessage());
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
package de.votian.web.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import java.util.*;
@Route(value = "customers", layout = MainLayout.class)
@PageTitle("Votian - Kunden")
public class CustomerListView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final Grid<Map<String, Object>> grid;
public CustomerListView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setSizeFull();
setPadding(true);
// Toolbar
TextField searchField = new TextField();
searchField.setPlaceholder("Kunde suchen...");
searchField.setPrefixComponent(VaadinIcon.SEARCH.create());
searchField.setValueChangeMode(ValueChangeMode.LAZY);
searchField.addValueChangeListener(e -> search(e.getValue()));
Button newButton = new Button("Neuer Kunde", VaadinIcon.PLUS.create(), e -> {
getUI().ifPresent(ui -> ui.navigate(CustomerDetailView.class));
});
newButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
newButton.setVisible(sessionData.canManageCustomers());
HorizontalLayout toolbar = new HorizontalLayout(searchField, newButton);
toolbar.setWidthFull();
toolbar.expand(searchField);
// Grid
grid = new Grid<>();
grid.addColumn(m -> m.get("eid")).setHeader("Kunden-Nr.").setSortable(true).setAutoWidth(true);
grid.addColumn(m -> m.get("companyName")).setHeader("Firma").setSortable(true).setFlexGrow(2);
grid.addColumn(m -> m.get("contact")).setHeader("Ansprechpartner").setFlexGrow(1);
grid.addColumn(m -> m.get("email")).setHeader("E-Mail").setFlexGrow(1);
grid.addColumn(m -> m.get("phone")).setHeader("Telefon").setAutoWidth(true);
grid.setSizeFull();
grid.addItemClickListener(e -> {
Object id = e.getItem().get("id");
if (id != null) {
getUI().ifPresent(ui -> ui.navigate("customers/" + id));
}
});
add(toolbar, grid);
loadCustomers();
}
private void loadCustomers() {
if (sessionData.getHeadquartersId() == null) return;
try {
Object[] customers = restClient.get("/customers/hq/" + sessionData.getHeadquartersId(), Object[].class);
if (customers != null) {
List<Map<String, Object>> items = new ArrayList<>();
for (Object c : customers) {
if (c instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) c;
items.add(map);
}
}
grid.setItems(items);
}
} catch (Exception e) {
Notification.show("Fehler beim Laden der Kunden: " + e.getMessage());
}
}
private void search(String term) {
if (term == null || term.trim().isEmpty()) {
loadCustomers();
return;
}
if (sessionData.getHeadquartersId() == null) return;
try {
Object[] results = restClient.get(
"/customers/search?hqId=" + sessionData.getHeadquartersId() + "&term=" + term, Object[].class);
if (results != null) {
List<Map<String, Object>> items = new ArrayList<>();
for (Object c : results) {
if (c instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) c;
items.add(map);
}
}
grid.setItems(items);
}
} catch (Exception e) {
Notification.show("Suchfehler: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,102 @@
package de.votian.web.view;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.router.RouteAlias;
import de.votian.web.model.SessionData;
import de.votian.web.service.ParameterProvider;
import de.votian.web.service.RestClientService;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
@Route(value = "dashboard", layout = MainLayout.class)
@RouteAlias(value = "", layout = MainLayout.class)
@PageTitle("Votian - Dashboard")
public class DashboardView extends VerticalLayout {
public DashboardView(RestClientService restClient, SessionData sessionData, ParameterProvider parameterProvider) {
setPadding(true);
setSpacing(true);
H2 welcome = new H2("Willkommen, " + sessionData.getDisplayName());
Paragraph dateInfo = new Paragraph(LocalDate.now().format(
DateTimeFormatter.ofPattern("EEEE, dd. MMMM yyyy", java.util.Locale.GERMAN)));
// Info cards
HorizontalLayout cards = new HorizontalLayout();
cards.setWidthFull();
cards.setSpacing(true);
// Load HQ info
String hqName = "Niederlassung";
if (sessionData.getHeadquartersId() != null) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> hq = restClient.get("/headquarters/" + sessionData.getHeadquartersId(),
LinkedHashMap.class);
if (hq != null) {
hqName = (String) hq.getOrDefault("name", "Niederlassung");
}
} catch (Exception ignored) {}
}
cards.add(createInfoCard("Niederlassung", hqName));
String userTypeLabel;
switch (sessionData.getUserType() != null ? sessionData.getUserType() : 0) {
case 1: userTypeLabel = "Mitarbeiter (HQ)"; break;
case 2: userTypeLabel = "Kundenbenutzer"; break;
case 3: userTypeLabel = "Kurier"; break;
case 4: userTypeLabel = "Lager"; break;
default: userTypeLabel = "Unbekannt";
}
cards.add(createInfoCard("Benutzertyp", userTypeLabel));
// Today's jobs count
try {
Object[] todayJobs = restClient.get("/jobs/today", Object[].class);
cards.add(createInfoCard("Heutige Auftraege", String.valueOf(todayJobs != null ? todayJobs.length : 0)));
} catch (Exception e) {
cards.add(createInfoCard("Heutige Auftraege", "N/A"));
}
// Parameter info
if (parameterProvider.isLoaded()) {
cards.add(createInfoCard("Parameter", String.valueOf(parameterProvider.size())));
}
add(welcome, dateInfo, cards);
}
private VerticalLayout createInfoCard(String title, String value) {
VerticalLayout card = new VerticalLayout();
card.setPadding(true);
card.setSpacing(false);
card.getStyle()
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-l)")
.set("background", "var(--lumo-base-color)")
.set("box-shadow", "var(--lumo-box-shadow-s)")
.set("min-width", "200px");
H3 cardTitle = new H3(title);
cardTitle.getStyle().set("margin", "0").set("font-size", "var(--lumo-font-size-s)")
.set("color", "var(--lumo-secondary-text-color)");
Span cardValue = new Span(value);
cardValue.getStyle().set("font-size", "var(--lumo-font-size-xl)")
.set("font-weight", "bold").set("color", "#1b12b9");
card.add(cardTitle, cardValue);
return card;
}
}

View File

@@ -0,0 +1,800 @@
package de.votian.web.view;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.H4;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.tabs.Tab;
import com.vaadin.flow.component.tabs.Tabs;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;
import java.math.BigDecimal;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Route(value = "disposition", layout = MainLayout.class)
@PageTitle("Votian - Disposition")
public class DispositionView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final DatePicker dayField = new DatePicker("Tag");
private final Span totalJobs = new Span("-");
private final Span assignedJobs = new Span("-");
private final Span unassignedJobs = new Span("-");
private final Span availableVehicles = new Span("-");
private final Grid<JobRow> jobGrid = new Grid<>(JobRow.class, false);
private final Grid<VehicleRow> vehicleGrid = new Grid<>(VehicleRow.class, false);
private final Grid<StationRow> stationGrid = new Grid<>(StationRow.class, false);
private final Button assignButton = new Button("Zuordnen");
private final Button clearAssignmentButton = new Button("Zuordnung loesen");
private final Button openJobButton = new Button("Auftrag oeffnen");
private final Span detailJob = new Span("-");
private final Span detailCustomer = new Span("-");
private final Span detailCostCenter = new Span("-");
private final Span detailStatus = new Span("-");
private final Span detailCourier = new Span("-");
private final Span detailVehicle = new Span("-");
private final Span detailPickup = new Span("-");
private final Span detailDelivery = new Span("-");
private final Anchor routeLink = createLink("Route in Google Maps");
private final Anchor pickupMapLink = createLink("Abholung auf Karte");
private final Anchor deliveryMapLink = createLink("Zustellung auf Karte");
private final Span stationJob = new Span("-");
private final Span stationCustomer = new Span("-");
private final Span stationName = new Span("-");
private final Span stationStatus = new Span("-");
private final Span stationAddress = new Span("-");
private final Span stationCourier = new Span("-");
private final Span stationRemark = new Span("-");
private final Span stationPhone = new Span("-");
private final Anchor stationMapLink = createLink("Station auf Karte");
private final Anchor stationRouteLink = createLink("Auftragsroute");
private final List<JobRow> jobs = new ArrayList<>();
private final List<VehicleRow> vehicles = new ArrayList<>();
private final List<StationRow> stations = new ArrayList<>();
private final Map<Long, JobRow> jobsById = new HashMap<>();
private JobRow selectedJob;
private VehicleRow selectedVehicle;
private StationRow selectedStation;
public DispositionView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setSizeFull();
setPadding(true);
setSpacing(true);
dayField.setValue(LocalDate.now());
configureJobGrid();
configureVehicleGrid();
configureStationGrid();
configureActions();
clearJobDetail();
clearStationDetail();
Button reloadButton = new Button("Neu laden", event -> loadWorkspace(selectedJob != null ? selectedJob.jobId() : null));
reloadButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
HorizontalLayout filterBar = new HorizontalLayout(dayField, reloadButton);
filterBar.setAlignItems(Alignment.BASELINE);
HorizontalLayout cards = new HorizontalLayout(
createCard("Auftraege", totalJobs),
createCard("Zugeordnet", assignedJobs),
createCard("Offen", unassignedJobs),
createCard("Fahrzeuge", availableVehicles)
);
cards.setWidthFull();
VerticalLayout dispositionContent = buildDispositionContent();
VerticalLayout locatingContent = buildLocatingContent();
locatingContent.setVisible(false);
Tab dispositionTab = new Tab("Disposition");
Tab locatingTab = new Tab("Locating");
Tabs tabs = new Tabs(dispositionTab, locatingTab);
Map<Tab, Component> tabContent = Map.of(
dispositionTab, dispositionContent,
locatingTab, locatingContent
);
tabs.addSelectedChangeListener(event -> {
for (Component content : tabContent.values()) {
content.setVisible(false);
}
tabContent.get(event.getSelectedTab()).setVisible(true);
});
add(new H3("Disposition"), filterBar, cards, tabs, dispositionContent, locatingContent);
expand(dispositionContent, locatingContent);
loadWorkspace(null);
}
private void configureJobGrid() {
jobGrid.addColumn(JobRow::jobId).setHeader("Auftrag").setAutoWidth(true).setFrozen(true);
jobGrid.addColumn(row -> formatDateTime(row.orderTime())).setHeader("Zeit").setAutoWidth(true);
jobGrid.addColumn(JobRow::customerName).setHeader("Kunde").setFlexGrow(1);
jobGrid.addColumn(JobRow::pickupName).setHeader("Abholung").setFlexGrow(1);
jobGrid.addColumn(JobRow::deliveryName).setHeader("Zustellung").setFlexGrow(1);
jobGrid.addColumn(row -> statusLabel(row.status(), false)).setHeader("Status").setAutoWidth(true);
jobGrid.addColumn(JobRow::courierSid).setHeader("Kurier").setAutoWidth(true);
jobGrid.addColumn(JobRow::vehicleDisplay).setHeader("Fahrzeug").setAutoWidth(true);
jobGrid.addColumn(row -> formatAmount(row.totalPrice())).setHeader("Umsatz").setAutoWidth(true);
jobGrid.setSizeFull();
jobGrid.asSingleSelect().addValueChangeListener(event -> {
selectedJob = event.getValue();
refreshActionState();
updateJobDetail();
});
}
private void configureVehicleGrid() {
vehicleGrid.addColumn(VehicleRow::courierSid).setHeader("Kurier").setAutoWidth(true);
vehicleGrid.addColumn(VehicleRow::courierEid).setHeader("EID").setAutoWidth(true);
vehicleGrid.addColumn(VehicleRow::vehicleDisplay).setHeader("Fahrzeug").setAutoWidth(true);
vehicleGrid.addColumn(row -> vehicleTypeLabel(row.vehicleTypeId())).setHeader("Typ").setAutoWidth(true);
vehicleGrid.addColumn(row -> availabilityLabel(row.available())).setHeader("Verfuegbar").setAutoWidth(true);
vehicleGrid.addColumn(VehicleRow::assignedJobs).setHeader("Auftraege").setAutoWidth(true);
vehicleGrid.setWidthFull();
vehicleGrid.setMinHeight("260px");
vehicleGrid.asSingleSelect().addValueChangeListener(event -> {
selectedVehicle = event.getValue();
refreshActionState();
});
}
private void configureStationGrid() {
stationGrid.addColumn(StationRow::jobId).setHeader("Auftrag").setAutoWidth(true).setFrozen(true);
stationGrid.addColumn(StationRow::stopSort).setHeader("Stop").setAutoWidth(true);
stationGrid.addColumn(row -> formatDateTime(row.orderTime())).setHeader("Zeit").setAutoWidth(true);
stationGrid.addColumn(StationRow::customerName).setHeader("Kunde").setFlexGrow(1);
stationGrid.addColumn(StationRow::stopName).setHeader("Station").setFlexGrow(1);
stationGrid.addColumn(StationRow::addressLabel).setHeader("Adresse").setFlexGrow(1);
stationGrid.addColumn(row -> statusLabel(row.stopStatus(), true)).setHeader("Status").setAutoWidth(true);
stationGrid.addColumn(StationRow::courierSid).setHeader("Kurier").setAutoWidth(true);
stationGrid.addComponentColumn(row -> mapAnchor(row.mapUrl(), "Karte")).setHeader("Karte").setAutoWidth(true);
stationGrid.setSizeFull();
stationGrid.asSingleSelect().addValueChangeListener(event -> {
selectedStation = event.getValue();
updateStationDetail();
});
}
private void configureActions() {
assignButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
assignButton.addClickListener(event -> assignSelectedVehicle());
clearAssignmentButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
clearAssignmentButton.addClickListener(event -> clearSelectedAssignment());
openJobButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
openJobButton.addClickListener(event -> {
if (selectedJob != null && sessionData.canViewJobs()) {
getUI().ifPresent(ui -> ui.navigate("jobs/" + selectedJob.jobId()));
}
});
refreshActionState();
}
private VerticalLayout buildDispositionContent() {
VerticalLayout jobPanel = new VerticalLayout(new H4("Tagesauftraege"), jobGrid);
jobPanel.setPadding(false);
jobPanel.setSpacing(true);
jobPanel.setSizeFull();
jobPanel.expand(jobGrid);
HorizontalLayout actions = new HorizontalLayout(assignButton, clearAssignmentButton, openJobButton);
actions.setAlignItems(Alignment.BASELINE);
VerticalLayout vehiclePanel = new VerticalLayout(new H4("Fahrzeuge"), vehicleGrid);
vehiclePanel.setPadding(false);
vehiclePanel.setSpacing(true);
vehiclePanel.setWidthFull();
VerticalLayout detailPanel = buildJobDetailPanel();
VerticalLayout sideColumn = new VerticalLayout(actions, vehiclePanel, detailPanel);
sideColumn.setPadding(false);
sideColumn.setSpacing(true);
sideColumn.setWidth("520px");
sideColumn.setHeightFull();
sideColumn.expand(vehiclePanel);
HorizontalLayout workspace = new HorizontalLayout(jobPanel, sideColumn);
workspace.setSizeFull();
workspace.expand(jobPanel);
VerticalLayout content = new VerticalLayout(workspace);
content.setPadding(false);
content.setSpacing(false);
content.setSizeFull();
content.expand(workspace);
return content;
}
private VerticalLayout buildLocatingContent() {
VerticalLayout stationPanel = new VerticalLayout(new H4("Stationen"), stationGrid);
stationPanel.setPadding(false);
stationPanel.setSpacing(true);
stationPanel.setSizeFull();
stationPanel.expand(stationGrid);
VerticalLayout detailPanel = buildStationDetailPanel();
detailPanel.setWidth("520px");
HorizontalLayout workspace = new HorizontalLayout(stationPanel, detailPanel);
workspace.setSizeFull();
workspace.expand(stationPanel);
VerticalLayout content = new VerticalLayout(workspace);
content.setPadding(false);
content.setSpacing(false);
content.setSizeFull();
content.expand(workspace);
return content;
}
private VerticalLayout buildJobDetailPanel() {
VerticalLayout panel = new VerticalLayout();
panel.setPadding(true);
panel.setSpacing(true);
panel.getStyle()
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-l)");
panel.add(
new H4("Auftragsdetail"),
detailLine("Auftrag", detailJob),
detailLine("Kunde", detailCustomer),
detailLine("Kostenstelle", detailCostCenter),
detailLine("Status", detailStatus),
detailLine("Kurier", detailCourier),
detailLine("Fahrzeug", detailVehicle),
detailLine("Abholung", detailPickup),
detailLine("Zustellung", detailDelivery),
routeLink,
pickupMapLink,
deliveryMapLink
);
return panel;
}
private VerticalLayout buildStationDetailPanel() {
VerticalLayout panel = new VerticalLayout();
panel.setPadding(true);
panel.setSpacing(true);
panel.getStyle()
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-l)");
panel.add(
new H4("Stationsdetail"),
detailLine("Auftrag", stationJob),
detailLine("Kunde", stationCustomer),
detailLine("Station", stationName),
detailLine("Status", stationStatus),
detailLine("Adresse", stationAddress),
detailLine("Kurier", stationCourier),
detailLine("Telefon", stationPhone),
detailLine("Bemerkung", stationRemark),
stationMapLink,
stationRouteLink
);
return panel;
}
private HorizontalLayout detailLine(String label, Span value) {
Span caption = new Span(label + ":");
caption.getStyle().set("font-weight", "600");
HorizontalLayout line = new HorizontalLayout(caption, value);
line.setPadding(false);
line.setSpacing(true);
return line;
}
private VerticalLayout createCard(String title, Span value) {
VerticalLayout card = new VerticalLayout();
card.setPadding(true);
card.setSpacing(false);
card.getStyle()
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-l)")
.set("min-width", "180px");
H4 headline = new H4(title);
headline.getStyle().set("margin", "0");
value.getStyle().set("font-size", "var(--lumo-font-size-xl)").set("font-weight", "700");
card.add(headline, value);
return card;
}
private void loadWorkspace(Long preferredJobId) {
if (sessionData.getHeadquartersId() == null) {
return;
}
loadJobs(preferredJobId);
loadVehicles();
loadStations();
}
private void loadJobs(Long preferredJobId) {
try {
jobs.clear();
jobsById.clear();
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/disposition/jobs?hqId=" + sessionData.getHeadquartersId() + "&day=" + dayField.getValue(),
new ParameterizedTypeReference<>() {}
);
long assignedCount = 0L;
for (Map<String, Object> row : data) {
JobRow item = new JobRow(
toLong(row.get("jobId")),
toInteger(row.get("status")),
str(row.get("orderTime")),
str(row.get("customerName")),
str(row.get("costCenterName")),
str(row.get("pickupName")),
str(row.get("pickupStreet")),
str(row.get("pickupZipcode")),
str(row.get("pickupCity")),
str(row.get("deliveryName")),
str(row.get("deliveryStreet")),
str(row.get("deliveryZipcode")),
str(row.get("deliveryCity")),
toLong(row.get("courierId")),
str(row.get("courierSid")),
toInteger(row.get("vehicleTypeId")),
toLong(row.get("courierVehicleId")),
str(row.get("courierVehicleSid")),
toBigDecimal(row.get("totalPrice")),
Boolean.TRUE.equals(row.get("exported")) || "true".equalsIgnoreCase(str(row.get("exported")))
);
jobs.add(item);
jobsById.put(item.jobId(), item);
if (item.courierId() != null || item.courierVehicleId() != null) {
assignedCount++;
}
}
jobGrid.setItems(jobs);
totalJobs.setText(String.valueOf(jobs.size()));
assignedJobs.setText(String.valueOf(assignedCount));
unassignedJobs.setText(String.valueOf(Math.max(0, jobs.size() - assignedCount)));
selectedJob = null;
jobGrid.asSingleSelect().clear();
if (preferredJobId != null) {
jobs.stream()
.filter(job -> preferredJobId.equals(job.jobId()))
.findFirst()
.ifPresent(jobGrid::select);
}
if (jobGrid.asSingleSelect().getValue() == null && !jobs.isEmpty()) {
jobGrid.select(jobs.get(0));
}
if (jobs.isEmpty()) {
clearJobDetail();
}
} catch (Exception e) {
Notification.show("Disposition konnte nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadVehicles() {
try {
vehicles.clear();
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/disposition/vehicles?hqId=" + sessionData.getHeadquartersId() + "&day=" + dayField.getValue(),
new ParameterizedTypeReference<>() {}
);
long availableCount = 0L;
for (Map<String, Object> row : data) {
VehicleRow item = new VehicleRow(
toLong(row.get("courierId")),
str(row.get("courierSid")),
str(row.get("courierEid")),
str(row.get("available")),
toLong(row.get("courierVehicleId")),
str(row.get("courierVehicleSid")),
toInteger(row.get("vehicleTypeId")),
toLong(row.get("assignedJobs")) != null ? toLong(row.get("assignedJobs")) : 0L
);
vehicles.add(item);
if ("1".equals(item.available())) {
availableCount++;
}
}
vehicleGrid.setItems(vehicles);
availableVehicles.setText(String.valueOf(availableCount));
selectedVehicle = null;
vehicleGrid.asSingleSelect().clear();
refreshActionState();
} catch (Exception e) {
Notification.show("Fahrzeuge konnten nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadStations() {
try {
stations.clear();
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/disposition/stations?hqId=" + sessionData.getHeadquartersId() + "&day=" + dayField.getValue(),
new ParameterizedTypeReference<>() {}
);
for (Map<String, Object> row : data) {
stations.add(new StationRow(
toLong(row.get("jobId")),
str(row.get("orderTime")),
toInteger(row.get("stopSort")),
toInteger(row.get("stopStatus")),
str(row.get("customerName")),
str(row.get("commissionNo")),
str(row.get("stopName")),
str(row.get("street")),
str(row.get("zipcode")),
str(row.get("city")),
str(row.get("country")),
str(row.get("remark")),
str(row.get("phone")),
str(row.get("courierSid"))
));
}
stationGrid.setItems(stations);
selectedStation = null;
stationGrid.asSingleSelect().clear();
if (!stations.isEmpty()) {
stationGrid.select(stations.get(0));
} else {
clearStationDetail();
}
} catch (Exception e) {
Notification.show("Stationsdaten konnten nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void assignSelectedVehicle() {
if (!sessionData.canManageDisposition()) {
return;
}
if (selectedJob == null || selectedVehicle == null) {
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("courierId", selectedVehicle.courierId());
request.put("courierVehicleId", selectedVehicle.courierVehicleId());
restClient.put(
"/disposition/jobs/" + selectedJob.jobId() + "/assignment?hqId=" + sessionData.getHeadquartersId(),
request
);
loadWorkspace(selectedJob.jobId());
} catch (Exception e) {
Notification.show("Zuordnung konnte nicht gespeichert werden: " + e.getMessage(),
5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void clearSelectedAssignment() {
if (!sessionData.canManageDisposition()) {
return;
}
if (selectedJob == null) {
return;
}
try {
restClient.delete(
"/disposition/jobs/" + selectedJob.jobId() + "/assignment?hqId=" + sessionData.getHeadquartersId()
);
loadWorkspace(selectedJob.jobId());
} catch (Exception e) {
Notification.show("Zuordnung konnte nicht geloest werden: " + e.getMessage(),
5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void refreshActionState() {
assignButton.setEnabled(sessionData.canManageDisposition() && selectedJob != null && selectedVehicle != null);
clearAssignmentButton.setEnabled(sessionData.canManageDisposition() && selectedJob != null
&& (selectedJob.courierId() != null || selectedJob.courierVehicleId() != null));
openJobButton.setEnabled(sessionData.canViewJobs() && selectedJob != null);
}
private void updateJobDetail() {
if (selectedJob == null) {
clearJobDetail();
return;
}
detailJob.setText(String.valueOf(selectedJob.jobId()));
detailCustomer.setText(selectedJob.customerName());
detailCostCenter.setText(selectedJob.costCenterName());
detailStatus.setText(statusLabel(selectedJob.status(), false));
detailCourier.setText(selectedJob.courierSid().isBlank() ? "-" : selectedJob.courierSid());
detailVehicle.setText(selectedJob.vehicleDisplay());
detailPickup.setText(selectedJob.pickupAddressLabel());
detailDelivery.setText(selectedJob.deliveryAddressLabel());
updateLink(routeLink, directionsUrl(selectedJob.pickupAddressLabel(), selectedJob.deliveryAddressLabel()), true);
updateLink(pickupMapLink, mapUrl(selectedJob.pickupAddressLabel()), true);
updateLink(deliveryMapLink, mapUrl(selectedJob.deliveryAddressLabel()), true);
}
private void clearJobDetail() {
detailJob.setText("-");
detailCustomer.setText("-");
detailCostCenter.setText("-");
detailStatus.setText("-");
detailCourier.setText("-");
detailVehicle.setText("-");
detailPickup.setText("-");
detailDelivery.setText("-");
updateLink(routeLink, "", false);
updateLink(pickupMapLink, "", false);
updateLink(deliveryMapLink, "", false);
}
private void updateStationDetail() {
if (selectedStation == null) {
clearStationDetail();
return;
}
stationJob.setText(String.valueOf(selectedStation.jobId()));
stationCustomer.setText(selectedStation.customerName());
stationName.setText(selectedStation.stopName());
stationStatus.setText(statusLabel(selectedStation.stopStatus(), true));
stationAddress.setText(selectedStation.addressLabel());
stationCourier.setText(selectedStation.courierSid().isBlank() ? "-" : selectedStation.courierSid());
stationRemark.setText(selectedStation.remark().isBlank() ? "-" : selectedStation.remark());
stationPhone.setText(selectedStation.phone().isBlank() ? "-" : selectedStation.phone());
updateLink(stationMapLink, selectedStation.mapUrl(), true);
JobRow job = jobsById.get(selectedStation.jobId());
updateLink(stationRouteLink,
job != null ? directionsUrl(job.pickupAddressLabel(), job.deliveryAddressLabel()) : "",
job != null);
}
private void clearStationDetail() {
stationJob.setText("-");
stationCustomer.setText("-");
stationName.setText("-");
stationStatus.setText("-");
stationAddress.setText("-");
stationCourier.setText("-");
stationRemark.setText("-");
stationPhone.setText("-");
updateLink(stationMapLink, "", false);
updateLink(stationRouteLink, "", false);
}
private Anchor mapAnchor(String href, String label) {
Anchor anchor = createLink(label);
updateLink(anchor, href, href != null && !href.isBlank());
return anchor;
}
private Anchor createLink(String text) {
Anchor anchor = new Anchor();
anchor.setText(text);
anchor.setTarget("_blank");
anchor.setVisible(false);
return anchor;
}
private void updateLink(Anchor anchor, String href, boolean visible) {
if (visible && href != null && !href.isBlank()) {
anchor.setHref(href);
anchor.setVisible(true);
} else {
anchor.setHref("");
anchor.setVisible(false);
}
}
private String mapUrl(String address) {
if (address == null || address.isBlank() || "-".equals(address)) {
return "";
}
return "https://www.google.com/maps/search/?api=1&query="
+ URLEncoder.encode(address, StandardCharsets.UTF_8);
}
private String directionsUrl(String origin, String destination) {
if (origin == null || origin.isBlank() || destination == null || destination.isBlank()
|| "-".equals(origin) || "-".equals(destination)) {
return "";
}
return "https://www.google.com/maps/dir/?api=1&origin="
+ URLEncoder.encode(origin, StandardCharsets.UTF_8)
+ "&destination="
+ URLEncoder.encode(destination, StandardCharsets.UTF_8);
}
private String formatDateTime(String value) {
return value == null || value.isBlank() ? "-" : value.replace("T", " ");
}
private String formatAmount(BigDecimal value) {
return value != null ? value.toPlainString() : "-";
}
private String statusLabel(Integer status, boolean station) {
if (station) {
return Integer.valueOf(1).equals(status) ? "Erledigt" : "Offen";
}
return switch (status != null ? status : -1) {
case 0 -> "Blockiert";
case 1 -> "Zugewiesen";
case 2 -> "In Zustellung";
case 8 -> "Zugestellt";
case 9 -> "Abgeschlossen";
case 10 -> "Storniert";
default -> status != null ? String.valueOf(status) : "-";
};
}
private String vehicleTypeLabel(Integer vehicleTypeId) {
return vehicleTypeId != null ? "Typ " + vehicleTypeId : "-";
}
private String availabilityLabel(String value) {
return "1".equals(value) ? "Ja" : "Nein";
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.longValue();
}
try {
return Long.parseLong(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
private Integer toInteger(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.intValue();
}
try {
return Integer.parseInt(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
private BigDecimal toBigDecimal(Object value) {
if (value == null) {
return null;
}
if (value instanceof BigDecimal decimal) {
return decimal;
}
try {
return new BigDecimal(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
private String str(Object value) {
return value != null ? value.toString() : "";
}
private record JobRow(Long jobId, Integer status, String orderTime, String customerName, String costCenterName,
String pickupName, String pickupStreet, String pickupZipcode, String pickupCity,
String deliveryName, String deliveryStreet, String deliveryZipcode, String deliveryCity,
Long courierId, String courierSid, Integer vehicleTypeId, Long courierVehicleId,
String courierVehicleSid, BigDecimal totalPrice, boolean exported) {
private String vehicleDisplay() {
if (courierVehicleSid != null && !courierVehicleSid.isBlank()) {
return courierVehicleSid;
}
if (vehicleTypeId != null) {
return "Typ " + vehicleTypeId;
}
return "-";
}
private String pickupAddressLabel() {
return joinAddress(pickupStreet, pickupZipcode, pickupCity);
}
private String deliveryAddressLabel() {
return joinAddress(deliveryStreet, deliveryZipcode, deliveryCity);
}
private String joinAddress(String street, String zipcode, String city) {
List<String> parts = new ArrayList<>();
if (street != null && !street.isBlank()) {
parts.add(street);
}
String zipcodeCity = ((zipcode != null ? zipcode : "") + " " + (city != null ? city : "")).trim();
if (!zipcodeCity.isBlank()) {
parts.add(zipcodeCity);
}
return parts.isEmpty() ? "-" : String.join(", ", parts);
}
}
private record VehicleRow(Long courierId, String courierSid, String courierEid, String available,
Long courierVehicleId, String courierVehicleSid, Integer vehicleTypeId,
Long assignedJobs) {
private String vehicleDisplay() {
if (courierVehicleSid != null && !courierVehicleSid.isBlank()) {
return courierVehicleSid;
}
return "Hauptfahrzeug";
}
}
private record StationRow(Long jobId, String orderTime, Integer stopSort, Integer stopStatus, String customerName,
String commissionNo, String stopName, String street, String zipcode, String city,
String country, String remark, String phone, String courierSid) {
private String addressLabel() {
List<String> parts = new ArrayList<>();
if (street != null && !street.isBlank()) {
parts.add(street);
}
String zipcodeCity = ((zipcode != null ? zipcode : "") + " " + (city != null ? city : "")).trim();
if (!zipcodeCity.isBlank()) {
parts.add(zipcodeCity);
}
if (country != null && !country.isBlank()) {
parts.add(country);
}
return parts.isEmpty() ? "-" : String.join(", ", parts);
}
private String mapUrl() {
if (addressLabel().equals("-")) {
return "";
}
return "https://www.google.com/maps/search/?api=1&query="
+ URLEncoder.encode(addressLabel(), StandardCharsets.UTF_8);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
package de.votian.web.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import java.util.*;
@Route(value = "employees", layout = MainLayout.class)
@PageTitle("Votian - Mitarbeiter")
public class EmployeeListView extends VerticalLayout {
private final RestClientService restClient;
private final Grid<Map<String, Object>> grid;
public EmployeeListView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
setSizeFull();
setPadding(true);
Button newButton = new Button("Neuer Mitarbeiter", VaadinIcon.PLUS.create(), e ->
getUI().ifPresent(ui -> ui.navigate(EmployeeDetailView.class)));
newButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
newButton.setVisible(sessionData.canManageEmployees());
HorizontalLayout toolbar = new HorizontalLayout(newButton);
toolbar.setWidthFull();
grid = new Grid<>();
grid.addColumn(m -> m.get("id")).setHeader("ID").setAutoWidth(true);
grid.addColumn(m -> m.get("userName")).setHeader("Name").setFlexGrow(1);
grid.addColumn(m -> m.get("userFirstname")).setHeader("Vorname").setFlexGrow(1);
grid.addColumn(m -> m.get("userEmail")).setHeader("E-Mail").setFlexGrow(1);
grid.addColumn(m -> userTypeLabel(m.get("userType"))).setHeader("Art").setAutoWidth(true);
grid.addColumn(m -> m.get("headquarters")).setHeader("Niederlassung").setAutoWidth(true);
grid.setSizeFull();
grid.addItemClickListener(e -> {
Object id = e.getItem().get("id");
if (id != null) {
getUI().ifPresent(ui -> ui.navigate("employees/" + id));
}
});
add(toolbar, grid);
loadEmployees();
}
private void loadEmployees() {
try {
Object[] employees = restClient.get("/employees", Object[].class);
if (employees != null) {
List<Map<String, Object>> items = new ArrayList<>();
for (Object e : employees) {
if (e instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) e;
items.add(map);
}
}
grid.setItems(items);
}
} catch (Exception e) {
Notification.show("Fehler: " + e.getMessage());
}
}
private String userTypeLabel(Object value) {
if (value instanceof Number number) {
return switch (number.intValue()) {
case 2 -> "Kunde";
case 4 -> "Lager";
default -> "HQ";
};
}
return "";
}
}

View File

@@ -0,0 +1,517 @@
package de.votian.web.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.H4;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.StreamResource;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Route(value = "exports", layout = MainLayout.class)
@PageTitle("Votian - Datenexport")
public class GenericExportWorkspaceView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final ComboBox<CategoryOption> categoryField = new ComboBox<>("Kategorie");
private final TextField customerFilterField = new TextField("Kundenfilter");
private final DatePicker fromDate = new DatePicker("Von");
private final DatePicker toDate = new DatePicker("Bis");
private final ComboBox<StatusOption> statusFilterField = new ComboBox<>("Status");
private final TextField delimiterField = new TextField("Delimiter");
private final TextField fileNameField = new TextField("Dateiname");
private final Checkbox includeHeadlineField = new Checkbox("Kopfzeile einbauen");
private final Button runExportButton = new Button("Export erzeugen");
private final Grid<ProfileRow> profileGrid = new Grid<>(ProfileRow.class, false);
private final TextField profileNameField = new TextField("Profilname");
private final TextField fileExtensionField = new TextField("Dateiendung");
private final TextArea parameterStringField = new TextArea("Parameterstring");
private final Checkbox profileHeadlineField = new Checkbox("Profil-Kopfzeile");
private final TextField beginOfLineField = new TextField("BOL");
private final TextField endOfLineField = new TextField("EOL");
private final TextField beginOfFileField = new TextField("BOF");
private final TextField endOfFileField = new TextField("EOF");
private final Button profileNewButton = new Button("Neu");
private final Button profileSaveButton = new Button("Speichern");
private final Button profileDeleteButton = new Button("Loeschen");
private final Grid<ExportFileRow> fileGrid = new Grid<>(ExportFileRow.class, false);
private final List<CategoryOption> categories = new ArrayList<>();
private final List<ProfileRow> profiles = new ArrayList<>();
private Long selectedProfileId;
private boolean customerScoped;
private Long scopedCustomerId;
public GenericExportWorkspaceView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setSizeFull();
setPadding(true);
setSpacing(true);
configureFilters();
configureProfileGrid();
configureFileGrid();
configureProfileEditor();
add(
new H3("Generischer Datenexport"),
buildFilterBar(),
buildProfileSection(),
buildFilesSection()
);
expand(profileGrid, fileGrid);
loadBootstrap();
}
private void configureFilters() {
categoryField.setItemLabelGenerator(CategoryOption::label);
categoryField.setWidth("320px");
categoryField.addValueChangeListener(event -> {
clearProfileForm();
loadProfiles();
});
customerFilterField.setWidth("220px");
customerFilterField.setClearButtonVisible(true);
fromDate.setValue(LocalDate.now().withDayOfMonth(1));
toDate.setValue(LocalDate.now());
statusFilterField.setItems(
new StatusOption(null, "Alle"),
new StatusOption(1, "Nur nicht exportierte"),
new StatusOption(2, "Nur exportierte")
);
statusFilterField.setItemLabelGenerator(StatusOption::label);
statusFilterField.setValue(statusFilterField.getListDataView().getItems().findFirst().orElse(null));
statusFilterField.setWidth("220px");
delimiterField.setValue(";");
delimiterField.setWidth("100px");
fileNameField.setWidth("260px");
runExportButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
runExportButton.addClickListener(event -> runExport());
}
private void configureProfileGrid() {
profileGrid.addColumn(ProfileRow::name).setHeader("Profil").setFlexGrow(1).setFrozen(true);
profileGrid.addColumn(ProfileRow::fileExtension).setHeader("Ext.").setAutoWidth(true);
profileGrid.addColumn(row -> row.headline() ? "Ja" : "").setHeader("Headline").setAutoWidth(true);
profileGrid.setHeight("220px");
profileGrid.asSingleSelect().addValueChangeListener(event -> fillProfileForm(event.getValue()));
}
private void configureFileGrid() {
fileGrid.addColumn(ExportFileRow::storedAt).setHeader("Zeitpunkt").setAutoWidth(true).setFrozen(true);
fileGrid.addColumn(ExportFileRow::fileName).setHeader("Datei").setFlexGrow(1);
fileGrid.addColumn(ExportFileRow::customerCode).setHeader("Kunde").setAutoWidth(true);
fileGrid.addColumn(row -> row.ftpUploaded() ? "Ja" : "").setHeader("FTP").setAutoWidth(true);
fileGrid.addComponentColumn(this::downloadAnchor).setHeader("Download").setAutoWidth(true);
fileGrid.addComponentColumn(row -> {
Button delete = new Button("Loeschen");
delete.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
delete.addClickListener(event -> deleteFile(row));
return delete;
}).setHeader("Aktion").setAutoWidth(true);
fileGrid.setSizeFull();
}
private void configureProfileEditor() {
parameterStringField.setWidthFull();
parameterStringField.setMinHeight("180px");
profileSaveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
profileDeleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
profileNewButton.addClickListener(event -> clearProfileForm());
profileSaveButton.addClickListener(event -> saveProfile());
profileDeleteButton.addClickListener(event -> deleteProfile());
}
private HorizontalLayout buildFilterBar() {
HorizontalLayout layout = new HorizontalLayout(
categoryField,
customerFilterField,
fromDate,
toDate,
statusFilterField,
delimiterField,
fileNameField,
includeHeadlineField,
runExportButton
);
layout.setAlignItems(Alignment.BASELINE);
layout.getStyle().set("flex-wrap", "wrap");
return layout;
}
private VerticalLayout buildProfileSection() {
FormLayout form = new FormLayout();
form.setResponsiveSteps(
new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("900px", 4)
);
form.add(profileNameField, fileExtensionField, profileHeadlineField, beginOfLineField, endOfLineField, beginOfFileField, endOfFileField, parameterStringField);
form.setColspan(parameterStringField, 4);
HorizontalLayout actions = new HorizontalLayout(profileNewButton, profileSaveButton, profileDeleteButton);
actions.setAlignItems(Alignment.BASELINE);
VerticalLayout section = new VerticalLayout(
new Paragraph("Profile arbeiten direkt auf `exportparameters`. Der Parameterstring bleibt bewusst editierbar, waehrend der NG-Workspace die Exportdateien nativ erzeugt und in `exportfiles` archiviert."),
new H4("Exportprofile"),
profileGrid,
form,
actions
);
section.setPadding(false);
section.setSpacing(true);
section.setSizeFull();
return section;
}
private VerticalLayout buildFilesSection() {
VerticalLayout section = new VerticalLayout(
new H4("Exportdateien"),
fileGrid
);
section.setPadding(false);
section.setSpacing(true);
section.setSizeFull();
return section;
}
private void loadBootstrap() {
Long headquartersId = sessionData.getHeadquartersId();
if (headquartersId == null) {
return;
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> bootstrap = restClient.get(
"/export-workspace/bootstrap?hqId=" + headquartersId,
LinkedHashMap.class
);
customerScoped = Boolean.TRUE.equals(bootstrap.get("customerScoped"));
scopedCustomerId = toLong(bootstrap.get("customerId"));
customerFilterField.setEnabled(!customerScoped);
categories.clear();
Object rawCategories = bootstrap.get("categories");
if (rawCategories instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Map<?, ?> row) {
Long id = toLong(row.get("id"));
if (id != null) {
categories.add(new CategoryOption(id, str(row.get("name"))));
}
}
}
}
categoryField.setItems(categories);
if (!categories.isEmpty()) {
categoryField.setValue(categories.get(0));
}
loadProfiles();
loadFiles();
} catch (Exception e) {
showError("Exportworkspace konnte nicht initialisiert werden: " + e.getMessage());
}
}
private void loadProfiles() {
CategoryOption category = categoryField.getValue();
Long headquartersId = sessionData.getHeadquartersId();
if (category == null || headquartersId == null) {
profileGrid.setItems(List.of());
return;
}
try {
String path = "/export-workspace/profiles?hqId=" + headquartersId
+ "&categoryId=" + category.id()
+ customerScopeQuery();
List<LinkedHashMap<String, Object>> result = restClient.getList(path, new ParameterizedTypeReference<>() {});
profiles.clear();
for (Map<String, Object> row : result) {
Long id = toLong(row.get("id"));
if (id == null) {
continue;
}
profiles.add(new ProfileRow(
id,
str(row.get("name")),
str(row.get("parameterString")),
Boolean.TRUE.equals(row.get("headline")),
str(row.get("beginOfLineChars")),
str(row.get("endOfLineChars")),
str(row.get("beginOfFileChars")),
str(row.get("endOfFileChars")),
str(row.get("fileExtension"))
));
}
profileGrid.setItems(profiles);
} catch (Exception e) {
showError("Exportprofile konnten nicht geladen werden: " + e.getMessage());
}
}
private void loadFiles() {
Long headquartersId = sessionData.getHeadquartersId();
if (headquartersId == null) {
return;
}
try {
String path = "/export-workspace/files?hqId=" + headquartersId + customerScopeQuery();
List<LinkedHashMap<String, Object>> result = restClient.getList(path, new ParameterizedTypeReference<>() {});
List<ExportFileRow> rows = new ArrayList<>();
for (Map<String, Object> row : result) {
Long id = toLong(row.get("id"));
if (id == null) {
continue;
}
rows.add(new ExportFileRow(
id,
str(row.get("fileName")),
str(row.get("storedAt")),
Boolean.TRUE.equals(row.get("ftpUploaded")),
str(row.get("customerCode"))
));
}
fileGrid.setItems(rows);
} catch (Exception e) {
showError("Exportdateien konnten nicht geladen werden: " + e.getMessage());
}
}
private void runExport() {
CategoryOption category = categoryField.getValue();
Long headquartersId = sessionData.getHeadquartersId();
if (category == null || headquartersId == null) {
return;
}
try {
Map<String, Object> body = new LinkedHashMap<>();
body.put("hqId", headquartersId);
body.put("customerId", scopedCustomerId);
body.put("categoryId", category.id());
body.put("profileId", selectedProfileId);
body.put("from", fromDate.getValue());
body.put("to", toDate.getValue());
body.put("statusFilter", statusFilterField.getValue() != null ? statusFilterField.getValue().id() : null);
body.put("customerCodeFilter", customerFilterField.getValue());
body.put("delimiter", delimiterField.getValue());
body.put("fileName", fileNameField.getValue());
body.put("includeHeadline", includeHeadlineField.getValue());
@SuppressWarnings("unchecked")
Map<String, Object> result = restClient.post("/export-workspace/run", body, LinkedHashMap.class);
showInfo("Export erzeugt: " + toInteger(result.get("rowCount")) + " Zeilen.");
loadFiles();
} catch (Exception e) {
showError("Export konnte nicht erzeugt werden: " + e.getMessage());
}
}
private void saveProfile() {
CategoryOption category = categoryField.getValue();
Long headquartersId = sessionData.getHeadquartersId();
if (category == null || headquartersId == null) {
return;
}
try {
Map<String, Object> body = new LinkedHashMap<>();
body.put("hqId", headquartersId);
body.put("customerId", scopedCustomerId);
body.put("categoryId", category.id());
body.put("name", profileNameField.getValue());
body.put("parameterString", parameterStringField.getValue());
body.put("headline", profileHeadlineField.getValue());
body.put("beginOfLineChars", beginOfLineField.getValue());
body.put("endOfLineChars", endOfLineField.getValue());
body.put("beginOfFileChars", beginOfFileField.getValue());
body.put("endOfFileChars", endOfFileField.getValue());
body.put("fileExtension", fileExtensionField.getValue());
if (selectedProfileId != null) {
restClient.put("/export-workspace/profiles/" + selectedProfileId, body);
} else {
@SuppressWarnings("unchecked")
Map<String, Object> saved = restClient.post("/export-workspace/profiles", body, LinkedHashMap.class);
selectedProfileId = toLong(saved.get("id"));
}
loadProfiles();
showInfo("Exportprofil gespeichert.");
} catch (Exception e) {
showError("Exportprofil konnte nicht gespeichert werden: " + e.getMessage());
}
}
private void deleteProfile() {
CategoryOption category = categoryField.getValue();
Long headquartersId = sessionData.getHeadquartersId();
if (selectedProfileId == null || category == null || headquartersId == null) {
return;
}
try {
restClient.delete("/export-workspace/profiles/" + selectedProfileId
+ "?hqId=" + headquartersId
+ "&categoryId=" + category.id()
+ customerScopeQuery());
clearProfileForm();
loadProfiles();
showInfo("Exportprofil geloescht.");
} catch (Exception e) {
showError("Exportprofil konnte nicht geloescht werden: " + e.getMessage());
}
}
private void deleteFile(ExportFileRow row) {
Long headquartersId = sessionData.getHeadquartersId();
if (headquartersId == null) {
return;
}
try {
restClient.delete("/export-workspace/files/" + row.id()
+ "?hqId=" + headquartersId
+ customerScopeQuery());
loadFiles();
showInfo("Exportdatei geloescht.");
} catch (Exception e) {
showError("Exportdatei konnte nicht geloescht werden: " + e.getMessage());
}
}
private Anchor downloadAnchor(ExportFileRow row) {
String path = "/export-workspace/files/" + row.id()
+ "/content?hqId=" + sessionData.getHeadquartersId()
+ customerScopeQuery();
StreamResource resource = new StreamResource(
row.fileName(),
() -> new ByteArrayInputStream(restClient.getBytes(path))
);
Anchor anchor = new Anchor(resource, "Download");
anchor.getElement().setAttribute("download", true);
return anchor;
}
private void fillProfileForm(ProfileRow row) {
if (row == null) {
clearProfileForm();
return;
}
selectedProfileId = row.id();
profileNameField.setValue(row.name());
fileExtensionField.setValue(row.fileExtension());
parameterStringField.setValue(row.parameterString());
profileHeadlineField.setValue(row.headline());
beginOfLineField.setValue(row.beginOfLineChars());
endOfLineField.setValue(row.endOfLineChars());
beginOfFileField.setValue(row.beginOfFileChars());
endOfFileField.setValue(row.endOfFileChars());
}
private void clearProfileForm() {
selectedProfileId = null;
profileGrid.deselectAll();
profileNameField.clear();
fileExtensionField.clear();
parameterStringField.clear();
profileHeadlineField.setValue(false);
beginOfLineField.clear();
endOfLineField.clear();
beginOfFileField.clear();
endOfFileField.clear();
}
private String customerScopeQuery() {
return scopedCustomerId != null ? "&customerId=" + scopedCustomerId : "";
}
private void showInfo(String message) {
Notification notification = Notification.show(message, 3000, Notification.Position.TOP_END);
notification.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
}
private void showError(String message) {
Notification notification = Notification.show(message, 5000, Notification.Position.TOP_END);
notification.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
private static String str(Object value) {
return value != null ? String.valueOf(value) : "";
}
private static Long toLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value != null && !String.valueOf(value).isBlank()) {
return Long.parseLong(String.valueOf(value));
}
return null;
}
private static Integer toInteger(Object value) {
if (value instanceof Number number) {
return number.intValue();
}
if (value != null && !String.valueOf(value).isBlank()) {
return Integer.parseInt(String.valueOf(value));
}
return null;
}
private record CategoryOption(Long id, String label) {
}
private record StatusOption(Integer id, String label) {
}
private record ProfileRow(Long id,
String name,
String parameterString,
boolean headline,
String beginOfLineChars,
String endOfLineChars,
String beginOfFileChars,
String endOfFileChars,
String fileExtension) {
}
private record ExportFileRow(Long id,
String fileName,
String storedAt,
boolean ftpUploaded,
String customerCode) {
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,493 @@
package de.votian.web.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.tabs.Tab;
import com.vaadin.flow.component.tabs.Tabs;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Route(value = "groups-filters", layout = MainLayout.class)
@PageTitle("Votian - Gruppen und Filter")
public class GroupFilterAdminView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final Grid<GroupRow> groupGrid = new Grid<>(GroupRow.class, false);
private final TextField groupNameField = new TextField("Gruppenname");
private final Checkbox groupGlobalField = new Checkbox("Fuer alle Niederlassungen sichtbar");
private final Paragraph groupInfo = new Paragraph();
private final Button groupNewButton = new Button("Neu");
private final Button groupSaveButton = new Button("Speichern");
private final Button groupDeleteButton = new Button("Loeschen");
private final List<GroupRow> groups = new ArrayList<>();
private final Grid<FilterRow> filterGrid = new Grid<>(FilterRow.class, false);
private final TextField filterShortField = new TextField("Kuerzel");
private final TextField filterTextField = new TextField("Bezeichnung");
private final ComboBox<FilterTypeOption> filterTypeField = new ComboBox<>("Art");
private final ComboBox<FilterStatusOption> filterStatusField = new ComboBox<>("Typ");
private final IntegerField filterSortField = new IntegerField("Sortierung");
private final TextArea filterLongTextField = new TextArea("Langtext");
private final Paragraph filterInfo = new Paragraph();
private final Button filterNewButton = new Button("Neu");
private final Button filterSaveButton = new Button("Speichern");
private final Button filterDeleteButton = new Button("Loeschen");
private final List<FilterRow> filters = new ArrayList<>();
private Long selectedGroupId;
private boolean selectedGroupReadonly;
private Long selectedFilterId;
public GroupFilterAdminView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setSizeFull();
setPadding(true);
setSpacing(true);
configureGroupGrid();
configureFilterGrid();
configureForms();
configureActions();
Tab groupsTab = new Tab("Gruppen");
Tab filtersTab = new Tab("Filter");
Tabs tabs = new Tabs(groupsTab, filtersTab);
VerticalLayout groupsContent = buildGroupsContent();
VerticalLayout filtersContent = buildFiltersContent();
filtersContent.setVisible(false);
tabs.addSelectedChangeListener(event -> {
groupsContent.setVisible(event.getSelectedTab() == groupsTab);
filtersContent.setVisible(event.getSelectedTab() == filtersTab);
});
add(new H3("Gruppen und Filter"), tabs, groupsContent, filtersContent);
expand(groupsContent, filtersContent);
loadGroups();
loadFilters();
}
private void configureGroupGrid() {
groupGrid.addColumn(GroupRow::name).setHeader("Gruppe").setAutoWidth(true).setFrozen(true);
groupGrid.addColumn(GroupRow::visibilityLabel).setHeader("Sichtbarkeit").setAutoWidth(true);
groupGrid.addColumn(GroupRow::usageCount).setHeader("Zuordnungen").setAutoWidth(true);
groupGrid.addColumn(GroupRow::usageSummary).setHeader("Verwendung").setFlexGrow(1);
groupGrid.addColumn(row -> row.readonly() ? "Ja" : "").setHeader("Readonly").setAutoWidth(true);
groupGrid.setSizeFull();
groupGrid.asSingleSelect().addValueChangeListener(event -> fillGroupForm(event.getValue()));
}
private void configureFilterGrid() {
filterGrid.addColumn(FilterRow::shortCode).setHeader("Kuerzel").setAutoWidth(true).setFrozen(true);
filterGrid.addColumn(FilterRow::text).setHeader("Bezeichnung").setAutoWidth(true);
filterGrid.addColumn(FilterRow::typeLabel).setHeader("Art").setAutoWidth(true);
filterGrid.addColumn(FilterRow::statusLabel).setHeader("Typ").setAutoWidth(true);
filterGrid.addColumn(FilterRow::sort).setHeader("Sort.").setAutoWidth(true);
filterGrid.addColumn(FilterRow::usageSummary).setHeader("Verwendung").setFlexGrow(1);
filterGrid.setSizeFull();
filterGrid.asSingleSelect().addValueChangeListener(event -> fillFilterForm(event.getValue()));
}
private void configureForms() {
groupNameField.setWidthFull();
groupInfo.getStyle().set("margin", "0");
filterTypeField.setItems(
new FilterTypeOption(0, "Fahrzeugfilter"),
new FilterTypeOption(1, "Transporteursfilter")
);
filterTypeField.setItemLabelGenerator(FilterTypeOption::label);
filterTypeField.setValue(new FilterTypeOption(0, "Fahrzeugfilter"));
filterStatusField.setItems(
new FilterStatusOption(1, "Pflichtfilter"),
new FilterStatusOption(2, "Optional")
);
filterStatusField.setItemLabelGenerator(FilterStatusOption::label);
filterStatusField.setValue(new FilterStatusOption(2, "Optional"));
filterLongTextField.setWidthFull();
filterLongTextField.setMinHeight("140px");
filterInfo.getStyle().set("margin", "0");
}
private void configureActions() {
groupSaveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
groupDeleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
filterSaveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
filterDeleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
groupNewButton.addClickListener(event -> clearGroupForm());
groupSaveButton.addClickListener(event -> saveGroup());
groupDeleteButton.addClickListener(event -> deleteGroup());
filterNewButton.addClickListener(event -> clearFilterForm());
filterSaveButton.addClickListener(event -> saveFilter());
filterDeleteButton.addClickListener(event -> deleteFilter());
refreshGroupButtons();
refreshFilterButtons();
}
private VerticalLayout buildGroupsContent() {
FormLayout form = new FormLayout();
form.setResponsiveSteps(
new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("700px", 2)
);
form.add(groupNameField, groupGlobalField);
HorizontalLayout actions = new HorizontalLayout(groupNewButton, groupSaveButton, groupDeleteButton);
VerticalLayout content = new VerticalLayout(
new Paragraph("Der Gruppenkatalog entspricht der Legacy-Tabelle `groups`. Nicht schreibbare Legacy-Gruppen bleiben sichtbar, aber gesperrt."),
form,
groupInfo,
actions,
groupGrid
);
content.setPadding(false);
content.setSpacing(true);
content.setSizeFull();
content.expand(groupGrid);
return content;
}
private VerticalLayout buildFiltersContent() {
FormLayout form = new FormLayout();
form.setResponsiveSteps(
new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("900px", 2)
);
form.add(filterShortField, filterTextField, filterTypeField, filterStatusField, filterSortField, filterLongTextField);
form.setColspan(filterLongTextField, 2);
HorizontalLayout actions = new HorizontalLayout(filterNewButton, filterSaveButton, filterDeleteButton);
VerticalLayout content = new VerticalLayout(
new Paragraph("Filter pflegen den Legacy-Katalog `courierfilter`. Beim Loeschen werden Kunden-, Kurier- und Fahrzeugzuordnungen bereinigt."),
form,
filterInfo,
actions,
filterGrid
);
content.setPadding(false);
content.setSpacing(true);
content.setSizeFull();
content.expand(filterGrid);
return content;
}
private void loadGroups() {
if (sessionData.getHeadquartersId() == null) {
return;
}
try {
List<LinkedHashMap<String, Object>> result = restClient.getList(
"/group-filter-admin/groups?hqId=" + sessionData.getHeadquartersId(),
new ParameterizedTypeReference<>() {}
);
groups.clear();
for (Map<String, Object> row : result) {
Long id = toLong(row.get("id"));
if (id == null) {
continue;
}
groups.add(new GroupRow(
id,
str(row.get("name")),
bool(row.get("globalVisible")),
bool(row.get("readonly")),
toLong(row.get("usageCount")) != null ? toLong(row.get("usageCount")) : 0L,
str(row.get("usageSummary"))
));
}
groupGrid.setItems(groups);
if (selectedGroupId != null) {
groups.stream()
.filter(row -> row.id().equals(selectedGroupId))
.findFirst()
.ifPresent(row -> groupGrid.asSingleSelect().setValue(row));
}
} catch (Exception e) {
showError("Gruppen konnten nicht geladen werden: " + e.getMessage());
}
}
private void loadFilters() {
if (sessionData.getHeadquartersId() == null) {
return;
}
try {
List<LinkedHashMap<String, Object>> result = restClient.getList(
"/group-filter-admin/filters?hqId=" + sessionData.getHeadquartersId(),
new ParameterizedTypeReference<>() {}
);
filters.clear();
for (Map<String, Object> row : result) {
Long id = toLong(row.get("id"));
if (id == null) {
continue;
}
filters.add(new FilterRow(
id,
toInteger(row.get("type")) != null ? toInteger(row.get("type")) : 0,
str(row.get("typeLabel")),
toInteger(row.get("status")) != null ? toInteger(row.get("status")) : 2,
str(row.get("statusLabel")),
str(row.get("shortCode")),
str(row.get("text")),
toInteger(row.get("sort")) != null ? toInteger(row.get("sort")) : 0,
str(row.get("longText")),
toLong(row.get("usageCount")) != null ? toLong(row.get("usageCount")) : 0L,
str(row.get("usageSummary"))
));
}
filterGrid.setItems(filters);
if (selectedFilterId != null) {
filters.stream()
.filter(row -> row.id().equals(selectedFilterId))
.findFirst()
.ifPresent(row -> filterGrid.asSingleSelect().setValue(row));
}
} catch (Exception e) {
showError("Filter konnten nicht geladen werden: " + e.getMessage());
}
}
private void fillGroupForm(GroupRow row) {
if (row == null) {
clearGroupForm();
return;
}
selectedGroupId = row.id();
selectedGroupReadonly = row.readonly();
groupNameField.setValue(row.name());
groupGlobalField.setValue(row.globalVisible());
groupInfo.setText(row.usageSummary());
refreshGroupButtons();
}
private void clearGroupForm() {
selectedGroupId = null;
selectedGroupReadonly = false;
groupGrid.asSingleSelect().clear();
groupNameField.clear();
groupGlobalField.setValue(false);
groupInfo.setText("Neue Gruppe anlegen oder bestehende Gruppe aus der Liste bearbeiten.");
refreshGroupButtons();
}
private void saveGroup() {
if (sessionData.getHeadquartersId() == null) {
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("hqId", sessionData.getHeadquartersId());
request.put("name", groupNameField.getValue());
request.put("globalVisible", groupGlobalField.getValue());
if (selectedGroupId == null) {
restClient.post("/group-filter-admin/groups", request, LinkedHashMap.class);
} else {
restClient.put("/group-filter-admin/groups/" + selectedGroupId, request);
}
Notification.show("Gruppe gespeichert.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
clearGroupForm();
loadGroups();
} catch (Exception e) {
showError("Gruppe konnte nicht gespeichert werden: " + e.getMessage());
}
}
private void deleteGroup() {
if (selectedGroupId == null || sessionData.getHeadquartersId() == null) {
return;
}
try {
restClient.delete("/group-filter-admin/groups/" + selectedGroupId + "?hqId=" + sessionData.getHeadquartersId());
Notification.show("Gruppe geloescht.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
clearGroupForm();
loadGroups();
} catch (Exception e) {
showError("Gruppe konnte nicht geloescht werden: " + e.getMessage());
}
}
private void fillFilterForm(FilterRow row) {
if (row == null) {
clearFilterForm();
return;
}
selectedFilterId = row.id();
filterShortField.setValue(row.shortCode());
filterShortField.setReadOnly(true);
filterTextField.setValue(row.text());
filterTypeField.setValue(new FilterTypeOption(row.type(), row.typeLabel()));
filterStatusField.setValue(new FilterStatusOption(row.status(), row.statusLabel()));
filterSortField.setValue(row.sort());
filterLongTextField.setValue(row.longText());
filterInfo.setText(row.usageSummary());
refreshFilterButtons();
}
private void clearFilterForm() {
selectedFilterId = null;
filterGrid.asSingleSelect().clear();
filterShortField.clear();
filterShortField.setReadOnly(false);
filterTextField.clear();
filterTypeField.setValue(new FilterTypeOption(0, "Fahrzeugfilter"));
filterStatusField.setValue(new FilterStatusOption(2, "Optional"));
filterSortField.clear();
filterLongTextField.clear();
filterInfo.setText("Neuen Filter anlegen oder bestehenden Filter aus der Liste bearbeiten.");
refreshFilterButtons();
}
private void saveFilter() {
if (sessionData.getHeadquartersId() == null) {
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("hqId", sessionData.getHeadquartersId());
request.put("shortCode", filterShortField.getValue());
request.put("text", filterTextField.getValue());
request.put("type", filterTypeField.getValue() != null ? filterTypeField.getValue().code() : 0);
request.put("status", filterStatusField.getValue() != null ? filterStatusField.getValue().code() : 2);
request.put("sort", filterSortField.getValue());
request.put("longText", filterLongTextField.getValue());
if (selectedFilterId == null) {
restClient.post("/group-filter-admin/filters", request, LinkedHashMap.class);
} else {
restClient.put("/group-filter-admin/filters/" + selectedFilterId, request);
}
Notification.show("Filter gespeichert.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
clearFilterForm();
loadFilters();
} catch (Exception e) {
showError("Filter konnte nicht gespeichert werden: " + e.getMessage());
}
}
private void deleteFilter() {
if (selectedFilterId == null || sessionData.getHeadquartersId() == null) {
return;
}
try {
restClient.delete("/group-filter-admin/filters/" + selectedFilterId + "?hqId=" + sessionData.getHeadquartersId());
Notification.show("Filter geloescht.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
clearFilterForm();
loadFilters();
} catch (Exception e) {
showError("Filter konnte nicht geloescht werden: " + e.getMessage());
}
}
private void refreshGroupButtons() {
boolean editable = !selectedGroupReadonly;
groupSaveButton.setEnabled(editable);
groupDeleteButton.setEnabled(selectedGroupId != null && editable);
}
private void refreshFilterButtons() {
filterDeleteButton.setEnabled(selectedFilterId != null);
}
private void showError(String message) {
Notification.show(message, 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
private Long toLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
try {
return value != null ? Long.parseLong(value.toString()) : null;
} catch (NumberFormatException e) {
return null;
}
}
private Integer toInteger(Object value) {
if (value instanceof Number number) {
return number.intValue();
}
try {
return value != null ? Integer.parseInt(value.toString()) : null;
} catch (NumberFormatException e) {
return null;
}
}
private boolean bool(Object value) {
return Boolean.TRUE.equals(value) || (value != null && "true".equalsIgnoreCase(value.toString()));
}
private String str(Object value) {
return value != null ? value.toString() : "";
}
private record GroupRow(Long id,
String name,
boolean globalVisible,
boolean readonly,
long usageCount,
String usageSummary) {
private String visibilityLabel() {
return globalVisible ? "Alle Niederlassungen" : "Nur aktuelle Niederlassung";
}
}
private record FilterRow(Long id,
int type,
String typeLabel,
int status,
String statusLabel,
String shortCode,
String text,
int sort,
String longText,
long usageCount,
String usageSummary) {
}
private record FilterTypeOption(int code, String label) {
}
private record FilterStatusOption(int code, String label) {
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
package de.votian.web.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import java.util.LinkedHashMap;
import java.util.Map;
@Route(value = "headquarters-admin", layout = MainLayout.class)
@PageTitle("Votian - Niederlassung")
public class HeadquartersAdminView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final TextField mnemonicField = new TextField("Kuerzel");
private final TextField nameField = new TextField("Name");
private final IntegerField workmodeField = new IntegerField("Workmode");
private final TextField companyNameField = new TextField("Firma");
private final TextField companyName2Field = new TextField("Firma 2");
private final TextField companyName3Field = new TextField("Firma 3");
private final TextField companyName4Field = new TextField("Firma 4");
private final TextField streetField = new TextField("Strasse");
private final TextField hsnoField = new TextField("Hausnummer");
private final TextField zipcodeField = new TextField("PLZ");
private final TextField cityField = new TextField("Ort");
private final TextField bwvPhoneField = new TextField("BWV-Hotline");
private final TextField glnField = new TextField("GLN");
private final TextField dunsField = new TextField("DUNS");
public HeadquartersAdminView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setPadding(true);
setSpacing(true);
FormLayout form = new FormLayout();
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("700px", 2));
form.add(
mnemonicField, nameField, workmodeField, companyNameField, companyName2Field,
companyName3Field, companyName4Field, streetField, hsnoField, zipcodeField,
cityField, bwvPhoneField, glnField, dunsField
);
Button saveButton = new Button("Speichern", event -> save());
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
saveButton.setEnabled(sessionData.canManageHeadquarters());
add(new H3("Niederlassung"), form, new HorizontalLayout(saveButton));
load();
}
@SuppressWarnings("unchecked")
private void load() {
if (sessionData.getHeadquartersId() == null) {
return;
}
try {
Map<String, Object> data = restClient.get("/headquarters/" + sessionData.getHeadquartersId(), LinkedHashMap.class);
mnemonicField.setValue(str(data.get("mnemonic")));
nameField.setValue(str(data.get("name")));
workmodeField.setValue(data.get("workmode") instanceof Number ? ((Number) data.get("workmode")).intValue() : null);
companyNameField.setValue(str(data.get("companyName")));
companyName2Field.setValue(str(data.get("companyName2")));
companyName3Field.setValue(str(data.get("companyName3")));
companyName4Field.setValue(str(data.get("companyName4")));
streetField.setValue(str(data.get("street")));
hsnoField.setValue(str(data.get("hsno")));
zipcodeField.setValue(str(data.get("zipcode")));
cityField.setValue(str(data.get("city")));
bwvPhoneField.setValue(str(data.get("bwvPhone")));
glnField.setValue(str(data.get("gln")));
dunsField.setValue(str(data.get("duns")));
} catch (Exception e) {
Notification.show("Niederlassung konnte nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void save() {
if (!sessionData.canManageHeadquarters()) {
Notification.show("Keine Berechtigung zum Bearbeiten der Niederlassung.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
if (sessionData.getHeadquartersId() == null) {
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
Map<String, Object> headquarters = new LinkedHashMap<>();
headquarters.put("mnemonic", mnemonicField.getValue());
headquarters.put("name", nameField.getValue());
headquarters.put("workmode", workmodeField.getValue());
request.put("headquarters", headquarters);
Map<String, Object> company = new LinkedHashMap<>();
company.put("comp", companyNameField.getValue());
company.put("comp2", companyName2Field.getValue());
company.put("comp3", companyName3Field.getValue());
company.put("comp4", companyName4Field.getValue());
company.put("hsno", hsnoField.getValue());
request.put("company", company);
Map<String, Object> address = new LinkedHashMap<>();
address.put("street", streetField.getValue());
address.put("zipcode", zipcodeField.getValue());
address.put("city", cityField.getValue());
address.put("country", "DE");
request.put("address", address);
request.put("bwvPhone", bwvPhoneField.getValue());
request.put("gln", glnField.getValue());
request.put("duns", dunsField.getValue());
restClient.put("/headquarters/" + sessionData.getHeadquartersId(), request);
Notification.show("Niederlassung gespeichert.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
load();
} catch (Exception e) {
Notification.show("Niederlassung konnte nicht gespeichert werden.", 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private String str(Object value) {
return value != null ? value.toString() : "";
}
}

View File

@@ -0,0 +1,429 @@
package de.votian.web.view;
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.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Pre;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.upload.Upload;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.StreamResource;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;
import java.io.ByteArrayInputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Route(value = "imports", layout = MainLayout.class)
@PageTitle("Votian - Importe")
public class ImportView extends VerticalLayout {
private static final DateTimeFormatter FILE_TIME_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
private final RestClientService restClient;
private final ComboBox<ImportProcessRow> processField = new ComboBox<>("Prozess");
private final ComboBox<CustomerOption> customerField = new ComboBox<>("Kunde");
private final Button refreshButton = new Button("Aktualisieren");
private final Button executeButton = new Button("Nativen Import ausfuehren");
private final Grid<ImportFileRow> fileGrid = new Grid<>(ImportFileRow.class, false);
private final MemoryBuffer uploadBuffer = new MemoryBuffer();
private final Upload upload = new Upload(uploadBuffer);
private final Pre processInfo = new Pre();
private final Pre resultBox = new Pre();
private List<ImportProcessRow> processes = List.of();
private List<CustomerOption> customers = List.of();
public ImportView(RestClientService restClient) {
this.restClient = restClient;
setSizeFull();
setPadding(true);
setSpacing(true);
configureSelectors();
configureUpload();
configureGrid();
configureResultBox();
add(new H3("Importe"), buildToolbar(), buildWorkspace());
expand(fileGrid);
loadProcesses();
}
private void configureSelectors() {
processField.setWidth("420px");
processField.setItemLabelGenerator(ImportProcessRow::comboLabel);
processField.addValueChangeListener(event -> {
updateProcessPresentation();
refreshFiles();
});
customerField.setWidth("360px");
customerField.setItemLabelGenerator(CustomerOption::label);
customerField.setVisible(false);
customerField.addValueChangeListener(event -> updateExecuteState());
refreshButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
refreshButton.addClickListener(event -> refreshFiles());
executeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
executeButton.addClickListener(event -> executeSelectedImport());
executeButton.setEnabled(false);
}
private void configureUpload() {
upload.setMaxFiles(1);
upload.setMaxFileSize(25 * 1024 * 1024);
upload.setDropAllowed(true);
upload.setUploadButton(new Button("Datei hochladen"));
upload.addSucceededListener(event -> uploadSelectedFile(event.getFileName(), event.getMIMEType()));
upload.addFileRejectedListener(event -> showError(event.getErrorMessage()));
}
private void configureGrid() {
fileGrid.addColumn(ImportFileRow::fileName).setHeader("Datei").setFlexGrow(1);
fileGrid.addColumn(row -> formatBytes(row.size())).setHeader("Groesse").setAutoWidth(true);
fileGrid.addColumn(row -> row.modifiedAt() != null ? FILE_TIME_FORMAT.format(row.modifiedAt()) : "")
.setHeader("Geaendert")
.setAutoWidth(true);
fileGrid.addComponentColumn(this::buildDownloadLink).setHeader("Download").setAutoWidth(true);
fileGrid.addComponentColumn(this::buildDeleteButton).setHeader("Loeschen").setAutoWidth(true);
fileGrid.setSizeFull();
fileGrid.asSingleSelect().addValueChangeListener(event -> updateExecuteState());
}
private void configureResultBox() {
processInfo.getStyle()
.set("background", "var(--lumo-contrast-5pct)")
.set("padding", "var(--lumo-space-m)")
.set("margin", "0");
resultBox.getStyle()
.set("background", "var(--lumo-contrast-5pct)")
.set("padding", "var(--lumo-space-m)")
.set("margin", "0");
processInfo.setText("Prozess auswaehlen");
resultBox.setText("Noch kein Import ausgefuehrt.");
}
private HorizontalLayout buildToolbar() {
HorizontalLayout toolbar = new HorizontalLayout(processField, customerField, upload, refreshButton, executeButton);
toolbar.setAlignItems(Alignment.END);
toolbar.setWidthFull();
return toolbar;
}
private VerticalLayout buildWorkspace() {
VerticalLayout workspace = new VerticalLayout(processInfo, fileGrid, resultBox);
workspace.setPadding(false);
workspace.setSpacing(true);
workspace.setSizeFull();
workspace.expand(fileGrid);
return workspace;
}
private void loadProcesses() {
try {
processes = restClient.getList("/imports/processes", new ParameterizedTypeReference<List<ImportProcessRow>>() {});
processField.setItems(processes);
if (!processes.isEmpty()) {
processField.setValue(processes.getFirst());
}
} catch (Exception ex) {
showError("Importprozesse konnten nicht geladen werden: " + ex.getMessage());
}
}
private void loadCustomersIfNeeded() {
ImportProcessRow selectedProcess = processField.getValue();
if (selectedProcess == null || !selectedProcess.nativeExecution() || !selectedProcess.customerRequired()) {
customers = List.of();
customerField.clear();
customerField.setItems(List.of());
customerField.setVisible(false);
return;
}
if (!customers.isEmpty()) {
customerField.setVisible(true);
return;
}
try {
customers = restClient.getList("/imports/customers", new ParameterizedTypeReference<List<CustomerOption>>() {});
customers = customers != null ? customers : List.of();
customerField.setItems(customers);
customerField.setVisible(true);
if (!customers.isEmpty()) {
customerField.setValue(customers.getFirst());
}
} catch (Exception ex) {
customers = List.of();
customerField.setItems(List.of());
customerField.setVisible(true);
showError("Kunden fuer den Import konnten nicht geladen werden: " + ex.getMessage());
}
}
private void updateProcessPresentation() {
ImportProcessRow selectedProcess = processField.getValue();
loadCustomersIfNeeded();
if (selectedProcess == null) {
processInfo.setText("Prozess auswaehlen");
fileGrid.setItems(List.of());
return;
}
String executionMode = selectedProcess.nativeExecution()
? "Nativer NG-Import verfuegbar"
: "Archivierter Legacy-Sonderfall ohne nativen Parser";
executeButton.setText(selectedProcess.nativeExecution() ? "Nativen Import ausfuehren" : "Archivierter Sonderfall");
processInfo.setText(selectedProcess.label()
+ "\n\n"
+ "Status: " + selectedProcess.status()
+ "\n"
+ "Prioritaet: " + selectedProcess.priority()
+ "\n"
+ "Legacy-Quellen: " + joinValues(selectedProcess.legacySources())
+ "\n"
+ "NG-Nachfolger: " + fallback(selectedProcess.successorWorkspace(), "Import-Workspace")
+ "\n\n"
+ selectedProcess.description()
+ "\n\n"
+ "Entscheidung: " + fallback(selectedProcess.decisionSummary(), "-")
+ "\n\n"
+ executionMode
+ "\n\n"
+ selectedProcess.schemaHint());
updateExecuteState();
}
private void refreshFiles() {
ImportProcessRow selectedProcess = processField.getValue();
if (selectedProcess == null) {
fileGrid.setItems(List.of());
updateExecuteState();
return;
}
try {
List<ImportFileRow> files = restClient.getList(
"/imports/processes/" + selectedProcess.key() + "/files",
new ParameterizedTypeReference<List<ImportFileRow>>() {});
fileGrid.setItems(files != null ? files : List.of());
} catch (Exception ex) {
fileGrid.setItems(List.of());
showError("Dateiliste konnte nicht geladen werden: " + ex.getMessage());
}
updateExecuteState();
}
private void uploadSelectedFile(String fileName, String mimeType) {
ImportProcessRow selectedProcess = processField.getValue();
if (selectedProcess == null) {
showError("Bitte zuerst einen Prozess auswaehlen.");
upload.clearFileList();
return;
}
try {
byte[] content = uploadBuffer.getInputStream().readAllBytes();
restClient.postMultipart(
"/imports/processes/" + selectedProcess.key() + "/files",
content,
fileName,
mimeType,
Collections.emptyMap(),
ImportFileRow.class
);
showSuccess("Datei gespeichert");
refreshFiles();
} catch (Exception ex) {
showError("Upload fehlgeschlagen: " + ex.getMessage());
} finally {
upload.clearFileList();
}
}
private void executeSelectedImport() {
ImportProcessRow selectedProcess = processField.getValue();
ImportFileRow selectedFile = fileGrid.asSingleSelect().getValue();
CustomerOption selectedCustomer = customerField.getValue();
if (selectedProcess == null || selectedFile == null) {
showError("Bitte Prozess und Datei auswaehlen.");
return;
}
if (selectedProcess.customerRequired() && selectedCustomer == null) {
showError("Bitte einen Kunden auswaehlen.");
return;
}
try {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("fileName", selectedFile.fileName());
if (selectedCustomer != null) {
payload.put("customerId", selectedCustomer.id());
}
ImportExecutionResult executionResult = restClient.post(
"/imports/processes/" + selectedProcess.key() + "/execute",
payload,
ImportExecutionResult.class
);
showExecutionResult(executionResult);
refreshFiles();
} catch (Exception ex) {
showError("Import konnte nicht gestartet werden: " + ex.getMessage());
}
}
private Anchor buildDownloadLink(ImportFileRow row) {
ImportProcessRow selectedProcess = processField.getValue();
String encodedName = URLEncoder.encode(row.fileName(), StandardCharsets.UTF_8);
StreamResource resource = new StreamResource(
row.fileName(),
() -> new ByteArrayInputStream(restClient.getBytes(
"/imports/processes/" + selectedProcess.key() + "/files/content?name=" + encodedName
))
);
Anchor anchor = new Anchor(resource, "");
anchor.getElement().setAttribute("download", true);
Button button = new Button("Download");
button.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
anchor.add(button);
return anchor;
}
private Button buildDeleteButton(ImportFileRow row) {
Button button = new Button("Loeschen");
button.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
button.addClickListener(event -> {
ImportProcessRow selectedProcess = processField.getValue();
if (selectedProcess == null) {
return;
}
try {
String encodedName = URLEncoder.encode(row.fileName(), StandardCharsets.UTF_8);
restClient.delete("/imports/processes/" + selectedProcess.key() + "/files?name=" + encodedName);
showSuccess("Datei geloescht");
refreshFiles();
} catch (Exception ex) {
showError("Datei konnte nicht geloescht werden: " + ex.getMessage());
}
});
return button;
}
private void updateExecuteState() {
ImportProcessRow selectedProcess = processField.getValue();
ImportFileRow selectedFile = fileGrid.asSingleSelect().getValue();
boolean enabled = selectedProcess != null
&& selectedProcess.nativeExecution()
&& selectedFile != null
&& (!selectedProcess.customerRequired() || customerField.getValue() != null);
executeButton.setEnabled(enabled);
}
private void showExecutionResult(ImportExecutionResult result) {
if (result == null) {
resultBox.setText("Keine Rueckgabe vom Importdienst erhalten.");
return;
}
StringBuilder text = new StringBuilder();
text.append(result.success ? "Erfolg" : "Fehler").append('\n');
if (result.summary != null) {
text.append(result.summary).append('\n');
}
text.append('\n')
.append("Angelegt: ").append(result.createdCount).append('\n')
.append("Aktualisiert: ").append(result.updatedCount).append('\n')
.append("Geloescht: ").append(result.deletedCount).append('\n')
.append("Ignoriert: ").append(result.ignoredCount);
if (result.messages != null && !result.messages.isEmpty()) {
text.append("\n\nHinweise:\n");
result.messages.forEach(message -> text.append("- ").append(message).append('\n'));
}
resultBox.setText(text.toString().trim());
if (result.success) {
showSuccess("Import ausgefuehrt");
} else {
showError(result.summary != null ? result.summary : "Import fehlgeschlagen");
}
}
private String formatBytes(long size) {
if (size >= 1024 * 1024) {
return String.format("%.1f MB", size / (1024d * 1024d));
}
if (size >= 1024) {
return String.format("%.1f KB", size / 1024d);
}
return size + " B";
}
private void showSuccess(String message) {
Notification notification = Notification.show(message, 2500, Notification.Position.BOTTOM_START);
notification.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
}
private void showError(String message) {
Notification notification = Notification.show(message, 4000, Notification.Position.BOTTOM_START);
notification.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
private String joinValues(List<String> values) {
if (values == null || values.isEmpty()) {
return "-";
}
return String.join(", ", values);
}
private String fallback(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
private record ImportProcessRow(String key,
String label,
String description,
String schemaHint,
boolean nativeExecution,
boolean customerRequired,
String status,
String priority,
String decisionSummary,
String successorWorkspace,
List<String> legacySources) {
private String comboLabel() {
return label + " [" + status + " | " + priority + "]";
}
}
private record ImportFileRow(String fileName, long size, LocalDateTime modifiedAt) {
}
private record CustomerOption(Long id, String eid, String companyName) {
private String label() {
return (eid != null ? eid + " - " : "") + (companyName != null ? companyName : "Kunde " + id);
}
}
private static class ImportExecutionResult {
public boolean success;
public String summary;
public int createdCount;
public int updatedCount;
public int deletedCount;
public int ignoredCount;
public List<String> messages;
}
}

View File

@@ -0,0 +1,574 @@
package de.votian.web.view;
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.datepicker.DatePicker;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.H3;
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.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.StreamResource;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Route(value = "invoices", layout = MainLayout.class)
@PageTitle("Votian - Rechnungen")
public class InvoiceOverviewView extends VerticalLayout {
private static final long ALL_COST_CENTERS_ID = -1L;
private final RestClientService restClient;
private final SessionData sessionData;
private final DatePicker fromDate = new DatePicker("Von");
private final DatePicker toDate = new DatePicker("Bis");
private final ComboBox<CostCenterOption> costCenterField = new ComboBox<>("Kostenstelle");
private final Span totalJobs = new Span("-");
private final Span billableJobs = new Span("-");
private final Span totalRevenue = new Span("-");
private final Span totalCourierCosts = new Span("-");
private final Span margin = new Span("-");
private final Grid<CustomerSummaryRow> customerGrid = new Grid<>(CustomerSummaryRow.class, false);
private final Grid<InvoiceRow> invoiceGrid = new Grid<>(InvoiceRow.class, false);
private final Span detailJob = new Span("-");
private final Span detailCustomer = new Span("-");
private final Span detailCostCenter = new Span("-");
private final Span detailTimes = new Span("-");
private final Span detailRoute = new Span("-");
private final Span detailCourier = new Span("-");
private final Span detailStatus = new Span("-");
private final Pre serviceDetails = new Pre();
private final Pre invoiceText = new Pre();
private final Button openJobButton = new Button("Auftrag oeffnen");
private final Anchor csvExportLink = new Anchor();
private final Anchor pdfExportLink = new Anchor();
private final Button csvExportButton = new Button("CSV exportieren");
private final Button pdfExportButton = new Button("PDF exportieren");
private CustomerSummaryRow selectedCustomer;
private InvoiceRow selectedInvoice;
public InvoiceOverviewView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setSizeFull();
setPadding(true);
setSpacing(true);
configureFilters();
configureCustomerGrid();
configureInvoiceGrid();
configureDetails();
configureExportLinks();
HorizontalLayout filterBar = buildFilterBar();
HorizontalLayout cards = buildCards();
HorizontalLayout workspace = buildWorkspace();
add(new H3("Rechnungen"), filterBar, cards, workspace);
expand(workspace);
loadWorkspace();
}
private void configureExportLinks() {
csvExportButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
pdfExportButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
csvExportLink.add(csvExportButton);
pdfExportLink.add(pdfExportButton);
csvExportLink.getElement().setAttribute("download", true);
pdfExportLink.getElement().setAttribute("download", true);
updateExportLinks();
}
private void configureFilters() {
fromDate.setValue(LocalDate.now().withDayOfMonth(1));
toDate.setValue(LocalDate.now());
costCenterField.setItemLabelGenerator(CostCenterOption::label);
costCenterField.setWidth("360px");
costCenterField.addValueChangeListener(event -> {
if (selectedCustomer != null) {
loadInvoiceItems();
}
});
}
private HorizontalLayout buildFilterBar() {
Button reloadButton = new Button("Neu laden", event -> loadWorkspace());
reloadButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
HorizontalLayout filterBar = new HorizontalLayout(fromDate, toDate, reloadButton);
filterBar.setAlignItems(Alignment.BASELINE);
return filterBar;
}
private HorizontalLayout buildCards() {
HorizontalLayout cards = new HorizontalLayout(
createCard("Auftraege", totalJobs),
createCard("Abrechenbar", billableJobs),
createCard("Umsatz", totalRevenue),
createCard("Kurierkosten", totalCourierCosts),
createCard("Deckungsbeitrag", margin)
);
cards.setWidthFull();
return cards;
}
private HorizontalLayout buildWorkspace() {
VerticalLayout customerPanel = new VerticalLayout(new H4("Kunden mit Rechnungsfaellen"), customerGrid);
customerPanel.setPadding(false);
customerPanel.setSpacing(true);
customerPanel.setWidth("360px");
customerPanel.setHeightFull();
customerPanel.expand(customerGrid);
Button refreshItemsButton = new Button("Details laden", event -> loadInvoiceItems());
HorizontalLayout invoiceToolbar = new HorizontalLayout(costCenterField, refreshItemsButton, csvExportLink, pdfExportLink);
invoiceToolbar.setAlignItems(Alignment.BASELINE);
VerticalLayout detailPanel = buildDetailPanel();
VerticalLayout invoicePanel = new VerticalLayout(new H4("Rechnungsdetails"), invoiceToolbar, invoiceGrid, detailPanel);
invoicePanel.setPadding(false);
invoicePanel.setSpacing(true);
invoicePanel.setSizeFull();
invoicePanel.expand(invoiceGrid);
HorizontalLayout workspace = new HorizontalLayout(customerPanel, invoicePanel);
workspace.setSizeFull();
workspace.expand(invoicePanel);
return workspace;
}
private void configureCustomerGrid() {
customerGrid.addColumn(CustomerSummaryRow::customerName).setHeader("Kunde").setFlexGrow(1);
customerGrid.addColumn(CustomerSummaryRow::customerEid).setHeader("EID").setAutoWidth(true);
customerGrid.addColumn(CustomerSummaryRow::invoiceableJobs).setHeader("Faelle").setAutoWidth(true);
customerGrid.addColumn(row -> formatAmount(row.totalRevenue())).setHeader("Umsatz").setAutoWidth(true);
customerGrid.setSizeFull();
customerGrid.asSingleSelect().addValueChangeListener(event -> {
selectedCustomer = event.getValue();
selectedInvoice = null;
clearDetail();
if (selectedCustomer != null) {
loadCostCenters(selectedCustomer.customerId());
loadInvoiceItems();
} else {
costCenterField.clear();
costCenterField.setItems(List.of());
invoiceGrid.setItems(List.of());
}
});
}
private void configureInvoiceGrid() {
invoiceGrid.addColumn(InvoiceRow::jobId).setHeader("Auftrag").setAutoWidth(true).setFrozen(true);
invoiceGrid.addColumn(InvoiceRow::finishTime).setHeader("Abschluss").setAutoWidth(true);
invoiceGrid.addColumn(InvoiceRow::costCenterName).setHeader("Kostenstelle").setAutoWidth(true);
invoiceGrid.addColumn(InvoiceRow::commissionNo).setHeader("Kommission").setAutoWidth(true);
invoiceGrid.addColumn(InvoiceRow::pickupName).setHeader("Abholung").setFlexGrow(1);
invoiceGrid.addColumn(InvoiceRow::deliveryName).setHeader("Zustellung").setFlexGrow(1);
invoiceGrid.addColumn(InvoiceRow::courierSid).setHeader("Kurier").setAutoWidth(true);
invoiceGrid.addColumn(row -> formatAmount(row.totalPrice())).setHeader("Umsatz").setAutoWidth(true);
invoiceGrid.addColumn(row -> formatAmount(row.courierPrice())).setHeader("Kurierkosten").setAutoWidth(true);
invoiceGrid.addColumn(row -> formatAmount(row.margin())).setHeader("Marge").setAutoWidth(true);
invoiceGrid.addColumn(row -> row.exported() ? "Ja" : "Nein").setHeader("Export").setAutoWidth(true);
invoiceGrid.setSizeFull();
invoiceGrid.asSingleSelect().addValueChangeListener(event -> {
selectedInvoice = event.getValue();
updateDetail();
});
}
private void configureDetails() {
configureTextBlock(serviceDetails);
configureTextBlock(invoiceText);
openJobButton.setEnabled(false);
openJobButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
openJobButton.addClickListener(event -> {
if (selectedInvoice != null && sessionData.canViewJobs()) {
getUI().ifPresent(ui -> ui.navigate("jobs/" + selectedInvoice.jobId()));
}
});
clearDetail();
}
private VerticalLayout buildDetailPanel() {
VerticalLayout detailPanel = new VerticalLayout();
detailPanel.setPadding(true);
detailPanel.setSpacing(true);
detailPanel.getStyle()
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-l)");
detailPanel.add(
new H4("Ausgewaehlter Rechnungsfall"),
detailLine("Auftrag", detailJob),
detailLine("Kunde", detailCustomer),
detailLine("Kostenstelle", detailCostCenter),
detailLine("Zeiten", detailTimes),
detailLine("Route", detailRoute),
detailLine("Kurier", detailCourier),
detailLine("Status", detailStatus),
new H4("Leistungen"),
serviceDetails,
new H4("Rechnungstext"),
invoiceText,
openJobButton
);
return detailPanel;
}
private HorizontalLayout detailLine(String label, Span value) {
Span headline = new Span(label + ":");
headline.getStyle().set("font-weight", "600");
HorizontalLayout line = new HorizontalLayout(headline, value);
line.setPadding(false);
line.setSpacing(true);
return line;
}
private VerticalLayout createCard(String title, Span value) {
VerticalLayout card = new VerticalLayout();
card.setPadding(true);
card.setSpacing(false);
card.getStyle()
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-l)")
.set("min-width", "180px");
H3 headline = new H3(title);
headline.getStyle().set("margin", "0");
value.getStyle().set("font-size", "var(--lumo-font-size-xl)").set("font-weight", "700");
card.add(headline, value);
return card;
}
private void loadWorkspace() {
if (sessionData.getHeadquartersId() == null) {
return;
}
loadSummary();
loadCustomers();
}
private void loadSummary() {
try {
@SuppressWarnings("unchecked")
Map<String, Object> data = restClient.get(
"/jobs/invoice-summary?hqId=" + sessionData.getHeadquartersId()
+ "&from=" + fromDate.getValue() + "&to=" + toDate.getValue(),
LinkedHashMap.class
);
totalJobs.setText(str(data.get("totalJobs")));
billableJobs.setText(str(data.get("billableJobs")));
totalRevenue.setText(str(data.get("totalRevenue")));
totalCourierCosts.setText(str(data.get("totalCourierCosts")));
margin.setText(str(data.get("contributionMargin")));
} catch (Exception e) {
Notification.show("Rechnungssummary konnte nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadCustomers() {
try {
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/invoices/customers?hqId=" + sessionData.getHeadquartersId()
+ "&from=" + fromDate.getValue() + "&to=" + toDate.getValue(),
new ParameterizedTypeReference<>() {}
);
List<CustomerSummaryRow> rows = new ArrayList<>();
for (Map<String, Object> row : data) {
rows.add(new CustomerSummaryRow(
toLong(row.get("customerId")),
str(row.get("customerName")),
str(row.get("customerEid")),
toLong(row.get("invoiceableJobs")) != null ? toLong(row.get("invoiceableJobs")) : 0L,
toBigDecimal(row.get("totalRevenue"))
));
}
rows.sort(Comparator.comparing(CustomerSummaryRow::customerName, String.CASE_INSENSITIVE_ORDER));
customerGrid.setItems(rows);
if (!rows.isEmpty()) {
customerGrid.select(rows.get(0));
} else {
selectedCustomer = null;
invoiceGrid.setItems(List.of());
costCenterField.setItems(List.of());
clearDetail();
}
updateExportLinks();
} catch (Exception e) {
Notification.show("Rechnungskunden konnten nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadCostCenters(Long customerId) {
try {
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/customers/" + customerId + "/costcenters/visible",
new ParameterizedTypeReference<>() {}
);
List<CostCenterOption> options = new ArrayList<>();
options.add(new CostCenterOption(ALL_COST_CENTERS_ID, "Alle sichtbaren Kostenstellen"));
for (Map<String, Object> row : data) {
Long id = toLong(row.get("id"));
if (id != null && (!sessionData.isCustomerUser() || sessionData.hasCostCenterAccess(id))) {
options.add(new CostCenterOption(id, str(row.get("name"))));
}
}
costCenterField.setItems(options);
costCenterField.setValue(options.get(0));
updateExportLinks();
} catch (Exception e) {
Notification.show("Kostenstellen konnten nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadInvoiceItems() {
if (selectedCustomer == null) {
return;
}
try {
CostCenterOption selectedCostCenter = costCenterField.getValue();
StringBuilder path = new StringBuilder("/invoices/items?hqId=")
.append(sessionData.getHeadquartersId())
.append("&customerId=").append(selectedCustomer.customerId())
.append("&from=").append(fromDate.getValue())
.append("&to=").append(toDate.getValue());
if (selectedCostCenter != null && selectedCostCenter.id() != ALL_COST_CENTERS_ID) {
path.append("&costCenterId=").append(selectedCostCenter.id());
}
List<LinkedHashMap<String, Object>> data = restClient.getList(
path.toString(),
new ParameterizedTypeReference<>() {}
);
List<InvoiceRow> rows = new ArrayList<>();
for (Map<String, Object> row : data) {
rows.add(new InvoiceRow(
toLong(row.get("jobId")),
toLong(row.get("costCenterId")),
str(row.get("costCenterName")),
str(row.get("customerName")),
str(row.get("orderTime")),
str(row.get("finishTime")),
str(row.get("courierSid")),
str(row.get("tourName")),
str(row.get("commissionNo")),
str(row.get("pickupName")),
str(row.get("deliveryName")),
toInteger(row.get("status")),
toInteger(row.get("incomplete")),
toBigDecimal(row.get("totalPrice")),
toBigDecimal(row.get("courierPrice")),
toBigDecimal(row.get("margin")),
Boolean.TRUE.equals(row.get("exported")) || "true".equalsIgnoreCase(str(row.get("exported"))),
blank(row.get("serviceDetails")),
blank(row.get("invoiceText"))
));
}
invoiceGrid.setItems(rows);
invoiceGrid.asSingleSelect().clear();
clearDetail();
if (!rows.isEmpty()) {
invoiceGrid.select(rows.get(0));
}
updateExportLinks();
} catch (Exception e) {
Notification.show("Rechnungsdetails konnten nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void updateExportLinks() {
boolean enabled = selectedCustomer != null && sessionData.canExportInvoices();
csvExportButton.setEnabled(enabled);
pdfExportButton.setEnabled(enabled);
if (!enabled) {
csvExportLink.setHref("");
pdfExportLink.setHref("");
return;
}
csvExportLink.setHref(exportResource("csv"));
pdfExportLink.setHref(exportResource("pdf"));
}
private StreamResource exportResource(String format) {
String fileName = "invoices_" + selectedCustomer.customerId() + "_" + fromDate.getValue() + "_" + toDate.getValue() + "." + format;
return new StreamResource(fileName, () -> new ByteArrayInputStream(restClient.getBytes(buildExportPath(format))));
}
private String buildExportPath(String format) {
StringBuilder path = new StringBuilder("/invoices/export/")
.append(format)
.append("?hqId=").append(sessionData.getHeadquartersId())
.append("&customerId=").append(selectedCustomer.customerId())
.append("&from=").append(fromDate.getValue())
.append("&to=").append(toDate.getValue());
CostCenterOption selectedCostCenter = costCenterField.getValue();
if (selectedCostCenter != null && selectedCostCenter.id() != ALL_COST_CENTERS_ID) {
path.append("&costCenterId=").append(selectedCostCenter.id());
}
return path.toString();
}
private void updateDetail() {
if (selectedInvoice == null) {
clearDetail();
return;
}
detailJob.setText(str(selectedInvoice.jobId()));
detailCustomer.setText(selectedInvoice.customerName());
detailCostCenter.setText(selectedInvoice.costCenterName());
detailTimes.setText(formatDateTime(selectedInvoice.orderTime()) + " / " + formatDateTime(selectedInvoice.finishTime()));
detailRoute.setText(selectedInvoice.pickupName() + " -> " + selectedInvoice.deliveryName());
detailCourier.setText(str(selectedInvoice.courierSid()));
detailStatus.setText(statusLabel(selectedInvoice.status(), selectedInvoice.incomplete(), selectedInvoice.exported()));
serviceDetails.setText(selectedInvoice.serviceDetails().isBlank() ? "(keine Leistungen)" : selectedInvoice.serviceDetails());
invoiceText.setText(selectedInvoice.invoiceText().isBlank() ? "(kein Rechnungstext)" : selectedInvoice.invoiceText());
openJobButton.setEnabled(sessionData.canViewJobs());
}
private void clearDetail() {
detailJob.setText("-");
detailCustomer.setText("-");
detailCostCenter.setText("-");
detailTimes.setText("-");
detailRoute.setText("-");
detailCourier.setText("-");
detailStatus.setText("-");
serviceDetails.setText("(keine Leistungen)");
invoiceText.setText("(kein Rechnungsfall ausgewaehlt)");
openJobButton.setEnabled(false);
}
private void configureTextBlock(Pre block) {
block.setWidthFull();
block.getStyle()
.set("background", "var(--lumo-contrast-5pct)")
.set("padding", "var(--lumo-space-m)")
.set("border-radius", "var(--lumo-border-radius-m)")
.set("white-space", "pre-wrap")
.set("margin", "0");
}
private String statusLabel(Integer status, Integer incomplete, boolean exported) {
String base = switch (status != null ? status : -1) {
case 0 -> "Blockiert";
case 1 -> "Zugewiesen";
case 2 -> "In Zustellung";
case 8 -> "Zugestellt";
case 9 -> "Abgeschlossen";
case 10 -> "Storniert";
default -> status != null ? String.valueOf(status) : "-";
};
if (Boolean.TRUE.equals(exported)) {
return base + ", exportiert";
}
if (incomplete != null && incomplete > 0) {
return base + ", unvollstaendig";
}
return base;
}
private String formatDateTime(String value) {
return value == null || value.isBlank() ? "-" : value.replace("T", " ");
}
private String formatAmount(BigDecimal value) {
return value != null ? value.toPlainString() : "-";
}
private BigDecimal toBigDecimal(Object value) {
if (value == null) {
return null;
}
if (value instanceof BigDecimal decimal) {
return decimal;
}
try {
return new BigDecimal(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.longValue();
}
try {
return Long.parseLong(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
private Integer toInteger(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.intValue();
}
try {
return Integer.parseInt(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
private String str(Object value) {
return value != null ? value.toString() : "-";
}
private String blank(Object value) {
return value != null ? value.toString() : "";
}
private record CustomerSummaryRow(Long customerId, String customerName, String customerEid,
long invoiceableJobs, BigDecimal totalRevenue) { }
private record CostCenterOption(Long id, String label) { }
private record InvoiceRow(Long jobId, Long costCenterId, String costCenterName, String customerName,
String orderTime, String finishTime, String courierSid, String tourName,
String commissionNo, String pickupName, String deliveryName,
Integer status, Integer incomplete, BigDecimal totalPrice,
BigDecimal courierPrice, BigDecimal margin, boolean exported,
String serviceDetails, String invoiceText) { }
}

View File

@@ -0,0 +1,369 @@
package de.votian.web.view;
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.datetimepicker.DateTimePicker;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Route(value = "jobs/batch", layout = MainLayout.class)
@PageTitle("Votian - Listenerfassung")
public class JobBatchView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final ComboBox<CustomerOption> customerField = new ComboBox<>("Kunde");
private final ComboBox<CostCenterOption> costCenterField = new ComboBox<>("Kostenstelle");
private final ComboBox<CourierOption> defaultCourierField = new ComboBox<>("Standard-Kurier");
private final VerticalLayout rowsLayout = new VerticalLayout();
private final List<BatchRowEditor> rowEditors = new ArrayList<>();
public JobBatchView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setSizeFull();
setPadding(true);
setSpacing(true);
customerField.setItemLabelGenerator(CustomerOption::label);
customerField.setWidth("360px");
customerField.addValueChangeListener(event -> loadCostCenters(event.getValue() != null ? event.getValue().id() : null));
costCenterField.setItemLabelGenerator(CostCenterOption::label);
costCenterField.setWidth("360px");
defaultCourierField.setItemLabelGenerator(CourierOption::label);
defaultCourierField.setWidth("260px");
rowsLayout.setPadding(false);
rowsLayout.setSpacing(true);
rowsLayout.setWidthFull();
Button addRowButton = new Button("Zeile hinzufuegen", event -> addRow(null));
Button saveButton = new Button("Liste buchen", event -> saveBatch());
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button cancelButton = new Button("Zurueck", event -> getUI().ifPresent(ui -> ui.navigate(JobListView.class)));
HorizontalLayout actions = new HorizontalLayout(addRowButton, saveButton, cancelButton);
add(
new H3("Listenerfassung"),
new Paragraph("Erzeugt mehrere abgeschlossene Listenbuchungen fuer einen Kunden und eine Kostenstelle."),
new HorizontalLayout(customerField, costCenterField, defaultCourierField),
rowsLayout,
actions
);
loadCustomers();
loadCouriers();
addInitialRows();
}
private void loadCustomers() {
try {
List<CustomerOption> customers = new ArrayList<>();
if (sessionData.isCustomerUser() && sessionData.getCustomerId() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> customer = restClient.get("/customers/" + sessionData.getCustomerId(), LinkedHashMap.class);
customers.add(new CustomerOption(
sessionData.getCustomerId(),
str(customer.get("companyName")).isBlank() ? str(customer.get("eid")) : str(customer.get("companyName"))
));
} else {
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/customers/hq/" + sessionData.getHeadquartersId(),
new ParameterizedTypeReference<>() {}
);
for (Map<String, Object> row : data) {
customers.add(new CustomerOption(
toLong(row.get("id")),
str(row.get("companyName")).isBlank() ? str(row.get("eid")) : str(row.get("companyName"))
));
}
}
customerField.setItems(customers);
if (!customers.isEmpty()) {
customerField.setValue(customers.get(0));
}
customerField.setEnabled(!sessionData.isCustomerUser());
} catch (Exception e) {
Notification.show("Kunden konnten nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadCostCenters(Long customerId) {
if (customerId == null) {
costCenterField.clear();
costCenterField.setItems(List.of());
return;
}
try {
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/customers/" + customerId + "/costcenters/visible",
new ParameterizedTypeReference<>() {}
);
List<CostCenterOption> options = new ArrayList<>();
for (Map<String, Object> row : data) {
options.add(new CostCenterOption(
toLong(row.get("id")),
str(row.get("path")).isBlank()
? str(row.get("name"))
: str(row.get("path")) + " / " + str(row.get("name"))
));
}
costCenterField.setItems(options);
if (!options.isEmpty()) {
costCenterField.setValue(options.get(0));
}
} catch (Exception e) {
Notification.show("Kostenstellen konnten nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadCouriers() {
try {
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/couriers/hq/" + sessionData.getHeadquartersId(),
new ParameterizedTypeReference<>() {}
);
List<CourierOption> options = new ArrayList<>();
options.add(new CourierOption(null, "Keine Zuordnung"));
for (Map<String, Object> row : data) {
options.add(new CourierOption(
toLong(row.get("id")),
str(row.get("sid")).isBlank() ? str(row.get("eid")) : str(row.get("sid"))
));
}
defaultCourierField.setItems(options);
defaultCourierField.setValue(options.get(0));
} catch (Exception e) {
Notification.show("Kuriere konnten nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void addInitialRows() {
if (rowEditors.isEmpty()) {
addRow(null);
addRow(null);
addRow(null);
}
}
private void addRow(BatchRowState initialState) {
BatchRowEditor editor = new BatchRowEditor(initialState);
rowEditors.add(editor);
rowsLayout.add(editor);
}
private void removeRow(BatchRowEditor editor) {
if (rowEditors.size() == 1) {
editor.clear();
return;
}
rowEditors.remove(editor);
rowsLayout.remove(editor);
}
private void saveBatch() {
if (customerField.getValue() == null || costCenterField.getValue() == null) {
Notification.show("Bitte Kunde und Kostenstelle waehlen.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
List<Map<String, Object>> rows = new ArrayList<>();
for (BatchRowEditor editor : rowEditors) {
if (!editor.hasContent()) {
continue;
}
if (!editor.isValid()) {
Notification.show("Bitte alle ausgefuellten Zeilen vollstaendig erfassen.",
4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
rows.add(editor.toRequest(defaultCourierField.getValue()));
}
if (rows.isEmpty()) {
Notification.show("Bitte mindestens eine Zeile erfassen.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("hqId", sessionData.getHeadquartersId());
request.put("customerId", customerField.getValue().id());
request.put("costCenterId", costCenterField.getValue().id());
request.put("rows", rows);
restClient.post("/jobs/batch", request, Object[].class);
Notification.show("Listenerfassung gespeichert.", 3000, Notification.Position.BOTTOM_START);
getUI().ifPresent(ui -> ui.navigate(JobListView.class));
} catch (Exception e) {
Notification.show("Listenerfassung konnte nicht gespeichert werden: " + e.getMessage(),
5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.longValue();
}
try {
return Long.parseLong(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
private String str(Object value) {
return value != null ? value.toString() : "";
}
private record CustomerOption(Long id, String label) {
}
private record CostCenterOption(Long id, String label) {
}
private record CourierOption(Long id, String label) {
}
private record BatchRowState(LocalDateTime orderTime,
String description,
String commissionNo,
Double totalPrice,
Double courierPrice,
Long courierId) {
}
private class BatchRowEditor extends VerticalLayout {
private final DateTimePicker orderTimeField = new DateTimePicker("Zeit");
private final TextField descriptionField = new TextField("Auftrag / Tourenverlauf");
private final TextField commissionField = new TextField("Kst./Referenz");
private final NumberField totalPriceField = new NumberField("Fuhrbetrag");
private final NumberField courierPriceField = new NumberField("Kurierpreis");
private final ComboBox<CourierOption> courierField = new ComboBox<>("Kurier");
private BatchRowEditor(BatchRowState initialState) {
setPadding(true);
setSpacing(true);
getStyle()
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-l)");
orderTimeField.setWidth("260px");
orderTimeField.setValue(initialState != null && initialState.orderTime() != null
? initialState.orderTime()
: LocalDateTime.now().withSecond(0).withNano(0));
descriptionField.setWidthFull();
commissionField.setWidth("220px");
totalPriceField.setWidth("180px");
courierPriceField.setWidth("180px");
courierField.setItems(defaultCourierField.getListDataView().getItems().toList());
courierField.setItemLabelGenerator(CourierOption::label);
courierField.setWidth("220px");
courierField.setValue(resolveCourier(initialState != null ? initialState.courierId() : null));
if (initialState != null) {
descriptionField.setValue(initialState.description() != null ? initialState.description() : "");
commissionField.setValue(initialState.commissionNo() != null ? initialState.commissionNo() : "");
totalPriceField.setValue(initialState.totalPrice());
courierPriceField.setValue(initialState.courierPrice());
}
Button removeButton = new Button("Zeile entfernen", event -> removeRow(this));
removeButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
FormLayout form = new FormLayout();
form.setWidthFull();
form.setResponsiveSteps(
new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("720px", 2),
new FormLayout.ResponsiveStep("1080px", 3)
);
form.add(orderTimeField, descriptionField, commissionField, totalPriceField, courierPriceField, courierField);
add(form, removeButton);
}
private void clear() {
descriptionField.clear();
commissionField.clear();
totalPriceField.clear();
courierPriceField.clear();
courierField.setValue(resolveCourier(null));
}
private boolean hasContent() {
return !descriptionField.getValue().isBlank()
|| !commissionField.getValue().isBlank()
|| totalPriceField.getValue() != null
|| courierPriceField.getValue() != null
|| (courierField.getValue() != null && courierField.getValue().id() != null);
}
private boolean isValid() {
return orderTimeField.getValue() != null
&& !descriptionField.getValue().isBlank()
&& totalPriceField.getValue() != null;
}
private Map<String, Object> toRequest(CourierOption defaultCourier) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("orderTime", orderTimeField.getValue().toString());
row.put("description", descriptionField.getValue());
row.put("commissionNo", commissionField.getValue());
row.put("totalPrice", decimal(totalPriceField.getValue()));
row.put("courierPrice", decimal(courierPriceField.getValue()));
CourierOption selectedCourier = courierField.getValue() != null ? courierField.getValue() : defaultCourier;
row.put("courierId", selectedCourier != null ? selectedCourier.id() : null);
return row;
}
private CourierOption resolveCourier(Long courierId) {
return defaultCourierField.getListDataView().getItems()
.filter(option -> option.id() == null ? courierId == null : option.id().equals(courierId))
.findFirst()
.orElse(defaultCourierField.getListDataView().getItems().findFirst().orElse(null));
}
private BigDecimal decimal(Double value) {
return value != null ? BigDecimal.valueOf(value) : null;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,438 @@
package de.votian.web.view;
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.datepicker.DatePicker;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.server.StreamResource;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Route(value = "jobs", layout = MainLayout.class)
@PageTitle("Votian - Auftraege")
public class JobListView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final TextField searchField = new TextField();
private final DatePicker fromDate = new DatePicker("Von");
private final DatePicker toDate = new DatePicker("Bis");
private final ComboBox<StatusOption> statusField = new ComboBox<>("Status");
private final ComboBox<ExportOption> exportField = new ComboBox<>("Export");
private final ComboBox<CostCenterOption> costCenterField = new ComboBox<>("Kostenstelle");
private final TextField courierField = new TextField("Kurier");
private final Anchor csvExportLink = new Anchor();
private final Button csvExportButton = new Button("CSV exportieren");
private final Grid<JobRow> grid = new Grid<>(JobRow.class, false);
public JobListView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setSizeFull();
setPadding(true);
setSpacing(true);
configureFilters();
configureGrid();
configureExportLink();
Button searchButton = new Button("Suchen", VaadinIcon.SEARCH.create(), event -> {
updateExportLink();
loadJobs();
});
searchButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button resetButton = new Button("Zuruecksetzen", event -> resetFilters());
Button newButton = new Button("Neuer Auftrag", VaadinIcon.PLUS.create(),
event -> getUI().ifPresent(ui -> ui.navigate(JobDetailView.class)));
newButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
newButton.setVisible(sessionData.canManageJobs());
Button batchButton = new Button("Listenerfassung", VaadinIcon.TABLE.create(),
event -> getUI().ifPresent(ui -> ui.navigate(JobBatchView.class)));
batchButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
batchButton.setVisible(sessionData.canBatchJobs());
HorizontalLayout toolbar = new HorizontalLayout(
searchField,
fromDate,
toDate,
statusField,
exportField,
costCenterField,
courierField,
csvExportLink,
searchButton,
resetButton,
newButton,
batchButton
);
toolbar.setAlignItems(Alignment.BASELINE);
toolbar.setWidthFull();
add(toolbar, grid);
expand(grid);
loadCostCenters();
loadJobs();
}
private void configureFilters() {
searchField.setPlaceholder("Auftrag, Kommission, Kunde ...");
searchField.setPrefixComponent(VaadinIcon.SEARCH.create());
searchField.setValueChangeMode(ValueChangeMode.LAZY);
searchField.setWidth("260px");
searchField.addValueChangeListener(event -> {
updateExportLink();
loadJobs();
});
fromDate.setValue(LocalDate.now());
fromDate.addValueChangeListener(event -> updateExportLink());
toDate.setValue(LocalDate.now());
toDate.addValueChangeListener(event -> updateExportLink());
statusField.setItems(StatusOption.values());
statusField.setItemLabelGenerator(StatusOption::label);
statusField.setValue(StatusOption.ALL);
statusField.setWidth("170px");
statusField.addValueChangeListener(event -> updateExportLink());
exportField.setItems(ExportOption.values());
exportField.setItemLabelGenerator(ExportOption::label);
exportField.setValue(ExportOption.ALL);
exportField.setWidth("160px");
exportField.addValueChangeListener(event -> updateExportLink());
costCenterField.setItemLabelGenerator(CostCenterOption::label);
costCenterField.setWidth("260px");
costCenterField.addValueChangeListener(event -> updateExportLink());
courierField.setWidth("180px");
courierField.setValueChangeMode(ValueChangeMode.LAZY);
courierField.addValueChangeListener(event -> updateExportLink());
}
private void configureExportLink() {
csvExportButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
csvExportLink.add(csvExportButton);
csvExportLink.getElement().setAttribute("download", true);
csvExportLink.setVisible(sessionData.canExportJobs());
updateExportLink();
}
private void configureGrid() {
grid.addColumn(JobRow::id).setHeader("Auftrag").setSortable(true).setAutoWidth(true).setFrozen(true);
grid.addColumn(row -> statusLabel(row.status())).setHeader("Status").setAutoWidth(true);
grid.addColumn(row -> formatDateTime(row.orderTime())).setHeader("Auftragszeit").setSortable(true).setAutoWidth(true);
grid.addColumn(JobRow::customerName).setHeader("Kunde").setFlexGrow(1);
grid.addColumn(JobRow::costCenterName).setHeader("Kostenstelle").setAutoWidth(true);
grid.addColumn(JobRow::commissionNo).setHeader("Kommission").setAutoWidth(true);
grid.addColumn(JobRow::pickupName).setHeader("Abholung").setFlexGrow(1);
grid.addColumn(JobRow::deliveryName).setHeader("Zustellung").setFlexGrow(1);
grid.addColumn(JobRow::courierSid).setHeader("Kurier SID").setAutoWidth(true);
grid.addColumn(row -> formatAmount(row.totalPrice())).setHeader("Gesamtpreis").setAutoWidth(true);
grid.addColumn(row -> row.exported() ? "Ja" : "Nein").setHeader("Export").setAutoWidth(true);
grid.addColumn(row -> vehicleLabel(row.vehicleTypeId())).setHeader("Fahrzeug").setAutoWidth(true);
grid.setSizeFull();
grid.addItemClickListener(event -> {
if (sessionData.canViewJobs()) {
getUI().ifPresent(ui -> ui.navigate("jobs/" + event.getItem().id()));
}
});
}
private void loadCostCenters() {
try {
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/jobs/filter-costcenters?hqId=" + sessionData.getHeadquartersId(),
new ParameterizedTypeReference<>() {}
);
List<CostCenterOption> options = new ArrayList<>();
options.add(new CostCenterOption(null, "Alle Kostenstellen"));
for (Map<String, Object> row : data) {
options.add(new CostCenterOption(
toLong(row.get("id")),
str(row.get("path")).isBlank()
? str(row.get("name"))
: str(row.get("path")) + " / " + str(row.get("name"))
));
}
costCenterField.setItems(options);
costCenterField.setValue(options.get(0));
updateExportLink();
} catch (Exception e) {
Notification.show("Kostenstellenfilter konnte nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadJobs() {
if (sessionData.getHeadquartersId() == null || fromDate.getValue() == null || toDate.getValue() == null) {
return;
}
try {
StringBuilder path = new StringBuilder("/jobs/list?hqId=")
.append(sessionData.getHeadquartersId())
.append("&from=").append(fromDate.getValue())
.append("&to=").append(toDate.getValue());
if (!searchField.getValue().isBlank()) {
path.append("&term=").append(encode(searchField.getValue()));
}
if (statusField.getValue() != null && statusField.getValue().value() != null) {
path.append("&status=").append(statusField.getValue().value());
}
if (costCenterField.getValue() != null && costCenterField.getValue().id() != null) {
path.append("&costCenterId=").append(costCenterField.getValue().id());
}
if (!courierField.getValue().isBlank()) {
path.append("&courierSid=").append(encode(courierField.getValue()));
}
if (exportField.getValue() != null && exportField.getValue().value() != null) {
path.append("&exported=").append(exportField.getValue().value());
}
List<LinkedHashMap<String, Object>> data = restClient.getList(
path.toString(),
new ParameterizedTypeReference<>() {}
);
List<JobRow> rows = new ArrayList<>();
for (Map<String, Object> row : data) {
rows.add(new JobRow(
toLong(row.get("id")),
toInteger(row.get("status")),
str(row.get("orderTime")),
str(row.get("customerName")),
str(row.get("costCenterName")),
str(row.get("commissionNo")),
str(row.get("pickupName")),
str(row.get("deliveryName")),
str(row.get("courierSid")),
toInteger(row.get("vehicleTypeId")),
toBigDecimal(row.get("totalPrice")),
Boolean.TRUE.equals(row.get("exported")) || "true".equalsIgnoreCase(str(row.get("exported")))
));
}
grid.setItems(rows);
} catch (Exception e) {
Notification.show("Auftragsliste konnte nicht geladen werden: " + e.getMessage(),
5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void resetFilters() {
searchField.clear();
fromDate.setValue(LocalDate.now());
toDate.setValue(LocalDate.now());
statusField.setValue(StatusOption.ALL);
exportField.setValue(ExportOption.ALL);
if (!costCenterField.getListDataView().getItems().toList().isEmpty()) {
costCenterField.setValue(costCenterField.getListDataView().getItems().findFirst().orElse(null));
}
courierField.clear();
updateExportLink();
loadJobs();
}
private void updateExportLink() {
boolean enabled = sessionData.canExportJobs()
&& sessionData.getHeadquartersId() != null
&& fromDate.getValue() != null
&& toDate.getValue() != null;
csvExportButton.setEnabled(enabled);
if (!enabled) {
csvExportLink.setHref("");
return;
}
StreamResource csv = new StreamResource(
"jobs_" + fromDate.getValue() + "_" + toDate.getValue() + ".csv",
() -> new ByteArrayInputStream(restClient.getBytes(buildExportPath()))
);
csvExportLink.setHref(csv);
}
private String buildExportPath() {
StringBuilder path = new StringBuilder("/jobs/export/csv?hqId=")
.append(sessionData.getHeadquartersId())
.append("&from=").append(fromDate.getValue())
.append("&to=").append(toDate.getValue());
if (!searchField.getValue().isBlank()) {
path.append("&term=").append(encode(searchField.getValue()));
}
if (statusField.getValue() != null && statusField.getValue().value() != null) {
path.append("&status=").append(statusField.getValue().value());
}
if (costCenterField.getValue() != null && costCenterField.getValue().id() != null) {
path.append("&costCenterId=").append(costCenterField.getValue().id());
}
if (!courierField.getValue().isBlank()) {
path.append("&courierSid=").append(encode(courierField.getValue()));
}
if (exportField.getValue() != null && exportField.getValue().value() != null) {
path.append("&exported=").append(exportField.getValue().value());
}
return path.toString();
}
private String encode(String value) {
return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
}
private String statusLabel(Integer status) {
if (status == null) {
return "";
}
return switch (status) {
case 0 -> "Blockiert";
case 1 -> "Zugewiesen";
case 2 -> "In Zustellung";
case 8 -> "Zugestellt";
case 9 -> "Abgeschlossen";
case 10 -> "Storniert";
default -> String.valueOf(status);
};
}
private String vehicleLabel(Integer vehicleTypeId) {
return vehicleTypeId != null ? "Typ " + vehicleTypeId : "";
}
private String formatDateTime(String value) {
return value == null || value.isBlank() ? "" : value.replace("T", " ");
}
private String formatAmount(BigDecimal value) {
return value != null ? value.toPlainString() : "";
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.longValue();
}
try {
return Long.parseLong(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
private Integer toInteger(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.intValue();
}
try {
return Integer.parseInt(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
private BigDecimal toBigDecimal(Object value) {
if (value == null) {
return null;
}
if (value instanceof BigDecimal decimal) {
return decimal;
}
try {
return new BigDecimal(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
private String str(Object value) {
return value != null ? value.toString() : "";
}
private record JobRow(Long id, Integer status, String orderTime, String customerName, String costCenterName,
String commissionNo, String pickupName, String deliveryName, String courierSid,
Integer vehicleTypeId, BigDecimal totalPrice, boolean exported) {
}
private record CostCenterOption(Long id, String label) {
}
private enum StatusOption {
ALL("Alle", null),
BLOCKED("Blockiert", 0),
ASSIGNED("Zugewiesen", 1),
ON_DELIVERY("In Zustellung", 2),
DELIVERED("Zugestellt", 8),
COMPLETED("Abgeschlossen", 9),
CANCELLED("Storniert", 10);
private final String label;
private final Integer value;
StatusOption(String label, Integer value) {
this.label = label;
this.value = value;
}
private String label() {
return label;
}
private Integer value() {
return value;
}
}
private enum ExportOption {
ALL("Alle", null),
NOT_EXPORTED("Nicht exportiert", false),
EXPORTED("Exportiert", true);
private final String label;
private final Boolean value;
ExportOption(String label, Boolean value) {
this.label = label;
this.value = value;
}
private String label() {
return label;
}
private Boolean value() {
return value;
}
}
}

View File

@@ -0,0 +1,220 @@
package de.votian.web.view;
import com.vaadin.flow.component.Key;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.PasswordField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.ParameterProvider;
import de.votian.web.service.RestClientService;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
@Route("login")
@PageTitle("Votian - Login")
public class LoginView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final ParameterProvider parameterProvider;
private final TextField accountField = new TextField("Benutzerkonto");
private final PasswordField passwordField = new PasswordField("Passwort");
private final IntegerField totpField = new IntegerField("TOTP-Code");
private final Button loginButton = new Button("Anmelden", event -> submit());
public LoginView(RestClientService restClient, SessionData sessionData, ParameterProvider parameterProvider) {
this.restClient = restClient;
this.sessionData = sessionData;
this.parameterProvider = parameterProvider;
if (sessionData.isAuthenticated()) {
UI.getCurrent().navigate(DashboardView.class);
return;
}
setSizeFull();
setAlignItems(FlexComponent.Alignment.CENTER);
setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
VerticalLayout loginForm = new VerticalLayout();
loginForm.setWidth("400px");
loginForm.setPadding(true);
loginForm.setSpacing(true);
loginForm.setAlignItems(FlexComponent.Alignment.CENTER);
loginForm.getStyle()
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-l)")
.set("background", "var(--lumo-base-color)")
.set("box-shadow", "var(--lumo-box-shadow-m)");
H2 title = new H2("Stadtbote GmbH");
title.getStyle().set("color", "#1b12b9");
accountField.setWidthFull();
accountField.setAutofocus(true);
passwordField.setWidthFull();
totpField.setWidthFull();
totpField.setMin(0);
totpField.setMax(999999);
totpField.setVisible(false);
loginButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
loginButton.setWidthFull();
loginButton.addClickShortcut(Key.ENTER);
loginForm.add(title, accountField, passwordField, totpField, loginButton);
add(loginForm);
}
private void submit() {
if (sessionData.getPendingTotpToken() != null && totpField.isVisible()) {
verifyTotp();
} else {
login();
}
}
private void login() {
String account = accountField.getValue().trim();
String password = passwordField.getValue();
if (account.isEmpty() || password.isEmpty()) {
showError("Bitte Benutzerkonto und Passwort eingeben.");
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("account", account);
request.put("password", password);
@SuppressWarnings("unchecked")
Map<String, Object> response = restClient.post("/auth/login", request, LinkedHashMap.class);
handleAuthResponse(response);
} catch (Exception e) {
showError("Anmeldung fehlgeschlagen. Bitte pruefen Sie Ihre Eingaben.");
}
}
private void verifyTotp() {
if (totpField.getValue() == null) {
showError("Bitte den TOTP-Code eingeben.");
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("pendingToken", sessionData.getPendingTotpToken());
request.put("code", String.format("%06d", totpField.getValue()));
@SuppressWarnings("unchecked")
Map<String, Object> response = restClient.post("/auth/verify-totp", request, LinkedHashMap.class);
handleAuthResponse(response);
} catch (Exception e) {
showError("TOTP-Pruefung fehlgeschlagen.");
}
}
@SuppressWarnings("unchecked")
private void handleAuthResponse(Map<String, Object> response) {
if (response == null) {
showError("Anmeldung fehlgeschlagen.");
return;
}
if (Boolean.TRUE.equals(response.get("requiresTotp"))) {
sessionData.setPendingTotpToken((String) response.get("pendingToken"));
totpField.clear();
totpField.setVisible(true);
loginButton.setText("Code pruefen");
totpField.focus();
Notification.show("TOTP-Code eingeben.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_CONTRAST);
return;
}
Map<String, Object> user = (Map<String, Object>) response.get("user");
if (user == null) {
showError("Die Anmeldedaten konnten nicht geladen werden.");
return;
}
sessionData.clear();
sessionData.setAuthToken((String) response.get("authToken"));
sessionData.setUserId(asLong(user.get("userId")));
sessionData.setHeadquartersId(asLong(user.get("headquartersId")));
sessionData.setEmployeeId(asLong(user.get("employeeId")));
sessionData.setCustomerId(asLong(user.get("customerId")));
sessionData.setCostCenterId(asLong(user.get("costCenterId")));
sessionData.setCourierId(asLong(user.get("courierId")));
sessionData.setUserName(asString(user.get("name"), user.get("userName")));
sessionData.setUserFirstname(asString(user.get("firstname"), user.get("userFirstname")));
sessionData.setUserType(user.get("userType") instanceof Number ? ((Number) user.get("userType")).intValue() : 1);
sessionData.setLegacyRights(asString(user.get("legacyRights"), null));
sessionData.setAccessibleCostCenterIds(asLongList(user.get("accessibleCostCenterIds")));
sessionData.setRightIds(asLongList(user.get("rightIds")));
sessionData.setRightNames(asStringList(user.get("rightNames")));
sessionData.setAuthenticated(true);
parameterProvider.init(sessionData.getHeadquartersId(), sessionData.getEmployeeId());
UI.getCurrent().navigate(DashboardView.class);
}
private Long asLong(Object value) {
return value instanceof Number ? ((Number) value).longValue() : null;
}
private List<Long> asLongList(Object value) {
List<Long> result = new java.util.ArrayList<>();
if (value instanceof Iterable<?> iterable) {
for (Object item : iterable) {
Long parsed = asLong(item);
if (parsed != null) {
result.add(parsed);
}
}
}
return result;
}
private String asString(Object primary, Object fallback) {
if (primary instanceof String primaryString && !primaryString.isBlank()) {
return primaryString;
}
return fallback instanceof String fallbackString ? fallbackString : null;
}
private List<String> asStringList(Object value) {
List<String> result = new java.util.ArrayList<>();
if (value instanceof Iterable<?> iterable) {
for (Object item : iterable) {
if (item != null) {
String text = item.toString();
if (!text.isBlank()) {
result.add(text);
}
}
}
}
return result;
}
private void showError(String message) {
Notification.show(message, 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,258 @@
package de.votian.web.view;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.applayout.DrawerToggle;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.Span;
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.component.sidenav.SideNav;
import com.vaadin.flow.component.sidenav.SideNavItem;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import de.votian.web.model.SessionData;
import de.votian.web.service.ParameterProvider;
import de.votian.web.service.RestClientService;
import java.util.LinkedHashMap;
import java.util.Map;
public class MainLayout extends AppLayout implements BeforeEnterObserver {
private final SessionData sessionData;
private final ParameterProvider parameterProvider;
private final RestClientService restClient;
public MainLayout(SessionData sessionData, ParameterProvider parameterProvider, RestClientService restClient) {
this.sessionData = sessionData;
this.parameterProvider = parameterProvider;
this.restClient = restClient;
createHeader();
createDrawer();
}
private void createHeader() {
H1 logo = new H1("Votian");
logo.getStyle()
.set("font-size", "var(--lumo-font-size-l)")
.set("margin", "0")
.set("color", "#1b12b9");
Span userInfo = new Span();
if (sessionData.isAuthenticated()) {
userInfo.setText(sessionData.getDisplayName());
}
userInfo.getStyle().set("font-size", "var(--lumo-font-size-s)");
Button logoutButton = new Button("Abmelden", VaadinIcon.SIGN_OUT.create(), e -> logout());
logoutButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
HorizontalLayout header = new HorizontalLayout(new DrawerToggle(), logo, userInfo, logoutButton);
header.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER);
header.expand(userInfo);
header.setWidthFull();
header.setPadding(true);
header.setSpacing(true);
addToNavbar(header);
}
private void createDrawer() {
SideNav nav = new SideNav();
nav.addItem(new SideNavItem("Dashboard", DashboardView.class, VaadinIcon.DASHBOARD.create()));
if (sessionData.canViewJobs()) {
nav.addItem(new SideNavItem("Auftraege", JobListView.class, VaadinIcon.CLIPBOARD_TEXT.create()));
}
if (sessionData.isCourierUser() && sessionData.canViewInvoices()) {
nav.addItem(new SideNavItem("Auftraege", CourierInvoiceView.class, VaadinIcon.CLIPBOARD_TEXT.create()));
}
if (sessionData.canViewDisposition()) {
nav.addItem(new SideNavItem("Disposition", DispositionView.class, VaadinIcon.MAP_MARKER.create()));
}
if (canAccessTracking()) {
nav.addItem(new SideNavItem("Tracking", TrackingView.class, VaadinIcon.MAP_MARKER.create()));
}
if (sessionData.canViewLonghaul()) {
nav.addItem(new SideNavItem("Ferntouren", LonghaulView.class, VaadinIcon.GLOBE.create()));
}
if (sessionData.canViewGroupware()) {
nav.addItem(new SideNavItem("Vertrieb", GroupwareView.class, VaadinIcon.CALENDAR_USER.create()));
}
if (sessionData.canViewGenericExports()) {
nav.addItem(new SideNavItem("Datenexport", GenericExportWorkspaceView.class, VaadinIcon.DOWNLOAD_ALT.create()));
}
if (sessionData.canViewReports()) {
nav.addItem(new SideNavItem("Berichte", ReportWorkspaceView.class, VaadinIcon.CLIPBOARD_PULSE.create()));
}
if (sessionData.canViewCommunication()) {
nav.addItem(new SideNavItem("Kommunikation", CommunicationWorkspaceView.class, VaadinIcon.COMMENT.create()));
}
if (sessionData.canViewGeoOperations()) {
nav.addItem(new SideNavItem("Karten/Adressen", GeoOperationsWorkspaceView.class, VaadinIcon.MAP_MARKER.create()));
}
if (sessionData.canViewCostCenters()) {
nav.addItem(new SideNavItem("Kostenstellen", CostCenterView.class, VaadinIcon.MONEY.create()));
}
if (sessionData.canViewCustomers()) {
nav.addItem(new SideNavItem("Kunden", CustomerListView.class, VaadinIcon.GROUP.create()));
}
if (sessionData.canViewCouriers()) {
nav.addItem(new SideNavItem("Kuriere", CourierListView.class, VaadinIcon.CAR.create()));
}
if (sessionData.canViewEmployees()) {
nav.addItem(new SideNavItem("Mitarbeiter", EmployeeListView.class, VaadinIcon.USER.create()));
}
if (sessionData.canViewStatistics()) {
nav.addItem(new SideNavItem("Statistik", StatisticsView.class, VaadinIcon.CHART.create()));
}
if (!sessionData.isCourierUser() && sessionData.canViewInvoices()) {
nav.addItem(new SideNavItem("Rechnungen", InvoiceOverviewView.class, VaadinIcon.INVOICE.create()));
}
if (sessionData.canManagePricing()) {
nav.addItem(new SideNavItem("Preise", PricingView.class, VaadinIcon.COIN_PILES.create()));
}
if (sessionData.canManageHeadquarters()) {
nav.addItem(new SideNavItem("Niederlassung", HeadquartersAdminView.class, VaadinIcon.BUILDING.create()));
nav.addItem(new SideNavItem("Feiertage", PublicHolidayView.class, VaadinIcon.CALENDAR.create()));
nav.addItem(new SideNavItem("Gruppen/Filter", GroupFilterAdminView.class, VaadinIcon.TAGS.create()));
}
if (sessionData.canManageMetaFields()) {
nav.addItem(new SideNavItem("Formulare", MetaFieldAdminView.class, VaadinIcon.FORM.create()));
}
if (sessionData.canManageImports()) {
nav.addItem(new SideNavItem("Importe", ImportView.class, VaadinIcon.UPLOAD.create()));
}
if (sessionData.canViewWarehouse()) {
nav.addItem(new SideNavItem("Lager", WarehouseView.class, VaadinIcon.ARCHIVES.create()));
}
nav.addItem(new SideNavItem("Passwort", PasswordChangeView.class, VaadinIcon.LOCK.create()));
VerticalLayout drawerContent = new VerticalLayout(nav);
drawerContent.setSizeFull();
drawerContent.setPadding(false);
drawerContent.setSpacing(false);
addToDrawer(drawerContent);
}
private void logout() {
try {
restClient.post("/auth/logout", Map.of(), LinkedHashMap.class);
} catch (Exception ignored) {
}
sessionData.clear();
UI.getCurrent().navigate(LoginView.class);
UI.getCurrent().getPage().reload();
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
if (!sessionData.isAuthenticated()) {
event.rerouteTo(LoginView.class);
return;
}
if (!canAccessTarget(event.getNavigationTarget())) {
event.rerouteTo(DashboardView.class);
}
}
private boolean canAccessTarget(Class<?> target) {
if (target == DashboardView.class || target == PasswordChangeView.class) {
return true;
}
if (target == CourierInvoiceView.class) {
return sessionData.isCourierUser() && sessionData.canViewInvoices();
}
if (target == JobListView.class) {
return sessionData.canViewJobs();
}
if (target == JobDetailView.class) {
return sessionData.canViewJobs();
}
if (target == JobBatchView.class) {
return sessionData.canBatchJobs();
}
if (target == DispositionView.class) {
return sessionData.canViewDisposition();
}
if (target == TrackingView.class) {
return canAccessTracking();
}
if (target == LonghaulView.class) {
return sessionData.canViewLonghaul();
}
if (target == GroupwareView.class) {
return sessionData.canViewGroupware();
}
if (target == GenericExportWorkspaceView.class) {
return sessionData.canViewGenericExports();
}
if (target == ReportWorkspaceView.class) {
return sessionData.canViewReports();
}
if (target == CommunicationWorkspaceView.class) {
return sessionData.canViewCommunication();
}
if (target == GeoOperationsWorkspaceView.class) {
return sessionData.canViewGeoOperations();
}
if (target == CostCenterView.class) {
return sessionData.canViewCostCenters();
}
if (target == CustomerListView.class || target == CustomerDetailView.class) {
return sessionData.canViewCustomers();
}
if (target == CourierListView.class || target == CourierDetailView.class) {
return sessionData.canViewCouriers();
}
if (target == EmployeeListView.class || target == EmployeeDetailView.class) {
return sessionData.canViewEmployees();
}
if (target == StatisticsView.class) {
return sessionData.canViewStatistics();
}
if (target == InvoiceOverviewView.class) {
return !sessionData.isCourierUser() && sessionData.canViewInvoices();
}
if (target == PricingView.class) {
return sessionData.canManagePricing();
}
if (target == HeadquartersAdminView.class) {
return sessionData.canManageHeadquarters();
}
if (target == PublicHolidayView.class) {
return sessionData.canManageHeadquarters();
}
if (target == GroupFilterAdminView.class) {
return sessionData.canManageHeadquarters();
}
if (target == MetaFieldAdminView.class) {
return sessionData.canManageMetaFields();
}
if (target == ImportView.class) {
return sessionData.canManageImports();
}
if (target == WarehouseView.class) {
return sessionData.canViewWarehouse();
}
return sessionData.isHeadquartersUser();
}
private boolean canAccessTracking() {
if (sessionData.isHeadquartersUser()) {
return sessionData.canViewDisposition() || sessionData.canViewJobs();
}
if (!sessionData.isCustomerUser() || !sessionData.canViewJobs() || sessionData.getCustomerId() == null) {
return false;
}
return parameterProvider.isForObject("TRACKING_ENABLED_CS_", sessionData.getCustomerId())
|| parameterProvider.is("CS_TRACKING_ENABLED");
}
}

View File

@@ -0,0 +1,741 @@
package de.votian.web.view;
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.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.H4;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.tabs.Tab;
import com.vaadin.flow.component.tabs.Tabs;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Route(value = "meta-admin", layout = MainLayout.class)
@PageTitle("Votian - Formulare")
public class MetaFieldAdminView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final Grid<CategoryRow> categoryGrid = new Grid<>(CategoryRow.class, false);
private final Grid<FieldRow> fieldGrid = new Grid<>(FieldRow.class, false);
private final Grid<TemplateRow> templateGrid = new Grid<>(TemplateRow.class, false);
private final Grid<CategoryFieldRow> categoryFieldGrid = new Grid<>(CategoryFieldRow.class, false);
private final TextField categoryMnemonicField = new TextField("Kuerzel");
private final TextField categoryDescriptionField = new TextField("Bezeichnung");
private final ComboBox<TemplateOption> categoryTemplateField = new ComboBox<>("Vorlage");
private final ComboBox<FieldOption> categoryFieldSelection = new ComboBox<>("Globales Feld");
private final TextField fieldFilter = new TextField("Anzeigefilter");
private final TextField fieldTypeField = new TextField("Typ");
private final TextField fieldNameField = new TextField("Name");
private final TextField templateNameField = new TextField("Name");
private final TextArea templateContentField = new TextArea("Inhalt");
private final Button categoryNewButton = new Button("Neu");
private final Button categorySaveButton = new Button("Speichern");
private final Button categoryFieldAddButton = new Button("Feld hinzufuegen");
private final Button categoryFieldUpButton = new Button("Nach oben");
private final Button categoryFieldDownButton = new Button("Nach unten");
private final Button categoryFieldRemoveButton = new Button("Entfernen");
private final Button fieldNewButton = new Button("Neu");
private final Button fieldSaveButton = new Button("Speichern");
private final Button fieldLoadButton = new Button("Anzeigen");
private final Button templateNewButton = new Button("Neu");
private final Button templateSaveButton = new Button("Speichern");
private final List<CategoryRow> categories = new ArrayList<>();
private final List<FieldRow> fieldGridRows = new ArrayList<>();
private final List<FieldOption> allFieldOptions = new ArrayList<>();
private final List<TemplateRow> templates = new ArrayList<>();
private final List<TemplateOption> templateOptions = new ArrayList<>();
private final List<CategoryFieldRow> categoryFieldRows = new ArrayList<>();
private Long selectedCategoryId;
private Long selectedFieldId;
private Long selectedTemplateId;
public MetaFieldAdminView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setSizeFull();
setPadding(true);
setSpacing(true);
configureGrids();
configureFields();
configureButtons();
Tab categoriesTab = new Tab("Formulare");
Tab fieldsTab = new Tab("Globale Felder");
Tab templatesTab = new Tab("Vorlagen");
Tabs tabs = new Tabs(categoriesTab, fieldsTab, templatesTab);
VerticalLayout categoriesContent = buildCategoriesContent();
VerticalLayout fieldsContent = buildFieldsContent();
VerticalLayout templatesContent = buildTemplatesContent();
fieldsContent.setVisible(false);
templatesContent.setVisible(false);
tabs.addSelectedChangeListener(event -> {
categoriesContent.setVisible(event.getSelectedTab() == categoriesTab);
fieldsContent.setVisible(event.getSelectedTab() == fieldsTab);
templatesContent.setVisible(event.getSelectedTab() == templatesTab);
});
add(new H3("Formulare und Metafelder"), tabs, categoriesContent, fieldsContent, templatesContent);
expand(categoriesContent, fieldsContent, templatesContent);
loadTemplates();
loadFieldOptions();
loadFieldGrid();
loadCategories();
}
private void configureGrids() {
categoryGrid.addColumn(CategoryRow::mnemonic).setHeader("Kuerzel").setAutoWidth(true).setFrozen(true);
categoryGrid.addColumn(CategoryRow::description).setHeader("Bezeichnung").setFlexGrow(1);
categoryGrid.addColumn(CategoryRow::templateName).setHeader("Vorlage").setAutoWidth(true);
categoryGrid.addColumn(CategoryRow::fieldCount).setHeader("Felder").setAutoWidth(true);
categoryGrid.setHeight("260px");
categoryGrid.asSingleSelect().addValueChangeListener(event -> fillCategoryForm(event.getValue()));
categoryFieldGrid.addColumn(CategoryFieldRow::sort).setHeader("Sort.").setAutoWidth(true).setFrozen(true);
categoryFieldGrid.addColumn(CategoryFieldRow::fieldName).setHeader("Feld").setFlexGrow(1);
categoryFieldGrid.addColumn(CategoryFieldRow::fieldType).setHeader("Typ").setAutoWidth(true);
categoryFieldGrid.setHeight("240px");
fieldGrid.addColumn(FieldRow::id).setHeader("Nr.").setAutoWidth(true).setFrozen(true);
fieldGrid.addColumn(FieldRow::name).setHeader("Name").setFlexGrow(1);
fieldGrid.addColumn(FieldRow::type).setHeader("Typ").setFlexGrow(1);
fieldGrid.setHeight("320px");
fieldGrid.asSingleSelect().addValueChangeListener(event -> fillFieldForm(event.getValue()));
templateGrid.addColumn(TemplateRow::name).setHeader("Name").setFlexGrow(1).setFrozen(true);
templateGrid.addColumn(TemplateRow::usageCount).setHeader("Verwendung").setAutoWidth(true);
templateGrid.setHeight("260px");
templateGrid.asSingleSelect().addValueChangeListener(event -> fillTemplateForm(event.getValue()));
}
private void configureFields() {
categoryTemplateField.setItemLabelGenerator(TemplateOption::label);
categoryTemplateField.setWidthFull();
categoryFieldSelection.setItemLabelGenerator(FieldOption::label);
categoryFieldSelection.setWidth("420px");
fieldFilter.setClearButtonVisible(true);
fieldFilter.setWidth("320px");
fieldTypeField.setWidthFull();
fieldNameField.setWidthFull();
templateNameField.setWidthFull();
templateContentField.setWidthFull();
templateContentField.setMinHeight("260px");
}
private void configureButtons() {
categorySaveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
categoryFieldAddButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
fieldSaveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
templateSaveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
categoryNewButton.addClickListener(event -> clearCategoryForm());
categorySaveButton.addClickListener(event -> saveCategory());
categoryFieldAddButton.addClickListener(event -> addCategoryField());
categoryFieldUpButton.addClickListener(event -> moveCategoryField(-1));
categoryFieldDownButton.addClickListener(event -> moveCategoryField(1));
categoryFieldRemoveButton.addClickListener(event -> removeCategoryField());
fieldNewButton.addClickListener(event -> clearFieldForm());
fieldSaveButton.addClickListener(event -> saveField());
fieldLoadButton.addClickListener(event -> loadFieldGrid());
templateNewButton.addClickListener(event -> clearTemplateForm());
templateSaveButton.addClickListener(event -> saveTemplate());
}
private VerticalLayout buildCategoriesContent() {
FormLayout categoryForm = new FormLayout();
categoryForm.setResponsiveSteps(
new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("850px", 3)
);
categoryForm.add(categoryMnemonicField, categoryDescriptionField, categoryTemplateField);
HorizontalLayout categoryActions = new HorizontalLayout(categoryNewButton, categorySaveButton);
HorizontalLayout fieldActions = new HorizontalLayout(
categoryFieldSelection,
categoryFieldAddButton,
categoryFieldUpButton,
categoryFieldDownButton,
categoryFieldRemoveButton
);
fieldActions.setAlignItems(Alignment.BASELINE);
VerticalLayout content = new VerticalLayout(
new Paragraph("Der Workspace pflegt die Legacy-Tabellen `metafieldcategory`, `metafieldkey`, `metafieldtemplate` und `metafieldcategorykey` direkt kompatibel weiter."),
new H4("Formulare"),
categoryGrid,
categoryForm,
categoryActions,
new H4("Formularfelder"),
fieldActions,
categoryFieldGrid
);
content.setPadding(false);
content.setSpacing(true);
content.setSizeFull();
return content;
}
private VerticalLayout buildFieldsContent() {
FormLayout form = new FormLayout();
form.setResponsiveSteps(
new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("900px", 2)
);
form.add(fieldTypeField, fieldNameField);
HorizontalLayout filterBar = new HorizontalLayout(fieldFilter, fieldLoadButton);
filterBar.setAlignItems(Alignment.BASELINE);
HorizontalLayout actions = new HorizontalLayout(fieldNewButton, fieldSaveButton);
VerticalLayout content = new VerticalLayout(
new Paragraph("Globale Felder bleiben mandantenunabhaengig. Die Filterung entspricht dem Legacy-Editor nach Feldnamenpraefix."),
filterBar,
fieldGrid,
form,
actions
);
content.setPadding(false);
content.setSpacing(true);
content.setSizeFull();
return content;
}
private VerticalLayout buildTemplatesContent() {
FormLayout form = new FormLayout();
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1));
form.add(templateNameField, templateContentField);
HorizontalLayout actions = new HorizontalLayout(templateNewButton, templateSaveButton);
VerticalLayout content = new VerticalLayout(
new Paragraph("Vorlagen editieren direkt `mtft_content` und koennen anschliessend den Formularen zugewiesen werden."),
templateGrid,
form,
actions
);
content.setPadding(false);
content.setSpacing(true);
content.setSizeFull();
return content;
}
private void loadCategories() {
Long headquartersId = sessionData.getHeadquartersId();
if (headquartersId == null) {
return;
}
try {
List<LinkedHashMap<String, Object>> result = restClient.getList(
"/meta-admin/categories?hqId=" + headquartersId,
new ParameterizedTypeReference<>() {}
);
categories.clear();
for (Map<String, Object> row : result) {
Long id = toLong(row.get("id"));
if (id == null) {
continue;
}
categories.add(new CategoryRow(
id,
str(row.get("mnemonic")),
str(row.get("description")),
toLong(row.get("templateId")),
str(row.get("templateName")),
toLong(row.get("fieldCount")) != null ? toLong(row.get("fieldCount")) : 0L
));
}
categoryGrid.setItems(categories);
reselectCategory();
if (selectedCategoryId == null && !categories.isEmpty()) {
categoryGrid.select(categories.get(0));
}
} catch (Exception e) {
showError("Formulare konnten nicht geladen werden: " + e.getMessage());
}
}
private void loadFieldGrid() {
Long headquartersId = sessionData.getHeadquartersId();
if (headquartersId == null) {
return;
}
try {
String path = "/meta-admin/fields?hqId=" + headquartersId;
if (!fieldFilter.getValue().isBlank()) {
path += "&filter=" + url(fieldFilter.getValue().trim());
}
List<LinkedHashMap<String, Object>> result = restClient.getList(path, new ParameterizedTypeReference<>() {});
fieldGridRows.clear();
for (Map<String, Object> row : result) {
Long id = toLong(row.get("id"));
if (id == null) {
continue;
}
fieldGridRows.add(new FieldRow(
id,
str(row.get("type")),
str(row.get("name"))
));
}
fieldGrid.setItems(fieldGridRows);
reselectField();
} catch (Exception e) {
showError("Globale Felder konnten nicht geladen werden: " + e.getMessage());
}
}
private void loadFieldOptions() {
Long headquartersId = sessionData.getHeadquartersId();
if (headquartersId == null) {
return;
}
try {
List<LinkedHashMap<String, Object>> result = restClient.getList(
"/meta-admin/fields?hqId=" + headquartersId,
new ParameterizedTypeReference<>() {}
);
allFieldOptions.clear();
for (Map<String, Object> row : result) {
Long id = toLong(row.get("id"));
if (id == null) {
continue;
}
allFieldOptions.add(new FieldOption(
id,
str(row.get("name")),
str(row.get("type"))
));
}
refreshCategoryFieldSelection();
} catch (Exception e) {
showError("Feldoptionen konnten nicht geladen werden: " + e.getMessage());
}
}
private void loadTemplates() {
Long headquartersId = sessionData.getHeadquartersId();
if (headquartersId == null) {
return;
}
try {
List<LinkedHashMap<String, Object>> result = restClient.getList(
"/meta-admin/templates?hqId=" + headquartersId,
new ParameterizedTypeReference<>() {}
);
templates.clear();
templateOptions.clear();
templateOptions.add(new TemplateOption(null, "(keine Vorlage)"));
for (Map<String, Object> row : result) {
Long id = toLong(row.get("id"));
if (id == null) {
continue;
}
TemplateRow template = new TemplateRow(
id,
str(row.get("name")),
str(row.get("content")),
toLong(row.get("usageCount")) != null ? toLong(row.get("usageCount")) : 0L
);
templates.add(template);
templateOptions.add(new TemplateOption(id, template.name()));
}
templateGrid.setItems(templates);
categoryTemplateField.setItems(templateOptions);
categoryTemplateField.setValue(templateOptions.get(0));
reselectTemplate();
} catch (Exception e) {
showError("Vorlagen konnten nicht geladen werden: " + e.getMessage());
}
}
private void loadCategoryFields() {
Long headquartersId = sessionData.getHeadquartersId();
if (selectedCategoryId == null || headquartersId == null) {
categoryFieldRows.clear();
categoryFieldGrid.setItems(categoryFieldRows);
refreshCategoryFieldSelection();
return;
}
try {
List<LinkedHashMap<String, Object>> result = restClient.getList(
"/meta-admin/categories/" + selectedCategoryId + "/fields?hqId=" + headquartersId,
new ParameterizedTypeReference<>() {}
);
categoryFieldRows.clear();
for (Map<String, Object> row : result) {
Long assignmentId = toLong(row.get("assignmentId"));
Long fieldId = toLong(row.get("fieldId"));
if (assignmentId == null || fieldId == null) {
continue;
}
categoryFieldRows.add(new CategoryFieldRow(
assignmentId,
fieldId,
str(row.get("fieldName")),
str(row.get("fieldType")),
toInteger(row.get("sort")) != null ? toInteger(row.get("sort")) : 0
));
}
categoryFieldGrid.setItems(categoryFieldRows);
refreshCategoryFieldSelection();
} catch (Exception e) {
showError("Formularfelder konnten nicht geladen werden: " + e.getMessage());
}
}
private void saveCategory() {
Long headquartersId = sessionData.getHeadquartersId();
if (headquartersId == null) {
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("hqId", headquartersId);
request.put("mnemonic", categoryMnemonicField.getValue());
request.put("description", categoryDescriptionField.getValue());
TemplateOption template = categoryTemplateField.getValue();
request.put("templateId", template != null ? template.id() : null);
if (selectedCategoryId == null) {
restClient.post("/meta-admin/categories", request, LinkedHashMap.class);
} else {
restClient.put("/meta-admin/categories/" + selectedCategoryId, request);
}
showSuccess("Formular gespeichert.");
loadTemplates();
loadCategories();
loadCategoryFields();
} catch (Exception e) {
showError("Formular konnte nicht gespeichert werden: " + e.getMessage());
}
}
private void saveField() {
Long headquartersId = sessionData.getHeadquartersId();
if (headquartersId == null) {
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("hqId", headquartersId);
request.put("type", fieldTypeField.getValue());
request.put("name", fieldNameField.getValue());
if (selectedFieldId == null) {
restClient.post("/meta-admin/fields", request, LinkedHashMap.class);
} else {
restClient.put("/meta-admin/fields/" + selectedFieldId, request);
}
showSuccess("Globales Feld gespeichert.");
loadFieldOptions();
loadFieldGrid();
loadCategoryFields();
} catch (Exception e) {
showError("Globales Feld konnte nicht gespeichert werden: " + e.getMessage());
}
}
private void saveTemplate() {
Long headquartersId = sessionData.getHeadquartersId();
if (headquartersId == null) {
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("hqId", headquartersId);
request.put("name", templateNameField.getValue());
request.put("content", templateContentField.getValue());
if (selectedTemplateId == null) {
restClient.post("/meta-admin/templates", request, LinkedHashMap.class);
} else {
restClient.put("/meta-admin/templates/" + selectedTemplateId, request);
}
showSuccess("Vorlage gespeichert.");
loadTemplates();
loadCategories();
} catch (Exception e) {
showError("Vorlage konnte nicht gespeichert werden: " + e.getMessage());
}
}
private void addCategoryField() {
Long headquartersId = sessionData.getHeadquartersId();
FieldOption selectedField = categoryFieldSelection.getValue();
if (selectedCategoryId == null || headquartersId == null || selectedField == null) {
showError("Bitte Formular und Feld auswaehlen.");
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("hqId", headquartersId);
request.put("fieldId", selectedField.id());
restClient.post("/meta-admin/categories/" + selectedCategoryId + "/fields", request, LinkedHashMap.class);
showSuccess("Formularfeld hinzugefuegt.");
loadCategoryFields();
loadCategories();
} catch (Exception e) {
showError("Formularfeld konnte nicht hinzugefuegt werden: " + e.getMessage());
}
}
private void moveCategoryField(int direction) {
CategoryFieldRow selected = categoryFieldGrid.asSingleSelect().getValue();
Long headquartersId = sessionData.getHeadquartersId();
if (selectedCategoryId == null || headquartersId == null || selected == null) {
return;
}
int index = categoryFieldRows.indexOf(selected);
int newIndex = index + direction;
if (index < 0 || newIndex < 0 || newIndex >= categoryFieldRows.size()) {
return;
}
List<CategoryFieldRow> reordered = new ArrayList<>(categoryFieldRows);
reordered.remove(index);
reordered.add(newIndex, selected);
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("hqId", headquartersId);
request.put("assignmentIds", reordered.stream().map(CategoryFieldRow::assignmentId).toList());
restClient.put("/meta-admin/categories/" + selectedCategoryId + "/fields/order", request);
loadCategoryFields();
loadCategories();
} catch (Exception e) {
showError("Sortierung konnte nicht gespeichert werden: " + e.getMessage());
}
}
private void removeCategoryField() {
CategoryFieldRow selected = categoryFieldGrid.asSingleSelect().getValue();
Long headquartersId = sessionData.getHeadquartersId();
if (selected == null || headquartersId == null) {
return;
}
try {
restClient.delete("/meta-admin/category-fields/" + selected.assignmentId() + "?hqId=" + headquartersId);
showSuccess("Formularfeld entfernt.");
loadCategoryFields();
loadCategories();
} catch (Exception e) {
showError("Formularfeld konnte nicht entfernt werden: " + e.getMessage());
}
}
private void fillCategoryForm(CategoryRow row) {
if (row == null) {
clearCategoryForm();
return;
}
selectedCategoryId = row.id();
categoryMnemonicField.setValue(row.mnemonic());
categoryDescriptionField.setValue(row.description());
categoryTemplateField.setValue(templateOptions.stream()
.filter(option -> Objects.equals(option.id(), row.templateId()))
.findFirst()
.orElse(templateOptions.get(0)));
loadCategoryFields();
}
private void fillFieldForm(FieldRow row) {
if (row == null) {
clearFieldForm();
return;
}
selectedFieldId = row.id();
fieldTypeField.setValue(row.type());
fieldNameField.setValue(row.name());
}
private void fillTemplateForm(TemplateRow row) {
if (row == null) {
clearTemplateForm();
return;
}
selectedTemplateId = row.id();
templateNameField.setValue(row.name());
templateContentField.setValue(row.content());
}
private void clearCategoryForm() {
selectedCategoryId = null;
categoryGrid.asSingleSelect().clear();
categoryMnemonicField.clear();
categoryDescriptionField.clear();
if (!templateOptions.isEmpty()) {
categoryTemplateField.setValue(templateOptions.get(0));
} else {
categoryTemplateField.clear();
}
loadCategoryFields();
}
private void clearFieldForm() {
selectedFieldId = null;
fieldGrid.asSingleSelect().clear();
fieldTypeField.clear();
fieldNameField.clear();
}
private void clearTemplateForm() {
selectedTemplateId = null;
templateGrid.asSingleSelect().clear();
templateNameField.clear();
templateContentField.clear();
}
private void refreshCategoryFieldSelection() {
if (selectedCategoryId == null) {
categoryFieldSelection.clear();
categoryFieldSelection.setItems(List.of());
return;
}
List<Long> assignedFieldIds = categoryFieldRows.stream().map(CategoryFieldRow::fieldId).toList();
List<FieldOption> available = allFieldOptions.stream()
.filter(option -> !assignedFieldIds.contains(option.id()))
.toList();
categoryFieldSelection.setItems(available);
if (!available.contains(categoryFieldSelection.getValue())) {
categoryFieldSelection.clear();
}
}
private void reselectCategory() {
if (selectedCategoryId == null) {
return;
}
categories.stream()
.filter(row -> Objects.equals(row.id(), selectedCategoryId))
.findFirst()
.ifPresent(row -> categoryGrid.select(row));
}
private void reselectField() {
if (selectedFieldId == null) {
return;
}
fieldGridRows.stream()
.filter(row -> Objects.equals(row.id(), selectedFieldId))
.findFirst()
.ifPresent(row -> fieldGrid.select(row));
}
private void reselectTemplate() {
if (selectedTemplateId == null) {
return;
}
templates.stream()
.filter(row -> Objects.equals(row.id(), selectedTemplateId))
.findFirst()
.ifPresent(row -> templateGrid.select(row));
}
private void showSuccess(String message) {
Notification.show(message, 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
}
private void showError(String message) {
Notification.show(message, 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
private String url(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private String str(Object value) {
return value != null ? value.toString() : "";
}
private Long toLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String string && !string.isBlank()) {
try {
return Long.parseLong(string);
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private Integer toInteger(Object value) {
if (value instanceof Number number) {
return number.intValue();
}
if (value instanceof String string && !string.isBlank()) {
try {
return Integer.parseInt(string);
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private record CategoryRow(Long id,
String mnemonic,
String description,
Long templateId,
String templateName,
long fieldCount) {
}
private record FieldRow(Long id, String type, String name) {
}
private record TemplateRow(Long id, String name, String content, long usageCount) {
}
private record CategoryFieldRow(Long assignmentId, Long fieldId, String fieldName, String fieldType, int sort) {
}
private record TemplateOption(Long id, String label) {
}
private record FieldOption(Long id, String name, String type) {
private String label() {
return name + (type == null || type.isBlank() ? "" : " [" + type + "]");
}
}
}

View File

@@ -0,0 +1,62 @@
package de.votian.web.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.PasswordField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.service.RestClientService;
import java.util.LinkedHashMap;
import java.util.Map;
@Route(value = "password", layout = MainLayout.class)
@PageTitle("Votian - Passwort")
public class PasswordChangeView extends VerticalLayout {
public PasswordChangeView(RestClientService restClient) {
setPadding(true);
setSpacing(true);
PasswordField oldPasswordField = new PasswordField("Aktuelles Passwort");
PasswordField newPasswordField = new PasswordField("Neues Passwort");
PasswordField confirmPasswordField = new PasswordField("Neues Passwort wiederholen");
FormLayout form = new FormLayout();
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1));
form.add(oldPasswordField, newPasswordField, confirmPasswordField);
Button saveButton = new Button("Passwort aendern", event -> {
if (!newPasswordField.getValue().equals(confirmPasswordField.getValue())) {
Notification.show("Die neuen Passwoerter stimmen nicht ueberein.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("oldPassword", oldPasswordField.getValue());
request.put("newPassword", newPasswordField.getValue());
restClient.post("/auth/change-password", request, LinkedHashMap.class);
oldPasswordField.clear();
newPasswordField.clear();
confirmPasswordField.clear();
Notification.show("Passwort geaendert.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} catch (Exception e) {
Notification.show("Passwort konnte nicht geaendert werden.", 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
});
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
add(new H3("Passwort aendern"), form, new HorizontalLayout(saveButton));
}
}

View File

@@ -0,0 +1,291 @@
package de.votian.web.view;
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.datepicker.DatePicker;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Route(value = "pricing", layout = MainLayout.class)
@PageTitle("Votian - Preise")
public class PricingView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final ComboBox<Map<String, Object>> customerBox = new ComboBox<>("Kunde");
private final ComboBox<Map<String, Object>> typeBox = new ComboBox<>("Leistungstyp");
private final Grid<Map<String, Object>> grid = new Grid<>();
private final NumberField priceField = new NumberField("Preis");
private final NumberField courierPriceField = new NumberField("Kurierpreis");
private final NumberField discountField = new NumberField("Rabatt");
private final DatePicker validFromField = new DatePicker("Gueltig ab");
private Long selectedServiceId;
public PricingView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setPadding(true);
setSpacing(true);
customerBox.setItemLabelGenerator(item -> (String) item.getOrDefault("companyName", "Global"));
customerBox.addValueChangeListener(event -> loadPrices());
typeBox.setItemLabelGenerator(item -> (String) item.getOrDefault("name", ""));
Button reloadButton = new Button("Neu laden", event -> loadPrices());
reloadButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
HorizontalLayout filterBar = new HorizontalLayout(customerBox, reloadButton);
filterBar.setAlignItems(Alignment.BASELINE);
grid.addColumn(item -> item.get("serviceTypeName")).setHeader("Typ").setAutoWidth(true);
grid.addColumn(item -> item.get("serviceName")).setHeader("Leistung").setFlexGrow(1);
grid.addColumn(item -> item.get("price")).setHeader("Preis").setAutoWidth(true);
grid.addColumn(item -> item.get("courierPrice")).setHeader("Kurierpreis").setAutoWidth(true);
grid.addColumn(item -> item.get("discount")).setHeader("Rabatt").setAutoWidth(true);
grid.addColumn(item -> item.get("validFrom")).setHeader("Gueltig ab").setAutoWidth(true);
grid.setHeight("320px");
grid.addSelectionListener(event -> event.getFirstSelectedItem().ifPresent(this::selectPrice));
validFromField.setValue(LocalDate.now());
Button savePriceButton = new Button("Preis speichern", event -> savePrice());
savePriceButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
savePriceButton.setEnabled(sessionData.canManagePricing());
FormLayout priceForm = new FormLayout();
priceForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("700px", 2));
priceForm.add(priceField, courierPriceField, discountField, validFromField, savePriceButton);
TextField newTypeNameField = new TextField("Neuer Leistungstyp");
IntegerField newTypeModeField = new IntegerField("Typ-Modus");
newTypeModeField.setValue(0);
Button addTypeButton = new Button("Typ anlegen", event -> addType(newTypeNameField, newTypeModeField));
TextField newServiceNameField = new TextField("Neue Leistung");
IntegerField newServiceModeField = new IntegerField("Leistungs-Modus");
newServiceModeField.setValue(0);
Button addServiceButton = new Button("Leistung anlegen", event -> addService(newServiceNameField, newServiceModeField));
addTypeButton.setEnabled(sessionData.canManagePricing());
addServiceButton.setEnabled(sessionData.canManagePricing());
FormLayout catalogForm = new FormLayout();
catalogForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("900px", 3));
catalogForm.add(newTypeNameField, newTypeModeField, addTypeButton, newServiceNameField, typeBox, newServiceModeField, addServiceButton);
add(new H3("Preisverwaltung"), filterBar, grid, new H3("Preis bearbeiten"), priceForm,
new H3("Leistungen und Typen"), catalogForm);
loadCustomers();
loadTypes();
loadPrices();
}
private void loadCustomers() {
if (sessionData.getHeadquartersId() == null) {
return;
}
try {
Object[] result = restClient.get("/customers/hq/" + sessionData.getHeadquartersId(), Object[].class);
List<Map<String, Object>> items = new ArrayList<>();
Map<String, Object> global = new LinkedHashMap<>();
global.put("id", 0L);
global.put("companyName", "Globale Preise");
items.add(global);
if (result != null) {
for (Object obj : result) {
if (obj instanceof Map<?, ?> map) {
@SuppressWarnings("unchecked")
Map<String, Object> typed = (Map<String, Object>) map;
items.add(typed);
}
}
}
customerBox.setItems(items);
customerBox.setValue(items.get(0));
} catch (Exception e) {
Notification.show("Kunden konnten nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadTypes() {
if (sessionData.getHeadquartersId() == null) {
return;
}
try {
Object[] result = restClient.get("/services/types/hq/" + sessionData.getHeadquartersId(), Object[].class);
List<Map<String, Object>> items = new ArrayList<>();
if (result != null) {
for (Object obj : result) {
if (obj instanceof Map<?, ?> map) {
@SuppressWarnings("unchecked")
Map<String, Object> typed = (Map<String, Object>) map;
items.add(typed);
}
}
}
typeBox.setItems(items);
} catch (Exception e) {
Notification.show("Leistungstypen konnten nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadPrices() {
if (sessionData.getHeadquartersId() == null) {
return;
}
String customerParam = "";
Map<String, Object> selectedCustomer = customerBox.getValue();
if (selectedCustomer != null && selectedCustomer.get("id") instanceof Number number && number.longValue() > 0) {
customerParam = "&customerId=" + number.longValue();
}
try {
Object[] result = restClient.get("/services/prices?hqId=" + sessionData.getHeadquartersId() + customerParam, Object[].class);
List<Map<String, Object>> items = new ArrayList<>();
if (result != null) {
for (Object obj : result) {
if (obj instanceof Map<?, ?> map) {
@SuppressWarnings("unchecked")
Map<String, Object> typed = (Map<String, Object>) map;
items.add(typed);
}
}
}
grid.setItems(items);
} catch (Exception e) {
Notification.show("Preise konnten nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void selectPrice(Map<String, Object> item) {
selectedServiceId = item.get("serviceId") instanceof Number number ? number.longValue() : null;
priceField.setValue(asDouble(item.get("price")));
courierPriceField.setValue(asDouble(item.get("courierPrice")));
discountField.setValue(asDouble(item.get("discount")));
if (item.get("validFrom") != null) {
validFromField.setValue(LocalDate.parse(item.get("validFrom").toString()));
}
}
private void savePrice() {
if (!sessionData.canManagePricing()) {
Notification.show("Keine Berechtigung zur Preispflege.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
if (selectedServiceId == null || sessionData.getHeadquartersId() == null) {
Notification.show("Bitte zuerst eine Leistung auswaehlen.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("serviceId", selectedServiceId);
request.put("headquartersId", sessionData.getHeadquartersId());
request.put("customerId", selectedCustomerId());
request.put("date", validFromField.getValue() != null ? validFromField.getValue().toString() : LocalDate.now().toString());
request.put("price", decimal(priceField.getValue()));
request.put("courierPrice", decimal(courierPriceField.getValue()));
request.put("discount", decimal(discountField.getValue()));
restClient.post("/services/history", request, LinkedHashMap.class);
Notification.show("Preis gespeichert.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
loadPrices();
} catch (Exception e) {
Notification.show("Preis konnte nicht gespeichert werden.", 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void addType(TextField nameField, IntegerField modeField) {
if (!sessionData.canManagePricing()) {
return;
}
if (sessionData.getHeadquartersId() == null || nameField.getValue().isBlank()) {
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("name", nameField.getValue());
request.put("mode", modeField.getValue());
request.put("headquartersId", sessionData.getHeadquartersId());
request.put("customerId", selectedCustomerId());
restClient.post("/services/types", request, LinkedHashMap.class);
nameField.clear();
loadTypes();
} catch (Exception e) {
Notification.show("Leistungstyp konnte nicht gespeichert werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void addService(TextField nameField, IntegerField modeField) {
if (!sessionData.canManagePricing()) {
return;
}
if (sessionData.getHeadquartersId() == null || nameField.getValue().isBlank() || typeBox.getValue() == null) {
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("name", nameField.getValue());
request.put("mode", modeField.getValue());
request.put("headquartersId", sessionData.getHeadquartersId());
request.put("customerId", selectedCustomerId());
request.put("serviceTypeId", typeBox.getValue().get("id"));
restClient.post("/services/entity", request, LinkedHashMap.class);
nameField.clear();
loadPrices();
} catch (Exception e) {
Notification.show("Leistung konnte nicht gespeichert werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private Long selectedCustomerId() {
Map<String, Object> selectedCustomer = customerBox.getValue();
if (selectedCustomer != null && selectedCustomer.get("id") instanceof Number number) {
return number.longValue();
}
return 0L;
}
private Double asDouble(Object value) {
return value instanceof Number ? ((Number) value).doubleValue() : null;
}
private BigDecimal decimal(Double value) {
return value != null ? BigDecimal.valueOf(value) : null;
}
}

View File

@@ -0,0 +1,343 @@
package de.votian.web.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Route(value = "public-holidays", layout = MainLayout.class)
@PageTitle("Votian - Feiertage")
public class PublicHolidayView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final ComboBox<Integer> yearSelect = new ComboBox<>("Jahr");
private final ComboBox<Integer> mergeYearSelect = new ComboBox<>("Vorjahr integrieren");
private final Button mergeButton = new Button("Eintraege uebernehmen");
private final Button saveButton = new Button("Speichern");
private final Paragraph info = new Paragraph();
private final Grid<HolidayRow> grid = new Grid<>(HolidayRow.class, false);
private final List<HolidayRow> rows = new ArrayList<>();
private boolean loading;
public PublicHolidayView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setSizeFull();
setPadding(true);
setSpacing(true);
configureToolbar();
configureGrid();
add(new H3("Feiertage"), new HorizontalLayout(yearSelect, mergeYearSelect, mergeButton, saveButton), info, grid);
expand(grid);
loadYear(LocalDate.now().getYear());
}
private void configureToolbar() {
yearSelect.setWidth("160px");
yearSelect.addValueChangeListener(event -> {
if (!loading && event.getValue() != null) {
loadYear(event.getValue());
}
});
mergeYearSelect.setWidth("220px");
mergeYearSelect.addValueChangeListener(event -> refreshButtons());
mergeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
mergeButton.addClickListener(event -> mergeSelectedYear());
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
saveButton.addClickListener(event -> save());
}
private void configureGrid() {
grid.addColumn(HolidayRow::isoDate).setHeader("Datum").setAutoWidth(true).setFrozen(true);
grid.addColumn(HolidayRow::weekdayName).setHeader("Wochentag").setAutoWidth(true);
grid.addComponentColumn(this::buildHolidayCheckbox).setHeader("Feiertag").setAutoWidth(true);
grid.addComponentColumn(this::buildNameField).setHeader("Bezeichnung").setFlexGrow(1);
grid.setSizeFull();
}
private Checkbox buildHolidayCheckbox(HolidayRow row) {
Checkbox checkbox = new Checkbox(row.holiday());
checkbox.setEnabled(row.editable());
checkbox.addValueChangeListener(event -> {
row.setHoliday(event.getValue());
if (!event.getValue()) {
row.setName("");
}
grid.getDataProvider().refreshItem(row);
});
return checkbox;
}
private TextField buildNameField(HolidayRow row) {
TextField field = new TextField();
field.setWidthFull();
field.setValue(row.name());
field.setEnabled(row.editable() && row.holiday());
field.setPlaceholder("Feiertagsname");
field.addValueChangeListener(event -> row.setName(event.getValue()));
return field;
}
private void loadYear(int year) {
if (sessionData.getHeadquartersId() == null) {
return;
}
try {
loading = true;
HolidayResponse response = parseResponse(restClient.getMap(
"/public-holidays?hqId=" + sessionData.getHeadquartersId() + "&year=" + year
));
yearSelect.setItems(response.availableYears());
yearSelect.setValue(response.year());
mergeYearSelect.setItems(response.copySourceYears());
if (!response.copySourceYears().contains(mergeYearSelect.getValue())) {
mergeYearSelect.clear();
}
rows.clear();
rows.addAll(response.days());
grid.setItems(rows);
info.setText(response.editable()
? "Nur zukuenftige Tage sind bearbeitbar. Vergangene Feiertage bleiben beim Speichern unveraendert."
: "Dieses Jahr ist nur noch lesend verfuegbar.");
refreshButtons();
} catch (Exception e) {
Notification.show("Feiertage konnten nicht geladen werden: " + e.getMessage(), 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
} finally {
loading = false;
}
}
private void mergeSelectedYear() {
Integer sourceYear = mergeYearSelect.getValue();
Integer targetYear = yearSelect.getValue();
if (sourceYear == null || targetYear == null) {
return;
}
try {
HolidayResponse source = parseResponse(restClient.getMap(
"/public-holidays?hqId=" + sessionData.getHeadquartersId() + "&year=" + sourceYear
));
Map<String, HolidayRow> sourceByDate = new LinkedHashMap<>();
for (HolidayRow row : source.days()) {
if (row.holiday()) {
sourceByDate.put(row.isoDate().substring(5), row);
}
}
int merged = 0;
for (HolidayRow targetRow : rows) {
if (!targetRow.editable() || targetRow.holiday()) {
continue;
}
HolidayRow sourceRow = sourceByDate.get(targetRow.isoDate().substring(5));
if (sourceRow == null) {
continue;
}
targetRow.setHoliday(true);
targetRow.setName(sourceRow.name());
merged++;
}
grid.getDataProvider().refreshAll();
Notification.show(merged + " Feiertage integriert.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} catch (Exception e) {
Notification.show("Vorjahreseintraege konnten nicht geladen werden: " + e.getMessage(), 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void save() {
Integer year = yearSelect.getValue();
if (year == null || sessionData.getHeadquartersId() == null) {
return;
}
try {
List<Map<String, Object>> days = rows.stream().map(HolidayRow::toMap).toList();
restClient.put(
"/public-holidays?hqId=" + sessionData.getHeadquartersId() + "&year=" + year,
Map.of("days", days)
);
Notification.show("Feiertage gespeichert.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
loadYear(year);
} catch (Exception e) {
Notification.show("Feiertage konnten nicht gespeichert werden: " + e.getMessage(), 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void refreshButtons() {
boolean editable = rows.stream().anyMatch(HolidayRow::editable);
mergeButton.setEnabled(editable && mergeYearSelect.getValue() != null);
saveButton.setEnabled(editable && sessionData.canManageHeadquarters());
}
private HolidayResponse parseResponse(Map<String, Object> data) {
List<Integer> availableYears = new ArrayList<>();
Object rawYears = data.get("availableYears");
if (rawYears instanceof List<?> list) {
for (Object entry : list) {
Integer year = toInteger(entry);
if (year != null) {
availableYears.add(year);
}
}
}
List<Integer> copySourceYears = new ArrayList<>();
Object rawCopyYears = data.get("copySourceYears");
if (rawCopyYears instanceof List<?> list) {
for (Object entry : list) {
Integer year = toInteger(entry);
if (year != null) {
copySourceYears.add(year);
}
}
}
List<HolidayRow> parsedRows = new ArrayList<>();
Object rawDays = data.get("days");
if (rawDays instanceof List<?> list) {
for (Object entry : list) {
if (!(entry instanceof Map<?, ?> rawMap)) {
continue;
}
parsedRows.add(new HolidayRow(
str(rawMap.get("isoDate")),
str(rawMap.get("weekdayName")),
toInteger(rawMap.get("month")),
toInteger(rawMap.get("day")),
bool(rawMap.get("holiday")),
str(rawMap.get("name")),
bool(rawMap.get("editable"))
));
}
}
return new HolidayResponse(
toInteger(data.get("year")) != null ? toInteger(data.get("year")) : LocalDate.now().getYear(),
availableYears,
copySourceYears,
parsedRows,
bool(data.get("editable"))
);
}
private Integer toInteger(Object value) {
if (value instanceof Number number) {
return number.intValue();
}
try {
return value != null ? Integer.parseInt(value.toString()) : null;
} catch (NumberFormatException e) {
return null;
}
}
private boolean bool(Object value) {
return Boolean.TRUE.equals(value) || (value != null && "true".equalsIgnoreCase(value.toString()));
}
private String str(Object value) {
return value != null ? value.toString() : "";
}
private record HolidayResponse(int year,
List<Integer> availableYears,
List<Integer> copySourceYears,
List<HolidayRow> days,
boolean editable) {
}
private static final class HolidayRow {
private final String isoDate;
private final String weekdayName;
private final Integer month;
private final Integer day;
private boolean holiday;
private String name;
private final boolean editable;
private HolidayRow(String isoDate,
String weekdayName,
Integer month,
Integer day,
boolean holiday,
String name,
boolean editable) {
this.isoDate = isoDate;
this.weekdayName = weekdayName;
this.month = month;
this.day = day;
this.holiday = holiday;
this.name = name != null ? name : "";
this.editable = editable;
}
private String isoDate() {
return isoDate;
}
private String weekdayName() {
return weekdayName;
}
private boolean holiday() {
return holiday;
}
private void setHoliday(boolean holiday) {
this.holiday = holiday;
}
private String name() {
return name;
}
private void setName(String name) {
this.name = name != null ? name : "";
}
private boolean editable() {
return editable;
}
private Map<String, Object> toMap() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("month", month);
map.put("day", day);
map.put("holiday", holiday);
map.put("name", name);
return map;
}
}
}

View File

@@ -0,0 +1,721 @@
package de.votian.web.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.H4;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.tabs.Tab;
import com.vaadin.flow.component.tabs.Tabs;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.StreamResource;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;
import java.io.ByteArrayInputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Route(value = "reports", layout = MainLayout.class)
@PageTitle("Votian - Berichte")
public class ReportWorkspaceView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final ComboBox<ObjectTypeOption> objectTypeField = new ComboBox<>("Bereich");
private final TextField objectSearchField = new TextField("Objektsuche");
private final Button objectSearchButton = new Button("Suchen");
private final ComboBox<ObjectOption> objectField = new ComboBox<>("Objekt");
private final DatePicker fromDate = new DatePicker("Von");
private final DatePicker toDate = new DatePicker("Bis");
private final ComboBox<ReportTypeOption> historyReportTypeField = new ComboBox<>("Berichtstyp");
private final TextField historySearchField = new TextField("Textsuche");
private final Button historyLoadButton = new Button("Historie laden");
private final Anchor historyExportLink = new Anchor();
private final Button historyExportButton = new Button("CSV exportieren");
private final Grid<ReportRow> historyGrid = new Grid<>(ReportRow.class, false);
private final ComboBox<ReportTypeOption> editorReportTypeField = new ComboBox<>("Berichtstyp");
private final Checkbox editorConfidentialField = new Checkbox("Vertraulich");
private final TextArea editorTextField = new TextArea("Berichtstext");
private final Button editorNewButton = new Button("Neu");
private final Button editorSaveButton = new Button("Speichern");
private final Button editorDeleteButton = new Button("Loeschen");
private final Checkbox splitByReportTypeField = new Checkbox("Nach Berichtstyp aufteilen");
private final Checkbox splitByCustomerTypeField = new Checkbox("Nach Kundentyp aufteilen");
private final Button statisticLoadButton = new Button("Statistik laden");
private final Anchor statisticExportLink = new Anchor();
private final Button statisticExportButton = new Button("CSV exportieren");
private final Grid<StatisticRow> statisticGrid = new Grid<>(StatisticRow.class, false);
private final List<ObjectTypeOption> objectTypes = new ArrayList<>();
private final List<ObjectOption> objectOptions = new ArrayList<>();
private final List<ReportTypeOption> historyTypeOptions = new ArrayList<>();
private final List<ReportTypeOption> editorTypeOptions = new ArrayList<>();
private final List<ReportRow> historyRows = new ArrayList<>();
private Long selectedReportId;
private boolean selectedReportEditable;
private boolean selectedReportDeletable;
public ReportWorkspaceView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setSizeFull();
setPadding(true);
setSpacing(true);
configureFilters();
configureHistoryGrid();
configureStatisticGrid();
configureEditor();
configureExportLinks();
Tab historyTab = new Tab("Historie");
Tab statisticsTab = new Tab("Statistik");
Tabs tabs = new Tabs(historyTab, statisticsTab);
VerticalLayout historyContent = buildHistoryContent();
VerticalLayout statisticContent = buildStatisticContent();
statisticContent.setVisible(false);
tabs.addSelectedChangeListener(event -> {
historyContent.setVisible(event.getSelectedTab() == historyTab);
statisticContent.setVisible(event.getSelectedTab() == statisticsTab);
});
add(new H3("Berichtsarbeitsbereich"), buildObjectFilterBar(), tabs, historyContent, statisticContent);
expand(historyContent, statisticContent);
loadBootstrap();
}
private void configureFilters() {
objectTypeField.setItemLabelGenerator(ObjectTypeOption::label);
objectTypeField.setWidth("220px");
objectTypeField.addValueChangeListener(event -> {
clearObjectSelection();
clearEditor();
loadReportTypes();
updateSplitCustomerTypeState();
});
objectSearchField.setWidth("260px");
objectSearchField.setClearButtonVisible(true);
objectSearchButton.addClickListener(event -> searchObjects());
objectField.setItemLabelGenerator(ObjectOption::labelWithSecondary);
objectField.setWidth("420px");
objectField.addValueChangeListener(event -> updateExportLinks());
fromDate.setValue(LocalDate.now().withDayOfMonth(1));
toDate.setValue(LocalDate.now());
fromDate.addValueChangeListener(event -> updateExportLinks());
toDate.addValueChangeListener(event -> updateExportLinks());
historyReportTypeField.setItemLabelGenerator(ReportTypeOption::label);
historyReportTypeField.setWidth("260px");
historyReportTypeField.addValueChangeListener(event -> updateExportLinks());
editorReportTypeField.setItemLabelGenerator(ReportTypeOption::label);
editorReportTypeField.setWidthFull();
historySearchField.setWidth("260px");
historySearchField.setClearButtonVisible(true);
historySearchField.addValueChangeListener(event -> updateExportLinks());
historyLoadButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
historyLoadButton.addClickListener(event -> loadHistory());
splitByReportTypeField.setValue(true);
splitByCustomerTypeField.setValue(false);
splitByReportTypeField.addValueChangeListener(event -> updateExportLinks());
splitByCustomerTypeField.addValueChangeListener(event -> updateExportLinks());
statisticLoadButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
statisticLoadButton.addClickListener(event -> loadStatistics());
}
private void configureHistoryGrid() {
historyGrid.addColumn(ReportRow::createdAt).setHeader("Zeitpunkt").setAutoWidth(true).setFrozen(true);
historyGrid.addColumn(ReportRow::objectLabel).setHeader("Objekt").setFlexGrow(1);
historyGrid.addColumn(ReportRow::reportTypeLabel).setHeader("Berichtstyp").setAutoWidth(true);
historyGrid.addColumn(ReportRow::authorName).setHeader("Mitarbeiter").setAutoWidth(true);
historyGrid.addColumn(row -> row.confidential() ? "Ja" : "").setHeader("Vertraulich").setAutoWidth(true);
historyGrid.addColumn(ReportRow::text).setHeader("Eintrag").setFlexGrow(2);
historyGrid.setSizeFull();
historyGrid.asSingleSelect().addValueChangeListener(event -> fillEditor(event.getValue()));
}
private void configureStatisticGrid() {
statisticGrid.addColumn(StatisticRow::headquartersName).setHeader("Zentrale").setAutoWidth(true).setFrozen(true);
statisticGrid.addColumn(StatisticRow::authorFirstname).setHeader("Vorname").setAutoWidth(true);
statisticGrid.addColumn(StatisticRow::authorName).setHeader("Name").setAutoWidth(true);
statisticGrid.addColumn(StatisticRow::reportTypeLabel).setHeader("Berichtstyp").setAutoWidth(true);
statisticGrid.addColumn(StatisticRow::companyTypeLabel).setHeader("Kundentyp").setAutoWidth(true);
statisticGrid.addColumn(StatisticRow::reports).setHeader("Berichte").setAutoWidth(true);
statisticGrid.setSizeFull();
}
private void configureEditor() {
editorSaveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
editorDeleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
editorTextField.setWidthFull();
editorTextField.setMinHeight("180px");
editorNewButton.addClickListener(event -> clearEditor());
editorSaveButton.addClickListener(event -> saveReport());
editorDeleteButton.addClickListener(event -> deleteReport());
refreshEditorButtons();
}
private void configureExportLinks() {
historyExportLink.add(historyExportButton);
statisticExportLink.add(statisticExportButton);
historyExportLink.getElement().setAttribute("download", true);
statisticExportLink.getElement().setAttribute("download", true);
updateExportLinks();
}
private HorizontalLayout buildObjectFilterBar() {
HorizontalLayout bar = new HorizontalLayout(
objectTypeField,
objectSearchField,
objectSearchButton,
objectField,
fromDate,
toDate
);
bar.setAlignItems(Alignment.BASELINE);
return bar;
}
private VerticalLayout buildHistoryContent() {
HorizontalLayout filters = new HorizontalLayout(historyReportTypeField, historySearchField, historyLoadButton, historyExportLink);
filters.setAlignItems(Alignment.BASELINE);
FormLayout editorForm = new FormLayout();
editorForm.setResponsiveSteps(
new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("900px", 2)
);
editorForm.add(editorReportTypeField, editorConfidentialField, editorTextField);
editorForm.setColspan(editorTextField, 2);
HorizontalLayout actions = new HorizontalLayout(editorNewButton, editorSaveButton, editorDeleteButton);
VerticalLayout content = new VerticalLayout(
new Paragraph("Historie und CRUD arbeiten direkt auf `phoenix_group.report_process`. Vertrauliche Berichte folgen der Legacy-Sichtbarkeit."),
filters,
historyGrid,
new H4("Eintrag bearbeiten"),
editorForm,
actions
);
content.setPadding(false);
content.setSpacing(true);
content.setSizeFull();
content.expand(historyGrid);
return content;
}
private VerticalLayout buildStatisticContent() {
HorizontalLayout controls = new HorizontalLayout(
splitByReportTypeField,
splitByCustomerTypeField,
statisticLoadButton,
statisticExportLink
);
controls.setAlignItems(Alignment.CENTER);
VerticalLayout content = new VerticalLayout(
new Paragraph("Die Statistik gruppiert die Berichte nach Mitarbeiter und optional nach Berichtstyp sowie Kundentyp."),
controls,
statisticGrid
);
content.setPadding(false);
content.setSpacing(true);
content.setSizeFull();
content.expand(statisticGrid);
return content;
}
private void loadBootstrap() {
Long headquartersId = sessionData.getHeadquartersId();
if (headquartersId == null) {
return;
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> bootstrap = restClient.get(
"/report-workspace/bootstrap?hqId=" + headquartersId,
LinkedHashMap.class
);
objectTypes.clear();
Object raw = bootstrap.get("objectTypes");
if (raw instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Map<?, ?> row) {
objectTypes.add(new ObjectTypeOption(
str(row.get("code")),
str(row.get("label"))
));
}
}
}
objectTypeField.setItems(objectTypes);
if (!objectTypes.isEmpty()) {
objectTypeField.setValue(objectTypes.get(0));
}
updateSplitCustomerTypeState();
loadReportTypes();
} catch (Exception e) {
showError("Berichtsworkspace konnte nicht initialisiert werden: " + e.getMessage());
}
}
private void loadReportTypes() {
ObjectTypeOption objectType = objectTypeField.getValue();
Long headquartersId = sessionData.getHeadquartersId();
if (objectType == null || headquartersId == null) {
historyReportTypeField.setItems(List.of());
editorReportTypeField.setItems(List.of());
return;
}
try {
List<LinkedHashMap<String, Object>> result = restClient.getList(
"/report-workspace/report-types?hqId=" + headquartersId + "&objectType=" + objectType.code(),
new ParameterizedTypeReference<>() {}
);
historyTypeOptions.clear();
editorTypeOptions.clear();
historyTypeOptions.add(new ReportTypeOption(0, "Alle"));
editorTypeOptions.add(new ReportTypeOption(0, "(ohne Typ)"));
for (Map<String, Object> row : result) {
Integer id = toInteger(row.get("id"));
if (id == null) {
continue;
}
String label = str(row.get("label"));
historyTypeOptions.add(new ReportTypeOption(id, label));
editorTypeOptions.add(new ReportTypeOption(id, label));
}
historyReportTypeField.setItems(historyTypeOptions);
editorReportTypeField.setItems(editorTypeOptions);
historyReportTypeField.setValue(historyTypeOptions.get(0));
editorReportTypeField.setValue(editorTypeOptions.get(0));
updateExportLinks();
} catch (Exception e) {
showError("Berichtstypen konnten nicht geladen werden: " + e.getMessage());
}
}
private void searchObjects() {
ObjectTypeOption objectType = objectTypeField.getValue();
Long headquartersId = sessionData.getHeadquartersId();
if (objectType == null || headquartersId == null) {
return;
}
try {
String path = "/report-workspace/search?hqId=" + headquartersId
+ "&objectType=" + objectType.code()
+ "&query=" + url(objectSearchField.getValue());
List<LinkedHashMap<String, Object>> result = restClient.getList(path, new ParameterizedTypeReference<>() {});
objectOptions.clear();
for (Map<String, Object> row : result) {
Long id = toLong(row.get("id"));
if (id == null) {
continue;
}
objectOptions.add(new ObjectOption(
id,
str(row.get("label")),
str(row.get("secondaryLabel"))
));
}
objectField.setItems(objectOptions);
if (!objectOptions.isEmpty()) {
objectField.setValue(objectOptions.get(0));
}
updateExportLinks();
} catch (Exception e) {
showError("Objekte konnten nicht gesucht werden: " + e.getMessage());
}
}
private void loadHistory() {
ObjectTypeOption objectType = objectTypeField.getValue();
Long headquartersId = sessionData.getHeadquartersId();
if (objectType == null || headquartersId == null) {
return;
}
try {
StringBuilder path = new StringBuilder("/report-workspace/reports?hqId=")
.append(headquartersId)
.append("&objectType=").append(objectType.code());
if (objectField.getValue() != null) {
path.append("&objectId=").append(objectField.getValue().id());
}
if (fromDate.getValue() != null) {
path.append("&from=").append(fromDate.getValue());
}
if (toDate.getValue() != null) {
path.append("&to=").append(toDate.getValue());
}
if (historyReportTypeField.getValue() != null && historyReportTypeField.getValue().id() != null
&& historyReportTypeField.getValue().id() > 0) {
path.append("&reportType=").append(historyReportTypeField.getValue().id());
}
if (!historySearchField.getValue().isBlank()) {
path.append("&search=").append(url(historySearchField.getValue()));
}
List<LinkedHashMap<String, Object>> result = restClient.getList(path.toString(), new ParameterizedTypeReference<>() {});
historyRows.clear();
for (Map<String, Object> row : result) {
Long id = toLong(row.get("id"));
if (id == null) {
continue;
}
historyRows.add(new ReportRow(
id,
str(row.get("objectType")),
toLong(row.get("objectId")),
str(row.get("objectLabel")),
str(row.get("headquartersName")),
toLong(row.get("authorUserId")),
str(row.get("authorName")),
str(row.get("authorPhone")),
str(row.get("createdAt")).replace("T", " "),
toInteger(row.get("reportType")),
str(row.get("reportTypeLabel")),
str(row.get("text")),
Boolean.TRUE.equals(row.get("confidential")) || "true".equalsIgnoreCase(str(row.get("confidential"))),
Boolean.TRUE.equals(row.get("editable")) || "true".equalsIgnoreCase(str(row.get("editable"))),
Boolean.TRUE.equals(row.get("deletable")) || "true".equalsIgnoreCase(str(row.get("deletable")))
));
}
historyGrid.setItems(historyRows);
historyGrid.asSingleSelect().clear();
clearEditor();
updateExportLinks();
} catch (Exception e) {
showError("Historie konnte nicht geladen werden: " + e.getMessage());
}
}
private void loadStatistics() {
ObjectTypeOption objectType = objectTypeField.getValue();
Long headquartersId = sessionData.getHeadquartersId();
if (objectType == null || headquartersId == null) {
return;
}
try {
StringBuilder path = new StringBuilder("/report-workspace/statistics?hqId=")
.append(headquartersId)
.append("&objectType=").append(objectType.code())
.append("&splitByReportType=").append(splitByReportTypeField.getValue())
.append("&splitByCustomerType=").append(splitByCustomerTypeField.getValue());
if (fromDate.getValue() != null) {
path.append("&from=").append(fromDate.getValue());
}
if (toDate.getValue() != null) {
path.append("&to=").append(toDate.getValue());
}
List<LinkedHashMap<String, Object>> result = restClient.getList(path.toString(), new ParameterizedTypeReference<>() {});
List<StatisticRow> rows = new ArrayList<>();
for (Map<String, Object> row : result) {
rows.add(new StatisticRow(
str(row.get("headquartersName")),
str(row.get("authorFirstname")),
str(row.get("authorName")),
str(row.get("reportTypeLabel")),
str(row.get("companyTypeLabel")),
toLong(row.get("reports")) != null ? toLong(row.get("reports")) : 0L
));
}
statisticGrid.setItems(rows);
updateExportLinks();
} catch (Exception e) {
showError("Statistik konnte nicht geladen werden: " + e.getMessage());
}
}
private void saveReport() {
ObjectTypeOption objectType = objectTypeField.getValue();
ObjectOption object = objectField.getValue();
Long headquartersId = sessionData.getHeadquartersId();
if (objectType == null || object == null || headquartersId == null) {
showError("Bitte zuerst Bereich und Objekt waehlen.");
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("hqId", headquartersId);
request.put("objectType", objectType.code());
request.put("objectId", object.id());
request.put("reportType", editorReportTypeField.getValue() != null ? editorReportTypeField.getValue().id() : 0);
request.put("text", editorTextField.getValue());
request.put("confidential", editorConfidentialField.getValue());
if (selectedReportId == null) {
restClient.post("/report-workspace/reports", request, LinkedHashMap.class);
} else {
restClient.put("/report-workspace/reports/" + selectedReportId, request);
}
showSuccess("Bericht gespeichert.");
loadHistory();
} catch (Exception e) {
showError("Bericht konnte nicht gespeichert werden: " + e.getMessage());
}
}
private void deleteReport() {
Long headquartersId = sessionData.getHeadquartersId();
if (selectedReportId == null || headquartersId == null || !selectedReportDeletable) {
return;
}
try {
restClient.delete("/report-workspace/reports/" + selectedReportId + "?hqId=" + headquartersId);
showSuccess("Bericht geloescht.");
loadHistory();
} catch (Exception e) {
showError("Bericht konnte nicht geloescht werden: " + e.getMessage());
}
}
private void fillEditor(ReportRow row) {
if (row == null) {
clearEditor();
return;
}
selectedReportId = row.id();
selectedReportEditable = row.editable();
selectedReportDeletable = row.deletable();
editorTextField.setValue(row.text());
editorConfidentialField.setValue(row.confidential());
editorReportTypeField.setValue(editorTypeOptions.stream()
.filter(option -> Objects.equals(option.id(), row.reportType()))
.findFirst()
.orElse(editorTypeOptions.isEmpty() ? null : editorTypeOptions.get(0)));
if (row.objectId() != null) {
ObjectOption reportObject = new ObjectOption(row.objectId(), row.objectLabel(), "");
if (objectOptions.stream().noneMatch(option -> Objects.equals(option.id(), reportObject.id()))) {
objectOptions.add(0, reportObject);
objectField.setItems(objectOptions);
}
objectField.setValue(objectOptions.stream()
.filter(option -> Objects.equals(option.id(), reportObject.id()))
.findFirst()
.orElse(reportObject));
}
refreshEditorButtons();
}
private void clearEditor() {
selectedReportId = null;
selectedReportEditable = true;
selectedReportDeletable = false;
historyGrid.asSingleSelect().clear();
editorTextField.clear();
editorConfidentialField.clear();
if (!editorTypeOptions.isEmpty()) {
editorReportTypeField.setValue(editorTypeOptions.get(0));
} else {
editorReportTypeField.clear();
}
refreshEditorButtons();
}
private void clearObjectSelection() {
objectOptions.clear();
objectField.clear();
objectField.setItems(List.of());
updateExportLinks();
}
private void updateSplitCustomerTypeState() {
boolean customerMode = objectTypeField.getValue() != null && "cs".equals(objectTypeField.getValue().code());
splitByCustomerTypeField.setEnabled(customerMode);
if (!customerMode) {
splitByCustomerTypeField.setValue(false);
}
}
private void refreshEditorButtons() {
editorSaveButton.setEnabled(selectedReportId == null || selectedReportEditable);
editorDeleteButton.setEnabled(selectedReportId != null && selectedReportDeletable);
}
private void updateExportLinks() {
boolean historyEnabled = objectTypeField.getValue() != null && sessionData.canManageReports();
boolean statisticEnabled = objectTypeField.getValue() != null && sessionData.canManageReports();
historyExportButton.setEnabled(historyEnabled);
statisticExportButton.setEnabled(statisticEnabled);
if (historyEnabled) {
historyExportLink.setHref(historyExportResource());
} else {
historyExportLink.setHref("");
}
if (statisticEnabled) {
statisticExportLink.setHref(statisticExportResource());
} else {
statisticExportLink.setHref("");
}
}
private StreamResource historyExportResource() {
String filename = "berichte_" + objectTypeField.getValue().code() + ".csv";
return new StreamResource(filename, () -> new ByteArrayInputStream(restClient.getBytes(buildHistoryExportPath())));
}
private StreamResource statisticExportResource() {
String filename = "berichtsstatistik_" + objectTypeField.getValue().code() + ".csv";
return new StreamResource(filename, () -> new ByteArrayInputStream(restClient.getBytes(buildStatisticExportPath())));
}
private String buildHistoryExportPath() {
StringBuilder path = new StringBuilder("/report-workspace/export/history?hqId=")
.append(sessionData.getHeadquartersId())
.append("&objectType=").append(objectTypeField.getValue().code());
if (objectField.getValue() != null) {
path.append("&objectId=").append(objectField.getValue().id());
}
if (fromDate.getValue() != null) {
path.append("&from=").append(fromDate.getValue());
}
if (toDate.getValue() != null) {
path.append("&to=").append(toDate.getValue());
}
if (historyReportTypeField.getValue() != null && historyReportTypeField.getValue().id() != null
&& historyReportTypeField.getValue().id() > 0) {
path.append("&reportType=").append(historyReportTypeField.getValue().id());
}
if (!historySearchField.getValue().isBlank()) {
path.append("&search=").append(url(historySearchField.getValue()));
}
return path.toString();
}
private String buildStatisticExportPath() {
StringBuilder path = new StringBuilder("/report-workspace/export/statistics?hqId=")
.append(sessionData.getHeadquartersId())
.append("&objectType=").append(objectTypeField.getValue().code())
.append("&splitByReportType=").append(splitByReportTypeField.getValue())
.append("&splitByCustomerType=").append(splitByCustomerTypeField.getValue());
if (fromDate.getValue() != null) {
path.append("&from=").append(fromDate.getValue());
}
if (toDate.getValue() != null) {
path.append("&to=").append(toDate.getValue());
}
return path.toString();
}
private void showSuccess(String message) {
Notification.show(message, 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
}
private void showError(String message) {
Notification.show(message, 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
private String url(String value) {
return URLEncoder.encode(value != null ? value : "", StandardCharsets.UTF_8);
}
private String str(Object value) {
return value != null ? value.toString() : "";
}
private Long toLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String string && !string.isBlank()) {
try {
return Long.parseLong(string);
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private Integer toInteger(Object value) {
if (value instanceof Number number) {
return number.intValue();
}
if (value instanceof String string && !string.isBlank()) {
try {
return Integer.parseInt(string);
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private record ObjectTypeOption(String code, String label) {
}
private record ObjectOption(Long id, String label, String secondaryLabel) {
private String labelWithSecondary() {
if (secondaryLabel == null || secondaryLabel.isBlank()) {
return label;
}
return label + " [" + secondaryLabel + "]";
}
}
private record ReportTypeOption(Integer id, String label) {
}
private record ReportRow(Long id,
String objectType,
Long objectId,
String objectLabel,
String headquartersName,
Long authorUserId,
String authorName,
String authorPhone,
String createdAt,
Integer reportType,
String reportTypeLabel,
String text,
boolean confidential,
boolean editable,
boolean deletable) {
}
private record StatisticRow(String headquartersName,
String authorFirstname,
String authorName,
String reportTypeLabel,
String companyTypeLabel,
long reports) {
}
}

View File

@@ -0,0 +1,107 @@
package de.votian.web.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.Map;
@Route(value = "statistics", layout = MainLayout.class)
@PageTitle("Votian - Statistik")
public class StatisticsView extends VerticalLayout {
private final RestClientService restClient;
private final SessionData sessionData;
private final Span totalJobs = new Span("-");
private final Span openJobs = new Span("-");
private final Span deliveredJobs = new Span("-");
private final Span cancelledJobs = new Span("-");
private final Span revenue = new Span("-");
private final Span courierCosts = new Span("-");
public StatisticsView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setPadding(true);
setSpacing(true);
DatePicker fromDate = new DatePicker("Von");
fromDate.setValue(LocalDate.now().withDayOfMonth(1));
DatePicker toDate = new DatePicker("Bis");
toDate.setValue(LocalDate.now());
Button loadButton = new Button("Auswerten", event -> load(fromDate.getValue(), toDate.getValue()));
loadButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
HorizontalLayout filterBar = new HorizontalLayout(fromDate, toDate, loadButton);
HorizontalLayout cards = new HorizontalLayout(
createCard("Auftraege gesamt", totalJobs),
createCard("Offen", openJobs),
createCard("Zugestellt", deliveredJobs),
createCard("Storniert", cancelledJobs),
createCard("Umsatz", revenue),
createCard("Kurierkosten", courierCosts)
);
cards.setWidthFull();
add(new H3("Statistik"), filterBar, cards);
load(fromDate.getValue(), toDate.getValue());
}
private void load(LocalDate fromDate, LocalDate toDate) {
if (sessionData.getHeadquartersId() == null) {
return;
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> data = restClient.get(
"/reporting/statistics?hqId=" + sessionData.getHeadquartersId()
+ "&from=" + fromDate + "&to=" + toDate,
LinkedHashMap.class
);
totalJobs.setText(str(data.get("totalJobs")));
openJobs.setText(str(data.get("openJobs")));
deliveredJobs.setText(str(data.get("deliveredJobs")));
cancelledJobs.setText(str(data.get("cancelledJobs")));
revenue.setText(str(data.get("totalRevenue")));
courierCosts.setText(str(data.get("totalCourierCosts")));
} catch (Exception e) {
Notification.show("Statistik konnte nicht geladen werden.", 4000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private VerticalLayout createCard(String title, Span value) {
VerticalLayout card = new VerticalLayout();
card.setPadding(true);
card.setSpacing(false);
card.getStyle()
.set("border", "1px solid var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-l)")
.set("min-width", "180px");
H3 headline = new H3(title);
headline.getStyle().set("margin", "0");
value.getStyle().set("font-size", "var(--lumo-font-size-xl)").set("font-weight", "700");
card.add(headline, value);
return card;
}
private String str(Object value) {
return value != null ? value.toString() : "-";
}
}

View File

@@ -0,0 +1,916 @@
package de.votian.web.view;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.H4;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.model.SessionData;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Route(value = "tracking", layout = MainLayout.class)
@PageTitle("Votian - Tracking")
public class TrackingView extends VerticalLayout {
private static final long ALL_CUSTOMERS_ID = -1L;
private final RestClientService restClient;
private final SessionData sessionData;
private final DatePicker dayField = new DatePicker("Tag");
private final Checkbox includeCompletedField = new Checkbox("Abgeschlossene anzeigen");
private final ComboBox<CustomerOption> customerField = new ComboBox<>("Kunde");
private final Button reloadButton = new Button("Neu laden", VaadinIcon.REFRESH.create());
private final Button openJobButton = new Button("Auftrag oeffnen", VaadinIcon.EXTERNAL_LINK.create());
private final Span totalJobs = new Span("-");
private final Span assignedJobs = new Span("-");
private final Span trackedJobs = new Span("-");
private final Span totalCouriers = new Span("-");
private final Span gpsCouriers = new Span("-");
private final Span fallbackCouriers = new Span("-");
private final Span mapInfo = new Span("-");
private final Div mapView = new Div();
private final Grid<TrackingJobRow> jobGrid = new Grid<>(TrackingJobRow.class, false);
private final Grid<TrackingCourierRow> courierGrid = new Grid<>(TrackingCourierRow.class, false);
private final Span detailJobId = new Span("-");
private final Span detailJobStatus = new Span("-");
private final Span detailJobCustomer = new Span("-");
private final Span detailJobCostCenter = new Span("-");
private final Span detailJobRoute = new Span("-");
private final Span detailJobCourier = new Span("-");
private final Span detailJobTimes = new Span("-");
private final Span detailJobTracking = new Span("-");
private final Span detailJobPrice = new Span("-");
private final Span detailCourierSid = new Span("-");
private final Span detailCourierCompany = new Span("-");
private final Span detailCourierVehicle = new Span("-");
private final Span detailCourierJobs = new Span("-");
private final Span detailCourierMobile = new Span("-");
private final Span detailCourierTracking = new Span("-");
private final Span detailCourierAvailable = new Span("-");
private final List<TrackingJobRow> jobs = new ArrayList<>();
private final List<TrackingCourierRow> couriers = new ArrayList<>();
private TrackingJobRow selectedJob;
private TrackingCourierRow selectedCourier;
public TrackingView(RestClientService restClient, SessionData sessionData) {
this.restClient = restClient;
this.sessionData = sessionData;
setSizeFull();
setPadding(true);
setSpacing(true);
dayField.setValue(LocalDate.now());
customerField.setItemLabelGenerator(CustomerOption::label);
customerField.setWidth("320px");
customerField.setVisible(sessionData.isHeadquartersUser() && sessionData.canViewCustomers());
customerField.addValueChangeListener(event -> loadDashboard());
includeCompletedField.addValueChangeListener(event -> loadDashboard());
reloadButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
reloadButton.addClickListener(event -> loadDashboard());
openJobButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
openJobButton.addClickListener(event -> {
if (selectedJob != null && sessionData.canViewJobs()) {
getUI().ifPresent(ui -> ui.navigate("jobs/" + selectedJob.jobId()));
}
});
configureMapView();
configureJobGrid();
configureCourierGrid();
refreshActionState();
clearDetail();
HorizontalLayout workspace = buildWorkspace();
add(
new H3("Tracking"),
buildFilterBar(),
buildCards(),
buildMapPanel(),
workspace,
buildDetailPanels()
);
expand(workspace);
if (customerField.isVisible()) {
loadCustomers();
}
loadDashboard();
}
private HorizontalLayout buildFilterBar() {
HorizontalLayout filterBar = new HorizontalLayout(dayField, includeCompletedField, customerField, reloadButton, openJobButton);
filterBar.setAlignItems(Alignment.BASELINE);
filterBar.getStyle().set("flex-wrap", "wrap");
return filterBar;
}
private HorizontalLayout buildCards() {
HorizontalLayout cards = new HorizontalLayout(
createCard("Auftraege", totalJobs),
createCard("Mit Kurier", assignedJobs),
createCard("Mit Tracking", trackedJobs),
createCard("Kuriere", totalCouriers),
createCard("GPS", gpsCouriers),
createCard("PLZ-Fallback", fallbackCouriers)
);
cards.setWidthFull();
cards.getStyle().set("flex-wrap", "wrap");
return cards;
}
private Component buildMapPanel() {
VerticalLayout panel = new VerticalLayout(new H4("Karte"), mapInfo, mapView);
panel.setPadding(false);
panel.setSpacing(true);
return panel;
}
private HorizontalLayout buildWorkspace() {
VerticalLayout jobPanel = new VerticalLayout(new H4("Auftraege"), jobGrid);
jobPanel.setPadding(false);
jobPanel.setSpacing(true);
jobPanel.setSizeFull();
jobPanel.expand(jobGrid);
VerticalLayout courierPanel = new VerticalLayout(new H4("Kuriere"), courierGrid);
courierPanel.setPadding(false);
courierPanel.setSpacing(true);
courierPanel.setSizeFull();
courierPanel.expand(courierGrid);
HorizontalLayout workspace = new HorizontalLayout(jobPanel, courierPanel);
workspace.setSizeFull();
workspace.expand(jobPanel, courierPanel);
return workspace;
}
private HorizontalLayout buildDetailPanels() {
VerticalLayout jobDetailPanel = new VerticalLayout(
new H4("Auftragsdetails"),
detailLine("Auftrag", detailJobId),
detailLine("Status", detailJobStatus),
detailLine("Kunde", detailJobCustomer),
detailLine("Kostenstelle", detailJobCostCenter),
detailLine("Route", detailJobRoute),
detailLine("Kurier", detailJobCourier),
detailLine("Zeiten", detailJobTimes),
detailLine("Tracking", detailJobTracking),
detailLine("Preis", detailJobPrice)
);
jobDetailPanel.setPadding(true);
jobDetailPanel.setSpacing(true);
jobDetailPanel.getStyle()
.set("border", "1px solid var(--lumo-contrast-10pct)")
.set("border-radius", "var(--lumo-border-radius-m)");
VerticalLayout courierDetailPanel = new VerticalLayout(
new H4("Kurierdetails"),
detailLine("Kurier", detailCourierSid),
detailLine("Firma", detailCourierCompany),
detailLine("Fahrzeug", detailCourierVehicle),
detailLine("Aktive Auftraege", detailCourierJobs),
detailLine("Mobile/PDA", detailCourierMobile),
detailLine("Tracking", detailCourierTracking),
detailLine("Verfuegbar seit", detailCourierAvailable)
);
courierDetailPanel.setPadding(true);
courierDetailPanel.setSpacing(true);
courierDetailPanel.getStyle()
.set("border", "1px solid var(--lumo-contrast-10pct)")
.set("border-radius", "var(--lumo-border-radius-m)");
HorizontalLayout panels = new HorizontalLayout(jobDetailPanel, courierDetailPanel);
panels.setWidthFull();
panels.expand(jobDetailPanel, courierDetailPanel);
return panels;
}
private HorizontalLayout detailLine(String label, Span value) {
Span title = new Span(label + ":");
title.getStyle().set("font-weight", "600").set("min-width", "110px");
HorizontalLayout line = new HorizontalLayout(title, value);
line.setAlignItems(Alignment.BASELINE);
line.setWidthFull();
return line;
}
private VerticalLayout createCard(String title, Span value) {
Span heading = new Span(title);
heading.getStyle().set("font-size", "var(--lumo-font-size-s)");
value.getStyle().set("font-size", "var(--lumo-font-size-l)").set("font-weight", "700");
VerticalLayout card = new VerticalLayout(heading, value);
card.setPadding(true);
card.setSpacing(false);
card.getStyle()
.set("border", "1px solid var(--lumo-contrast-10pct)")
.set("border-radius", "var(--lumo-border-radius-m)")
.set("background", "var(--lumo-base-color)")
.set("min-width", "160px");
return card;
}
private void configureMapView() {
mapView.setWidthFull();
mapView.setHeight("360px");
mapView.getStyle()
.set("border", "1px solid var(--lumo-contrast-10pct)")
.set("border-radius", "var(--lumo-border-radius-m)")
.set("overflow", "hidden")
.set("background", "linear-gradient(180deg, #edf6ff 0%, #f7f9fc 100%)");
mapInfo.getStyle()
.set("font-size", "var(--lumo-font-size-s)")
.set("color", "var(--lumo-secondary-text-color)");
}
private void configureJobGrid() {
jobGrid.addColumn(TrackingJobRow::jobId).setHeader("Auftrag").setAutoWidth(true).setFrozen(true);
jobGrid.addColumn(TrackingJobRow::statusLabel).setHeader("Status").setAutoWidth(true);
jobGrid.addColumn(row -> formatDateTime(row.orderTime())).setHeader("Zeit").setAutoWidth(true);
jobGrid.addColumn(TrackingJobRow::customerName).setHeader("Kunde").setFlexGrow(1);
jobGrid.addColumn(TrackingJobRow::costCenterName).setHeader("Kostenstelle").setFlexGrow(1);
jobGrid.addColumn(TrackingJobRow::pickupLabel).setHeader("Start").setFlexGrow(1);
jobGrid.addColumn(TrackingJobRow::deliveryLabel).setHeader("Ziel").setFlexGrow(1);
jobGrid.addColumn(TrackingJobRow::courierSid).setHeader("Kurier").setAutoWidth(true);
jobGrid.addColumn(TrackingJobRow::trackingLabel).setHeader("Tracking").setAutoWidth(true);
jobGrid.addColumn(row -> formatAmount(row.totalPrice())).setHeader("Preis").setAutoWidth(true);
jobGrid.setSizeFull();
jobGrid.asSingleSelect().addValueChangeListener(event -> {
selectedJob = event.getValue();
if (selectedJob != null && selectedJob.courierId() != null) {
TrackingCourierRow matchingCourier = couriers.stream()
.filter(courier -> selectedJob.courierId().equals(courier.courierId()))
.findFirst()
.orElse(null);
courierGrid.asSingleSelect().setValue(matchingCourier);
selectedCourier = matchingCourier;
}
if (selectedJob == null) {
selectedCourier = courierGrid.asSingleSelect().getValue();
}
updateDetail();
updateMap();
refreshActionState();
});
}
private void configureCourierGrid() {
courierGrid.addColumn(TrackingCourierRow::courierSid).setHeader("Kurier").setAutoWidth(true).setFrozen(true);
courierGrid.addColumn(TrackingCourierRow::companyName).setHeader("Firma").setFlexGrow(1);
courierGrid.addColumn(TrackingCourierRow::vehicleDisplay).setHeader("Fahrzeug").setAutoWidth(true);
courierGrid.addColumn(TrackingCourierRow::activeJobs).setHeader("Jobs").setAutoWidth(true);
courierGrid.addColumn(TrackingCourierRow::locationZipcode).setHeader("PLZ").setAutoWidth(true);
courierGrid.addColumn(TrackingCourierRow::trackingLabel).setHeader("Tracking").setAutoWidth(true);
courierGrid.addColumn(row -> formatDateTime(row.gpsTime())).setHeader("Zeit").setAutoWidth(true);
courierGrid.setSizeFull();
courierGrid.asSingleSelect().addValueChangeListener(event -> {
selectedCourier = event.getValue();
updateDetail();
updateMap();
});
}
private void loadCustomers() {
try {
List<LinkedHashMap<String, Object>> rows = restClient.getList(
"/customers/hq/" + sessionData.getHeadquartersId(),
new ParameterizedTypeReference<>() {}
);
List<CustomerOption> options = new ArrayList<>();
options.add(new CustomerOption(ALL_CUSTOMERS_ID, "Alle Kunden"));
for (Map<String, Object> row : rows) {
Long id = toLong(row.get("id"));
if (id != null) {
options.add(new CustomerOption(id, str(row.get("companyName"))));
}
}
options.sort(Comparator.comparing(CustomerOption::label, String.CASE_INSENSITIVE_ORDER));
if (!options.isEmpty()) {
CustomerOption allOption = options.stream()
.filter(option -> option.id().equals(ALL_CUSTOMERS_ID))
.findFirst()
.orElse(new CustomerOption(ALL_CUSTOMERS_ID, "Alle Kunden"));
options.removeIf(option -> option.id().equals(ALL_CUSTOMERS_ID));
options.add(0, allOption);
}
customerField.setItems(options);
customerField.setValue(options.getFirst());
} catch (Exception e) {
Notification.show("Kundenfilter konnte nicht geladen werden: " + e.getMessage(), 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private void loadDashboard() {
if (sessionData.getHeadquartersId() == null || dayField.getValue() == null) {
return;
}
try {
StringBuilder path = new StringBuilder("/tracking/dashboard?hqId=")
.append(sessionData.getHeadquartersId())
.append("&day=").append(dayField.getValue())
.append("&includeCompleted=").append(includeCompletedField.getValue());
CustomerOption customer = customerField.getValue();
if (customerField.isVisible() && customer != null && !customer.id().equals(ALL_CUSTOMERS_ID)) {
path.append("&customerId=").append(customer.id());
}
Map<String, Object> data = restClient.getMap(path.toString());
jobs.clear();
jobs.addAll(parseJobs(data.get("jobs")));
couriers.clear();
couriers.addAll(parseCouriers(data.get("couriers")));
totalJobs.setText(str(data.get("totalJobs"), jobs.size()));
assignedJobs.setText(str(data.get("assignedJobs"), 0));
trackedJobs.setText(str(data.get("trackedJobs"), 0));
totalCouriers.setText(str(data.get("totalCouriers"), couriers.size()));
gpsCouriers.setText(str(data.get("gpsCouriers"), 0));
fallbackCouriers.setText(str(data.get("fallbackCouriers"), 0));
jobGrid.setItems(jobs);
courierGrid.setItems(couriers);
if (selectedJob != null) {
TrackingJobRow persistedJob = jobs.stream()
.filter(row -> row.jobId().equals(selectedJob.jobId()))
.findFirst()
.orElse(null);
jobGrid.asSingleSelect().setValue(persistedJob);
selectedJob = persistedJob;
} else {
jobGrid.asSingleSelect().clear();
}
if (selectedCourier != null) {
TrackingCourierRow persistedCourier = couriers.stream()
.filter(row -> row.courierId().equals(selectedCourier.courierId()))
.findFirst()
.orElse(null);
courierGrid.asSingleSelect().setValue(persistedCourier);
selectedCourier = persistedCourier;
} else {
courierGrid.asSingleSelect().clear();
}
if (selectedJob == null && !jobs.isEmpty()) {
jobGrid.asSingleSelect().setValue(jobs.getFirst());
selectedJob = jobs.getFirst();
}
if (selectedCourier == null && !couriers.isEmpty()) {
selectedCourier = couriers.getFirst();
courierGrid.asSingleSelect().setValue(selectedCourier);
}
updateDetail();
updateMap();
refreshActionState();
} catch (Exception e) {
Notification.show("Tracking konnte nicht geladen werden: " + e.getMessage(), 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
private List<TrackingJobRow> parseJobs(Object rawValue) {
if (!(rawValue instanceof List<?> rawList)) {
return List.of();
}
List<TrackingJobRow> rows = new ArrayList<>();
for (Object entry : rawList) {
if (!(entry instanceof Map<?, ?> rawMap)) {
continue;
}
rows.add(new TrackingJobRow(
toLong(rawMap.get("jobId")),
toInteger(rawMap.get("status")),
str(rawMap.get("statusLabel")),
str(rawMap.get("orderTime")),
str(rawMap.get("takeTime")),
str(rawMap.get("finishTime")),
toLong(rawMap.get("customerId")),
str(rawMap.get("customerName")),
str(rawMap.get("costCenterName")),
str(rawMap.get("commissionNo")),
str(rawMap.get("pickupName")),
str(rawMap.get("pickupZipcode")),
str(rawMap.get("pickupCity")),
toDouble(rawMap.get("pickupLatitude")),
toDouble(rawMap.get("pickupLongitude")),
str(rawMap.get("deliveryName")),
str(rawMap.get("deliveryZipcode")),
str(rawMap.get("deliveryCity")),
toDouble(rawMap.get("deliveryLatitude")),
toDouble(rawMap.get("deliveryLongitude")),
toLong(rawMap.get("courierId")),
str(rawMap.get("courierSid")),
str(rawMap.get("courierCompanyName")),
str(rawMap.get("courierVehicleSid")),
str(rawMap.get("vehicleTypeName")),
toDouble(rawMap.get("courierLatitude")),
toDouble(rawMap.get("courierLongitude")),
str(rawMap.get("courierGpsTime")),
str(rawMap.get("courierGpsTypeLabel")),
str(rawMap.get("courierLocationZipcode")),
bool(rawMap.get("courierTracked")),
bool(rawMap.get("courierGpsBased")),
toBigDecimal(rawMap.get("totalPrice"))
));
}
return rows;
}
private List<TrackingCourierRow> parseCouriers(Object rawValue) {
if (!(rawValue instanceof List<?> rawList)) {
return List.of();
}
List<TrackingCourierRow> rows = new ArrayList<>();
for (Object entry : rawList) {
if (!(entry instanceof Map<?, ?> rawMap)) {
continue;
}
rows.add(new TrackingCourierRow(
toLong(rawMap.get("courierId")),
str(rawMap.get("courierSid")),
str(rawMap.get("companyName")),
str(rawMap.get("vehicleDisplay")),
toInteger(rawMap.get("activeJobs")) != null ? toInteger(rawMap.get("activeJobs")) : 0,
str(rawMap.get("locationZipcode")),
toDouble(rawMap.get("latitude")),
toDouble(rawMap.get("longitude")),
str(rawMap.get("gpsTime")),
str(rawMap.get("gpsTypeLabel")),
str(rawMap.get("mobilePda")),
str(rawMap.get("availableTime")),
bool(rawMap.get("tracked")),
bool(rawMap.get("gpsBased"))
));
}
return rows;
}
private void refreshActionState() {
openJobButton.setEnabled(selectedJob != null && sessionData.canViewJobs());
}
private void updateDetail() {
if (selectedJob == null) {
detailJobId.setText("-");
detailJobStatus.setText("-");
detailJobCustomer.setText("-");
detailJobCostCenter.setText("-");
detailJobRoute.setText("-");
detailJobCourier.setText("-");
detailJobTimes.setText("-");
detailJobTracking.setText("-");
detailJobPrice.setText("-");
} else {
detailJobId.setText(str(selectedJob.jobId()));
detailJobStatus.setText(orDash(selectedJob.statusLabel()));
detailJobCustomer.setText(orDash(selectedJob.customerName()));
detailJobCostCenter.setText(orDash(selectedJob.costCenterName()));
detailJobRoute.setText(orDash(selectedJob.pickupLabel() + " -> " + selectedJob.deliveryLabel()));
detailJobCourier.setText(orDash(selectedJob.courierSid()));
detailJobTimes.setText(orDash(formatJobTimes(selectedJob)));
detailJobTracking.setText(orDash(selectedJob.trackingLabel()));
detailJobPrice.setText(orDash(formatAmount(selectedJob.totalPrice())));
}
if (selectedCourier == null) {
detailCourierSid.setText("-");
detailCourierCompany.setText("-");
detailCourierVehicle.setText("-");
detailCourierJobs.setText("-");
detailCourierMobile.setText("-");
detailCourierTracking.setText("-");
detailCourierAvailable.setText("-");
} else {
detailCourierSid.setText(orDash(selectedCourier.courierSid()));
detailCourierCompany.setText(orDash(selectedCourier.companyName()));
detailCourierVehicle.setText(orDash(selectedCourier.vehicleDisplay()));
detailCourierJobs.setText(String.valueOf(selectedCourier.activeJobs()));
detailCourierMobile.setText(orDash(selectedCourier.mobilePda()));
detailCourierTracking.setText(orDash(selectedCourier.trackingLabel()));
detailCourierAvailable.setText(orDash(formatDateTime(selectedCourier.availableTime())));
}
}
private void clearDetail() {
selectedJob = null;
selectedCourier = null;
updateDetail();
}
private void updateMap() {
long mappedJobs = jobs.stream().filter(TrackingJobRow::hasMapRoute).count();
long trackedCouriers = couriers.stream().filter(TrackingCourierRow::hasPoint).count();
if (jobs.isEmpty() && couriers.isEmpty()) {
mapInfo.setText("Keine Trackingdaten fuer den gewaehlten Tag.");
mapView.getElement().setProperty("innerHTML", buildEmptyMapSvg("Keine Trackingdaten"));
return;
}
mapInfo.setText(mappedJobs + " geocodierte Auftragsrouten, " + trackedCouriers + " ortbare Kuriere");
mapView.getElement().setProperty("innerHTML", buildMapSvg());
}
private String buildMapSvg() {
List<PointSource> points = new ArrayList<>();
for (TrackingJobRow row : jobs) {
if (row.hasMapRoute()) {
points.add(new PointSource(row.pickupLatitude(), row.pickupLongitude()));
points.add(new PointSource(row.deliveryLatitude(), row.deliveryLongitude()));
}
}
for (TrackingCourierRow row : couriers) {
if (row.hasPoint()) {
points.add(new PointSource(row.latitude(), row.longitude()));
}
}
if (points.isEmpty()) {
return buildEmptyMapSvg("Keine geocodierten Trackingdaten");
}
double minLongitude = points.stream().mapToDouble(PointSource::longitude).min().orElse(5.5d);
double maxLongitude = points.stream().mapToDouble(PointSource::longitude).max().orElse(15.5d);
double minLatitude = points.stream().mapToDouble(PointSource::latitude).min().orElse(47.0d);
double maxLatitude = points.stream().mapToDouble(PointSource::latitude).max().orElse(55.0d);
double longitudeSpan = Math.max(maxLongitude - minLongitude, 4.0d);
double latitudeSpan = Math.max(maxLatitude - minLatitude, 3.0d);
minLongitude -= longitudeSpan * 0.12d;
maxLongitude += longitudeSpan * 0.12d;
minLatitude -= latitudeSpan * 0.12d;
maxLatitude += latitudeSpan * 0.12d;
int width = 820;
int height = 360;
int padding = 26;
StringBuilder svg = new StringBuilder();
svg.append("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ").append(width).append(' ').append(height).append("'>");
svg.append("<defs>");
svg.append("<linearGradient id='tracking-bg' x1='0' y1='0' x2='0' y2='1'>")
.append("<stop offset='0%' stop-color='#edf6ff'/>")
.append("<stop offset='100%' stop-color='#f7f9fc'/>")
.append("</linearGradient>");
svg.append("</defs>");
svg.append("<rect width='").append(width).append("' height='").append(height).append("' fill='url(#tracking-bg)'/>");
svg.append("<rect x='").append(padding).append("' y='").append(padding).append("' width='")
.append(width - (padding * 2)).append("' height='").append(height - (padding * 2))
.append("' rx='18' fill='#ffffff' fill-opacity='0.55' stroke='#0f172a' stroke-opacity='0.10'/>");
for (int step = 1; step < 5; step++) {
double verticalX = padding + ((width - (padding * 2)) / 5.0d) * step;
double horizontalY = padding + ((height - (padding * 2)) / 5.0d) * step;
svg.append("<line x1='").append(roundSvg(verticalX)).append("' y1='").append(padding)
.append("' x2='").append(roundSvg(verticalX)).append("' y2='").append(height - padding)
.append("' stroke='#0f172a' stroke-opacity='0.08' stroke-dasharray='4 8'/>");
svg.append("<line x1='").append(padding).append("' y1='").append(roundSvg(horizontalY))
.append("' x2='").append(width - padding).append("' y2='").append(roundSvg(horizontalY))
.append("' stroke='#0f172a' stroke-opacity='0.08' stroke-dasharray='4 8'/>");
}
for (TrackingJobRow row : jobs) {
appendRoute(svg, row, minLongitude, maxLongitude, minLatitude, maxLatitude, width, height, padding);
}
for (TrackingCourierRow row : couriers) {
appendCourierMarker(svg, row, minLongitude, maxLongitude, minLatitude, maxLatitude, width, height, padding);
}
svg.append("</svg>");
return svg.toString();
}
private void appendRoute(StringBuilder svg,
TrackingJobRow row,
double minLongitude,
double maxLongitude,
double minLatitude,
double maxLatitude,
int width,
int height,
int padding) {
if (!row.hasMapRoute()) {
return;
}
double x1 = projectLongitude(row.pickupLongitude(), minLongitude, maxLongitude, width, padding);
double y1 = projectLatitude(row.pickupLatitude(), minLatitude, maxLatitude, height, padding);
double x2 = projectLongitude(row.deliveryLongitude(), minLongitude, maxLongitude, width, padding);
double y2 = projectLatitude(row.deliveryLatitude(), minLatitude, maxLatitude, height, padding);
String color = statusColor(row.status());
boolean selected = selectedJob != null && row.jobId().equals(selectedJob.jobId());
String tooltip = escapeSvgText("Auftrag " + row.jobId() + " | " + row.pickupLabel() + " -> " + row.deliveryLabel());
if (selected) {
svg.append("<line x1='").append(roundSvg(x1)).append("' y1='").append(roundSvg(y1))
.append("' x2='").append(roundSvg(x2)).append("' y2='").append(roundSvg(y2))
.append("' stroke='#101828' stroke-opacity='0.22' stroke-width='8' stroke-linecap='round'/>");
}
svg.append("<line x1='").append(roundSvg(x1)).append("' y1='").append(roundSvg(y1))
.append("' x2='").append(roundSvg(x2)).append("' y2='").append(roundSvg(y2))
.append("' stroke='").append(color).append("' stroke-width='").append(selected ? "4.5" : "3")
.append("' stroke-linecap='round' stroke-opacity='0.88'>");
svg.append("<title>").append(tooltip).append("</title></line>");
svg.append("<circle cx='").append(roundSvg(x1)).append("' cy='").append(roundSvg(y1))
.append("' r='").append(selected ? "5.5" : "4").append("' fill='#ffffff' stroke='")
.append(color).append("' stroke-width='2'/>");
svg.append("<circle cx='").append(roundSvg(x2)).append("' cy='").append(roundSvg(y2))
.append("' r='").append(selected ? "5.5" : "4.5").append("' fill='").append(color)
.append("' stroke='#ffffff' stroke-width='1.5'/>");
}
private void appendCourierMarker(StringBuilder svg,
TrackingCourierRow row,
double minLongitude,
double maxLongitude,
double minLatitude,
double maxLatitude,
int width,
int height,
int padding) {
if (!row.hasPoint()) {
return;
}
double x = projectLongitude(row.longitude(), minLongitude, maxLongitude, width, padding);
double y = projectLatitude(row.latitude(), minLatitude, maxLatitude, height, padding);
boolean selected = selectedCourier != null && row.courierId().equals(selectedCourier.courierId());
String fill = row.gpsBased() ? "#175cd3" : "#b54708";
String tooltip = escapeSvgText("Kurier " + row.courierSid() + " | " + row.trackingLabel());
if (selected) {
svg.append("<circle cx='").append(roundSvg(x)).append("' cy='").append(roundSvg(y))
.append("' r='12' fill='#101828' fill-opacity='0.15'/>");
}
svg.append("<circle cx='").append(roundSvg(x)).append("' cy='").append(roundSvg(y))
.append("' r='").append(selected ? "7" : "5.5").append("' fill='").append(fill)
.append("' stroke='#ffffff' stroke-width='2'>");
svg.append("<title>").append(tooltip).append("</title></circle>");
}
private String buildEmptyMapSvg(String message) {
return "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 820 360'>"
+ "<rect width='820' height='360' fill='#f7f9fc'/>"
+ "<rect x='26' y='26' width='768' height='308' rx='18' fill='#ffffff' stroke='#0f172a' stroke-opacity='0.10'/>"
+ "<text x='410' y='180' text-anchor='middle' fill='#475467' font-size='16' font-family='Arial, sans-serif'>"
+ escapeSvgText(message)
+ "</text></svg>";
}
private double projectLongitude(Double longitude, double minLongitude, double maxLongitude, int width, int padding) {
double usableWidth = width - (padding * 2.0d);
return padding + ((longitude - minLongitude) / (maxLongitude - minLongitude)) * usableWidth;
}
private double projectLatitude(Double latitude, double minLatitude, double maxLatitude, int height, int padding) {
double usableHeight = height - (padding * 2.0d);
return padding + (1.0d - ((latitude - minLatitude) / (maxLatitude - minLatitude))) * usableHeight;
}
private String roundSvg(double value) {
return String.format(Locale.ROOT, "%.2f", value);
}
private String escapeSvgText(String value) {
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;");
}
private String statusColor(Integer status) {
if (status == null) {
return "#667085";
}
return switch (status) {
case 1 -> "#175cd3";
case 2 -> "#087443";
case 8 -> "#087443";
case 9 -> "#b42318";
default -> "#667085";
};
}
private String formatJobTimes(TrackingJobRow row) {
List<String> values = new ArrayList<>();
if (!row.orderTime().isBlank()) {
values.add("Auftrag " + formatDateTime(row.orderTime()));
}
if (!row.takeTime().isBlank()) {
values.add("Uebernommen " + formatDateTime(row.takeTime()));
}
if (!row.finishTime().isBlank()) {
values.add("Abschluss " + formatDateTime(row.finishTime()));
}
return values.isEmpty() ? "" : String.join(" / ", values);
}
private String formatDateTime(String value) {
return value == null || value.isBlank() ? "" : value.replace("T", " ");
}
private String formatAmount(BigDecimal value) {
return value != null ? value.toPlainString() : "";
}
private String orDash(String value) {
return value == null || value.isBlank() ? "-" : value;
}
private Long toLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
try {
return value != null ? Long.parseLong(value.toString()) : null;
} catch (NumberFormatException e) {
return null;
}
}
private Integer toInteger(Object value) {
if (value instanceof Number number) {
return number.intValue();
}
try {
return value != null ? Integer.parseInt(value.toString()) : null;
} catch (NumberFormatException e) {
return null;
}
}
private Double toDouble(Object value) {
if (value instanceof Number number) {
return number.doubleValue();
}
try {
return value != null ? Double.parseDouble(value.toString()) : null;
} catch (NumberFormatException e) {
return null;
}
}
private BigDecimal toBigDecimal(Object value) {
if (value instanceof BigDecimal decimal) {
return decimal;
}
try {
return value != null ? new BigDecimal(value.toString()) : null;
} catch (NumberFormatException e) {
return null;
}
}
private boolean bool(Object value) {
return Boolean.TRUE.equals(value) || "true".equalsIgnoreCase(str(value));
}
private String str(Object value) {
return value != null ? value.toString() : "";
}
private String str(Object value, Object fallback) {
String text = str(value);
return text.isBlank() ? String.valueOf(fallback) : text;
}
private record CustomerOption(Long id, String label) {
}
private record PointSource(Double latitude, Double longitude) {
}
private record TrackingJobRow(Long jobId,
Integer status,
String statusLabel,
String orderTime,
String takeTime,
String finishTime,
Long customerId,
String customerName,
String costCenterName,
String commissionNo,
String pickupName,
String pickupZipcode,
String pickupCity,
Double pickupLatitude,
Double pickupLongitude,
String deliveryName,
String deliveryZipcode,
String deliveryCity,
Double deliveryLatitude,
Double deliveryLongitude,
Long courierId,
String courierSid,
String courierCompanyName,
String courierVehicleSid,
String vehicleTypeName,
Double courierLatitude,
Double courierLongitude,
String courierGpsTime,
String courierGpsTypeLabel,
String courierLocationZipcode,
boolean courierTracked,
boolean courierGpsBased,
BigDecimal totalPrice) {
private boolean hasMapRoute() {
return pickupLatitude != null && pickupLongitude != null && deliveryLatitude != null && deliveryLongitude != null;
}
private String pickupLabel() {
return joinNonBlank(pickupName, joinNonBlank(pickupZipcode, pickupCity));
}
private String deliveryLabel() {
return joinNonBlank(deliveryName, joinNonBlank(deliveryZipcode, deliveryCity));
}
private String trackingLabel() {
if (!courierTracked) {
return "";
}
String mode = courierGpsBased ? "GPS" : "PLZ";
String location = courierLocationZipcode != null && !courierLocationZipcode.isBlank() ? " / " + courierLocationZipcode : "";
String time = courierGpsTime != null && !courierGpsTime.isBlank() ? " / " + courierGpsTime.replace("T", " ") : "";
return mode + location + time;
}
}
private record TrackingCourierRow(Long courierId,
String courierSid,
String companyName,
String vehicleDisplay,
int activeJobs,
String locationZipcode,
Double latitude,
Double longitude,
String gpsTime,
String gpsTypeLabel,
String mobilePda,
String availableTime,
boolean tracked,
boolean gpsBased) {
private boolean hasPoint() {
return latitude != null && longitude != null;
}
private String trackingLabel() {
if (!tracked) {
return "";
}
String mode = gpsBased ? "GPS" : "PLZ";
String zipcode = locationZipcode != null && !locationZipcode.isBlank() ? " / " + locationZipcode : "";
String time = gpsTime != null && !gpsTime.isBlank() ? " / " + gpsTime.replace("T", " ") : "";
return mode + zipcode + time;
}
}
private static String joinNonBlank(String left, String right) {
String leftValue = left != null ? left : "";
String rightValue = right != null ? right : "";
if (leftValue.isBlank()) {
return rightValue;
}
if (rightValue.isBlank()) {
return leftValue;
}
return leftValue + " / " + rightValue;
}
}

View File

@@ -0,0 +1,843 @@
package de.votian.web.view;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.H4;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.tabs.Tab;
import com.vaadin.flow.component.tabs.Tabs;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.votian.web.service.RestClientService;
import org.springframework.core.ParameterizedTypeReference;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Route(value = "warehouse", layout = MainLayout.class)
@PageTitle("Votian - Lager")
public class WarehouseView extends VerticalLayout {
private final RestClientService restClient;
private final ComboBox<RootOption> rootStockField = new ComboBox<>("Hauptlager");
private final Span selectedStockText = new Span("-");
private final Span selectedCapacityText = new Span("-");
private final Span accessText = new Span("-");
private final Checkbox includeSubstocksField = new Checkbox("Unterlager einbeziehen", true);
private final TextField inventorySearchField = new TextField("Bestandssuche");
private final Grid<StockRow> stockGrid = new Grid<>(StockRow.class, false);
private final Grid<InventoryRow> inventoryGrid = new Grid<>(InventoryRow.class, false);
private final Grid<SerialRow> serialGrid = new Grid<>(SerialRow.class, false);
private final Grid<JournalRow> journalGrid = new Grid<>(JournalRow.class, false);
private final ComboBox<ArticleOption> movementArticleField = new ComboBox<>("Artikel");
private final ComboBox<MovementStockOption> movementSourceField = new ComboBox<>("Quelle");
private final ComboBox<MovementStockOption> movementTargetField = new ComboBox<>("Ziel");
private final IntegerField movementQuantityField = new IntegerField("Menge");
private final TextField movementSerialField = new TextField("Seriennummer");
private final TextField movementTanField = new TextField("TAN");
private final TextField movementJobField = new TextField("Auftrag");
private final TextField movementRemarkField = new TextField("Bemerkung");
private final Button moveButton = new Button("Buchen");
private final DatePicker journalFromField = new DatePicker("Von");
private final DatePicker journalToField = new DatePicker("Bis");
private final ComboBox<MovementStockOption> journalSourceField = new ComboBox<>("Quelle");
private final ComboBox<MovementStockOption> journalTargetField = new ComboBox<>("Ziel");
private final TextField journalSearchField = new TextField("Journalfilter");
private final List<RootOption> rootOptions = new ArrayList<>();
private final List<StockRow> stockRows = new ArrayList<>();
private final List<ArticleOption> articleOptions = new ArrayList<>();
private boolean canMove;
private StockRow selectedStock;
private InventoryRow selectedInventory;
private SerialRow selectedSerial;
public WarehouseView(RestClientService restClient) {
this.restClient = restClient;
setSizeFull();
setPadding(true);
setSpacing(true);
configureSelectors();
configureGrids();
configureMovementForm();
configureJournalFilters();
VerticalLayout inventoryContent = buildInventoryContent();
VerticalLayout movementContent = buildMovementContent();
VerticalLayout journalContent = buildJournalContent();
movementContent.setVisible(false);
journalContent.setVisible(false);
Tab inventoryTab = new Tab("Bestand");
Tab movementTab = new Tab("Bewegung");
Tab journalTab = new Tab("Journal");
Tabs tabs = new Tabs(inventoryTab, movementTab, journalTab);
Map<Tab, Component> tabContent = Map.of(
inventoryTab, inventoryContent,
movementTab, movementContent,
journalTab, journalContent
);
tabs.addSelectedChangeListener(event -> {
for (Component content : tabContent.values()) {
content.setVisible(false);
}
tabContent.get(event.getSelectedTab()).setVisible(true);
});
add(new H3("Lager"), buildTopBar(), buildSummary(), tabs, inventoryContent, movementContent, journalContent);
expand(inventoryContent, movementContent, journalContent);
loadBootstrap();
}
private void configureSelectors() {
rootStockField.setItemLabelGenerator(RootOption::label);
rootStockField.addValueChangeListener(event -> {
if (event.getValue() != null && !event.isFromClient()) {
return;
}
if (event.getValue() != null) {
loadRootWorkspace();
} else {
clearWorkspace();
}
});
inventorySearchField.setClearButtonVisible(true);
journalSearchField.setClearButtonVisible(true);
}
private void configureGrids() {
stockGrid.addColumn(StockRow::indentedName).setHeader("Lagerort").setFlexGrow(1);
stockGrid.addColumn(StockRow::capacityLabel).setHeader("Kapazitaet").setAutoWidth(true);
stockGrid.addColumn(row -> row.writeAllowed() ? "Ja" : "Nein").setHeader("Buchbar").setAutoWidth(true);
stockGrid.addColumn(StockRow::addressLabel).setHeader("Adresse").setFlexGrow(1);
stockGrid.setWidth("420px");
stockGrid.setHeightFull();
stockGrid.asSingleSelect().addValueChangeListener(event -> {
selectedStock = event.getValue();
selectedInventory = null;
selectedSerial = null;
updateSummary();
refreshMovementStockDefaults();
loadInventory();
loadJournal();
});
inventoryGrid.addColumn(InventoryRow::articleCode).setHeader("Artikel").setAutoWidth(true).setFrozen(true);
inventoryGrid.addColumn(InventoryRow::name).setHeader("Bezeichnung").setFlexGrow(1);
inventoryGrid.addColumn(InventoryRow::match).setHeader("Zusatz").setFlexGrow(1);
inventoryGrid.addColumn(InventoryRow::itemQuantity).setHeader("Menge").setAutoWidth(true);
inventoryGrid.addColumn(InventoryRow::areaQuantity).setHeader("Plaetze").setAutoWidth(true);
inventoryGrid.addColumn(InventoryRow::stockCount).setHeader("Lagerorte").setAutoWidth(true);
inventoryGrid.addColumn(row -> row.serialTracked() ? "Ja" : "Nein").setHeader("Seriennr.").setAutoWidth(true);
inventoryGrid.setSizeFull();
inventoryGrid.asSingleSelect().addValueChangeListener(event -> {
selectedInventory = event.getValue();
if (selectedInventory != null) {
selectMovementArticle(selectedInventory.articleId());
movementQuantityField.setValue(selectedInventory.serialTracked() ? 1 : movementQuantityField.getValue());
if (selectedStock != null && selectedStock.writeAllowed()) {
movementSourceField.setValue(findMovementSourceOption(selectedStock.id()));
}
}
loadSerials();
});
serialGrid.addColumn(SerialRow::stockName).setHeader("Lagerort").setFlexGrow(1);
serialGrid.addColumn(SerialRow::serialNo).setHeader("Seriennummer").setAutoWidth(true);
serialGrid.addColumn(SerialRow::dataPreview).setHeader("Daten").setFlexGrow(1);
serialGrid.setWidth("420px");
serialGrid.setHeightFull();
serialGrid.asSingleSelect().addValueChangeListener(event -> {
selectedSerial = event.getValue();
if (selectedSerial != null) {
movementSerialField.setValue(selectedSerial.serialNo());
movementQuantityField.setValue(1);
movementSourceField.setValue(findMovementSourceOption(selectedSerial.stockId()));
}
});
journalGrid.addColumn(JournalRow::bookingTime).setHeader("Zeit").setAutoWidth(true).setFrozen(true);
journalGrid.addColumn(JournalRow::sourceStock).setHeader("Quelle").setFlexGrow(1);
journalGrid.addColumn(JournalRow::targetStock).setHeader("Ziel").setFlexGrow(1);
journalGrid.addColumn(JournalRow::articleCode).setHeader("Artikel").setAutoWidth(true);
journalGrid.addColumn(JournalRow::articleName).setHeader("Bezeichnung").setFlexGrow(1);
journalGrid.addColumn(JournalRow::itemQuantity).setHeader("Menge").setAutoWidth(true);
journalGrid.addColumn(JournalRow::serialNo).setHeader("Seriennummer").setAutoWidth(true);
journalGrid.addColumn(JournalRow::tan).setHeader("TAN").setAutoWidth(true);
journalGrid.addColumn(JournalRow::remark).setHeader("Bemerkung").setFlexGrow(1);
journalGrid.addColumn(row -> row.jobId() != null && row.jobId() > 0 ? row.jobId() : null).setHeader("Auftrag").setAutoWidth(true);
journalGrid.setSizeFull();
}
private void configureMovementForm() {
movementArticleField.setItemLabelGenerator(ArticleOption::label);
movementSourceField.setItemLabelGenerator(MovementStockOption::label);
movementTargetField.setItemLabelGenerator(MovementStockOption::label);
movementQuantityField.setMin(1);
movementQuantityField.setValue(1);
movementArticleField.addValueChangeListener(event -> updateMovementSerialState());
moveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
moveButton.addClickListener(event -> moveArticle());
}
private void configureJournalFilters() {
journalFromField.setValue(LocalDate.now().minusDays(7));
journalToField.setValue(LocalDate.now());
journalSourceField.setItemLabelGenerator(MovementStockOption::label);
journalTargetField.setItemLabelGenerator(MovementStockOption::label);
}
private HorizontalLayout buildTopBar() {
Button reloadButton = new Button("Neu laden", event -> loadRootWorkspace());
reloadButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
HorizontalLayout bar = new HorizontalLayout(rootStockField, reloadButton);
bar.setAlignItems(Alignment.BASELINE);
return bar;
}
private HorizontalLayout buildSummary() {
HorizontalLayout layout = new HorizontalLayout(
detailLine("Lagerort", selectedStockText),
detailLine("Kapazitaet", selectedCapacityText),
detailLine("Zugriff", accessText)
);
layout.setWidthFull();
layout.setAlignItems(Alignment.BASELINE);
layout.getStyle().set("flex-wrap", "wrap");
return layout;
}
private VerticalLayout buildInventoryContent() {
Button loadInventoryButton = new Button("Bestand laden", event -> loadInventory());
HorizontalLayout filterBar = new HorizontalLayout(includeSubstocksField, inventorySearchField, loadInventoryButton);
filterBar.setAlignItems(Alignment.BASELINE);
VerticalLayout stockPanel = new VerticalLayout(new H4("Lagerbaum"), stockGrid);
stockPanel.setPadding(false);
stockPanel.setSpacing(true);
stockPanel.setWidth("420px");
stockPanel.setHeightFull();
stockPanel.expand(stockGrid);
VerticalLayout inventoryPanel = new VerticalLayout(new H4("Bestand"), filterBar, inventoryGrid);
inventoryPanel.setPadding(false);
inventoryPanel.setSpacing(true);
inventoryPanel.setSizeFull();
inventoryPanel.expand(inventoryGrid);
VerticalLayout serialPanel = new VerticalLayout(new H4("Seriennummern"), serialGrid);
serialPanel.setPadding(false);
serialPanel.setSpacing(true);
serialPanel.setWidth("420px");
serialPanel.setHeightFull();
serialPanel.expand(serialGrid);
HorizontalLayout workspace = new HorizontalLayout(stockPanel, inventoryPanel, serialPanel);
workspace.setSizeFull();
workspace.expand(inventoryPanel);
VerticalLayout content = new VerticalLayout(workspace);
content.setPadding(false);
content.setSpacing(false);
content.setSizeFull();
content.expand(workspace);
return content;
}
private VerticalLayout buildMovementContent() {
FormLayout form = new FormLayout();
form.setResponsiveSteps(
new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("720px", 2)
);
form.add(movementArticleField, movementQuantityField, movementSourceField, movementTargetField,
movementSerialField, movementTanField, movementJobField, movementRemarkField);
Paragraph hint = new Paragraph("Quelle `Eingangslieferung` bucht Ware ins Lager. Ziel `Ausgangslager` bucht Ware aus dem Lagerbestand aus.");
hint.getStyle().set("margin", "0");
VerticalLayout content = new VerticalLayout(new H4("Artikelbewegung"), hint, form, moveButton);
content.setPadding(false);
content.setSpacing(true);
content.setWidthFull();
return content;
}
private VerticalLayout buildJournalContent() {
Button loadJournalButton = new Button("Journal laden", event -> loadJournal());
HorizontalLayout filters = new HorizontalLayout(journalFromField, journalToField, journalSourceField, journalTargetField,
journalSearchField, loadJournalButton);
filters.setAlignItems(Alignment.BASELINE);
filters.getStyle().set("flex-wrap", "wrap");
VerticalLayout content = new VerticalLayout(new H4("Lagerjournal"), filters, journalGrid);
content.setPadding(false);
content.setSpacing(true);
content.setSizeFull();
content.expand(journalGrid);
return content;
}
private HorizontalLayout detailLine(String label, Span value) {
Span title = new Span(label + ":");
title.getStyle().set("font-weight", "600");
return new HorizontalLayout(title, value);
}
private void loadBootstrap() {
try {
Map<String, Object> data = restClient.getMap("/warehouse/bootstrap");
rootOptions.clear();
canMove = bool(data.get("canMove"));
for (Object row : asList(data.get("roots"))) {
if (row instanceof Map<?, ?> map) {
Long id = toLong(map.get("id"));
if (id != null) {
rootOptions.add(new RootOption(id, str(map.get("name"))));
}
}
}
rootStockField.setItems(rootOptions);
moveButton.setEnabled(canMove);
Long defaultRootStockId = toLong(data.get("defaultRootStockId"));
RootOption defaultRoot = rootOptions.stream()
.filter(option -> option.id().equals(defaultRootStockId))
.findFirst()
.orElse(rootOptions.isEmpty() ? null : rootOptions.get(0));
if (defaultRoot != null) {
rootStockField.setValue(defaultRoot);
loadRootWorkspace();
} else {
clearWorkspace();
}
} catch (Exception e) {
showError("Lagermodul konnte nicht geladen werden: " + e.getMessage());
}
}
private void loadRootWorkspace() {
RootOption root = rootStockField.getValue();
if (root == null) {
clearWorkspace();
return;
}
try {
loadStocks(root.id());
loadArticleOptions(root.id());
refreshStockSelectors();
selectInitialStock(root.id());
loadInventory();
loadJournal();
} catch (Exception e) {
showError("Lagerdaten konnten nicht geladen werden: " + e.getMessage());
}
}
private void loadStocks(Long rootStockId) {
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/warehouse/stocks?rootStockId=" + rootStockId,
new ParameterizedTypeReference<>() {}
);
stockRows.clear();
for (Map<String, Object> row : data) {
stockRows.add(new StockRow(
toLong(row.get("id")),
toLong(row.get("parentId")),
toInt(row.get("level")),
str(row.get("name")),
bool(row.get("planning")),
toInt(row.get("maxQuantity")),
toInt(row.get("usedCapacity")),
toInt(row.get("reservedCapacity")),
toInt(row.get("freeCapacity")),
str(row.get("barcode")),
str(row.get("addressLabel")),
bool(row.get("writeAllowed"))
));
}
stockGrid.setItems(stockRows);
}
private void loadArticleOptions(Long rootStockId) {
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/warehouse/articles?rootStockId=" + rootStockId,
new ParameterizedTypeReference<>() {}
);
articleOptions.clear();
for (Map<String, Object> row : data) {
Long articleId = toLong(row.get("articleId"));
if (articleId != null) {
articleOptions.add(new ArticleOption(
articleId,
str(row.get("articleCode")),
str(row.get("name")),
str(row.get("match")),
bool(row.get("serialTracked"))
));
}
}
movementArticleField.setItems(articleOptions);
}
private void refreshStockSelectors() {
List<MovementStockOption> writableStocks = stockRows.stream()
.filter(StockRow::writeAllowed)
.map(row -> new MovementStockOption(row.id(), row.indentedName(), true))
.toList();
List<MovementStockOption> movementSourceOptions = new ArrayList<>();
movementSourceOptions.add(new MovementStockOption(0L, "Eingangslieferung", canMove));
movementSourceOptions.addAll(writableStocks);
List<MovementStockOption> movementTargetOptions = new ArrayList<>(writableStocks);
movementTargetOptions.add(new MovementStockOption(0L, "Ausgangslager", canMove));
movementSourceField.setItems(movementSourceOptions);
movementTargetField.setItems(movementTargetOptions);
journalSourceField.setItems(stockRows.stream()
.map(row -> new MovementStockOption(row.id(), row.indentedName(), row.writeAllowed()))
.toList());
journalTargetField.setItems(stockRows.stream()
.map(row -> new MovementStockOption(row.id(), row.indentedName(), row.writeAllowed()))
.toList());
}
private void selectInitialStock(Long rootStockId) {
StockRow preferred = selectedStock != null
? stockRows.stream().filter(row -> row.id().equals(selectedStock.id())).findFirst().orElse(null)
: null;
if (preferred == null) {
preferred = stockRows.stream().filter(row -> row.id().equals(rootStockId)).findFirst().orElse(null);
}
if (preferred == null && !stockRows.isEmpty()) {
preferred = stockRows.get(0);
}
selectedStock = preferred;
if (preferred != null) {
stockGrid.select(preferred);
} else {
stockGrid.deselectAll();
updateSummary();
}
}
private void loadInventory() {
if (selectedStock == null) {
inventoryGrid.setItems(List.of());
serialGrid.setItems(List.of());
return;
}
try {
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/warehouse/inventory?stockId=" + selectedStock.id()
+ "&includeSubstocks=" + includeSubstocksField.getValue()
+ "&search=" + encode(inventorySearchField.getValue()),
new ParameterizedTypeReference<>() {}
);
List<InventoryRow> rows = new ArrayList<>();
for (Map<String, Object> row : data) {
rows.add(new InventoryRow(
toLong(row.get("articleId")),
str(row.get("articleCode")),
str(row.get("name")),
str(row.get("match")),
str(row.get("barcode")),
bool(row.get("serialTracked")),
toInt(row.get("itemQuantity")),
toInt(row.get("areaQuantity")),
toInt(row.get("stockCount"))
));
}
inventoryGrid.setItems(rows);
InventoryRow updatedSelection = selectedInventory != null
? rows.stream().filter(row -> row.articleId().equals(selectedInventory.articleId())).findFirst().orElse(null)
: null;
selectedInventory = updatedSelection;
if (updatedSelection != null) {
inventoryGrid.select(updatedSelection);
} else {
inventoryGrid.deselectAll();
loadSerials();
}
} catch (Exception e) {
showError("Bestand konnte nicht geladen werden: " + e.getMessage());
}
}
private void loadSerials() {
if (selectedStock == null || selectedInventory == null || !selectedInventory.serialTracked()) {
serialGrid.setItems(List.of());
return;
}
try {
List<LinkedHashMap<String, Object>> data = restClient.getList(
"/warehouse/serials?stockId=" + selectedStock.id()
+ "&includeSubstocks=" + includeSubstocksField.getValue()
+ "&articleId=" + selectedInventory.articleId(),
new ParameterizedTypeReference<>() {}
);
List<SerialRow> rows = new ArrayList<>();
for (Map<String, Object> row : data) {
rows.add(new SerialRow(
toLong(row.get("id")),
toLong(row.get("stockId")),
str(row.get("stockName")),
str(row.get("serialNo")),
extractStringList(row.get("dataFields"))
));
}
serialGrid.setItems(rows);
} catch (Exception e) {
showError("Seriennummern konnten nicht geladen werden: " + e.getMessage());
}
}
private void loadJournal() {
RootOption root = rootStockField.getValue();
if (root == null) {
journalGrid.setItems(List.of());
return;
}
try {
String path = "/warehouse/journal?rootStockId=" + root.id()
+ optionalLongParam("stockFromId", journalSourceField.getValue() != null ? journalSourceField.getValue().stockId() : null)
+ optionalLongParam("stockToId", journalTargetField.getValue() != null ? journalTargetField.getValue().stockId() : null)
+ "&from=" + journalFromField.getValue()
+ "&to=" + journalToField.getValue()
+ "&search=" + encode(journalSearchField.getValue());
List<LinkedHashMap<String, Object>> data = restClient.getList(path, new ParameterizedTypeReference<>() {});
List<JournalRow> rows = new ArrayList<>();
for (Map<String, Object> row : data) {
rows.add(new JournalRow(
toLong(row.get("id")),
str(row.get("bookingTime")),
str(row.get("sourceStock")),
str(row.get("targetStock")),
str(row.get("articleCode")),
str(row.get("articleName")),
toInt(row.get("itemQuantity")),
str(row.get("serialNo")),
str(row.get("tan")),
str(row.get("remark")),
toLong(row.get("jobId"))
));
}
journalGrid.setItems(rows);
} catch (Exception e) {
showError("Journal konnte nicht geladen werden: " + e.getMessage());
}
}
private void moveArticle() {
if (!canMove) {
showError("Lagerbuchungen sind fuer den angemeldeten Benutzer gesperrt.");
return;
}
if (movementArticleField.getValue() == null || movementSourceField.getValue() == null || movementTargetField.getValue() == null) {
showError("Bitte Artikel, Quelle und Ziel waehlen.");
return;
}
if (movementArticleField.getValue().serialTracked() && movementSerialField.getValue().isBlank()) {
showError("Fuer diesen Artikel ist eine Seriennummer erforderlich.");
return;
}
try {
Map<String, Object> request = new LinkedHashMap<>();
request.put("articleId", movementArticleField.getValue().articleId());
request.put("sourceStockId", movementSourceField.getValue().stockId());
request.put("targetStockId", movementTargetField.getValue().stockId());
request.put("itemQuantity", movementQuantityField.getValue());
request.put("serialNo", movementSerialField.getValue());
request.put("tan", movementTanField.getValue());
request.put("remark", movementRemarkField.getValue());
request.put("jobId", parseLongOrNull(movementJobField.getValue()));
restClient.post("/warehouse/moves", request, LinkedHashMap.class);
Notification.show("Lagerbewegung gespeichert.", 3000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
movementRemarkField.clear();
movementTanField.clear();
movementSerialField.clear();
movementJobField.clear();
movementQuantityField.setValue(1);
loadRootWorkspace();
} catch (Exception e) {
showError("Lagerbewegung konnte nicht gespeichert werden: " + e.getMessage());
}
}
private void updateSummary() {
if (selectedStock == null) {
selectedStockText.setText("-");
selectedCapacityText.setText("-");
accessText.setText(canMove ? "Buchbar" : "Nur Lesen");
return;
}
selectedStockText.setText(selectedStock.name());
if (selectedStock.planning()) {
selectedCapacityText.setText(selectedStock.usedCapacity() + " belegt / "
+ selectedStock.reservedCapacity() + " reserviert / "
+ selectedStock.freeCapacity() + " frei");
} else {
selectedCapacityText.setText("Kapazitaetsplanung deaktiviert");
}
accessText.setText(selectedStock.writeAllowed() && canMove ? "Buchbar" : "Nur Lesen");
}
private void updateMovementSerialState() {
ArticleOption article = movementArticleField.getValue();
boolean serialTracked = article != null && article.serialTracked();
movementSerialField.setRequired(serialTracked);
if (serialTracked && (movementQuantityField.getValue() == null || movementQuantityField.getValue() != 1)) {
movementQuantityField.setValue(1);
}
}
private void refreshMovementStockDefaults() {
updateSummary();
if (selectedStock != null && selectedStock.writeAllowed()) {
movementSourceField.setValue(findMovementSourceOption(selectedStock.id()));
} else {
movementSourceField.clear();
}
movementTargetField.clear();
}
private void selectMovementArticle(Long articleId) {
if (articleId == null) {
return;
}
articleOptions.stream()
.filter(option -> option.articleId().equals(articleId))
.findFirst()
.ifPresent(movementArticleField::setValue);
}
private MovementStockOption findMovementSourceOption(Long stockId) {
return movementSourceField.getListDataView().getItems()
.filter(option -> option.stockId().equals(stockId))
.findFirst()
.orElse(null);
}
private void clearWorkspace() {
stockRows.clear();
articleOptions.clear();
selectedStock = null;
selectedInventory = null;
selectedSerial = null;
stockGrid.setItems(List.of());
inventoryGrid.setItems(List.of());
serialGrid.setItems(List.of());
journalGrid.setItems(List.of());
updateSummary();
}
private void showError(String message) {
Notification.show(message, 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
private String optionalLongParam(String key, Long value) {
return value != null ? "&" + key + "=" + value : "";
}
private String encode(String value) {
return value != null ? URLEncoder.encode(value, StandardCharsets.UTF_8) : "";
}
private List<?> asList(Object value) {
if (value instanceof List<?> list) {
return list;
}
return List.of();
}
private List<String> extractStringList(Object value) {
List<String> result = new ArrayList<>();
if (value instanceof Iterable<?> iterable) {
for (Object item : iterable) {
result.add(str(item));
}
}
return result;
}
private Long parseLongOrNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException ignored) {
return null;
}
}
private Long toLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value == null || value.toString().isBlank()) {
return null;
}
try {
return Long.parseLong(value.toString());
} catch (NumberFormatException ignored) {
return null;
}
}
private int toInt(Object value) {
if (value instanceof Number number) {
return number.intValue();
}
if (value == null || value.toString().isBlank()) {
return 0;
}
try {
return Integer.parseInt(value.toString());
} catch (NumberFormatException ignored) {
return 0;
}
}
private boolean bool(Object value) {
if (value instanceof Boolean bool) {
return bool;
}
return "true".equalsIgnoreCase(str(value)) || "1".equals(str(value));
}
private String str(Object value) {
return value != null ? value.toString() : "";
}
private record RootOption(Long id, String name) {
private String label() {
return name + " (" + id + ")";
}
}
private record StockRow(
Long id,
Long parentId,
int level,
String name,
boolean planning,
int maxQuantity,
int usedCapacity,
int reservedCapacity,
int freeCapacity,
String barcode,
String addressLabel,
boolean writeAllowed
) {
private String indentedName() {
return " ".repeat(Math.max(0, level)) + name;
}
private String capacityLabel() {
if (!planning) {
return "-";
}
return maxQuantity + " / " + freeCapacity + " frei";
}
}
private record InventoryRow(
Long articleId,
String articleCode,
String name,
String match,
String barcode,
boolean serialTracked,
int itemQuantity,
int areaQuantity,
int stockCount
) {
}
private record SerialRow(Long id, Long stockId, String stockName, String serialNo, List<String> dataFields) {
private String dataPreview() {
return dataFields.stream()
.filter(value -> value != null && !value.isBlank())
.limit(3)
.collect(Collectors.joining(" | "));
}
}
private record JournalRow(
Long id,
String bookingTime,
String sourceStock,
String targetStock,
String articleCode,
String articleName,
int itemQuantity,
String serialNo,
String tan,
String remark,
Long jobId
) {
}
private record ArticleOption(Long articleId, String articleCode, String name, String match, boolean serialTracked) {
private String label() {
StringBuilder builder = new StringBuilder();
if (articleCode != null && !articleCode.isBlank()) {
builder.append(articleCode).append(" - ");
}
builder.append(name);
if (match != null && !match.isBlank()) {
builder.append(" (").append(match).append(")");
}
if (serialTracked) {
builder.append(" [SN]");
}
return builder.toString();
}
}
private record MovementStockOption(Long stockId, String label, boolean writeAllowed) {
}
}

View File

@@ -0,0 +1,9 @@
# Votian Vaadin Web Application
server.port=8080
# REST Service Backend URL
votian.services.url=http://localhost:8081/api
# Vaadin
vaadin.launch-browser=true
spring.mustache.check-template-location=false