first commit

This commit is contained in:
2026-06-08 16:06:43 +02:00
commit b07b09c28e
38 changed files with 18827 additions and 0 deletions

20
.dockerignore Normal file
View File

@@ -0,0 +1,20 @@
target/*
!target/*.jar
target/*.original
node_modules/
.vite/
src/main/frontend/generated/
src/main/bundles/
.git/
.gitignore
.idea/
.vscode/
*.iml
.DS_Store
Thumbs.db
README.md
*.md

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Vorlage für die MongoDB-Zugangsdaten.
# Kopieren nach .env und Werte anpassen: cp .env.example .env
# Verbindungs-URI (ohne Auth für lokale Entwicklung):
MONGODB_URI=mongodb://localhost:27017
# Mit Authentifizierung:
# MONGODB_URI=mongodb://benutzer:passwort@host:27017/?authSource=admin
# Zieldatenbank:
MONGODB_DATABASE=tasklist_editor
# Collection, in der das Task-JSON gespeichert wird:
MONGODB_COLLECTION=tasks_json
# Collection, in der die Canvas-Stände (Layout + Werte) gespeichert werden:
MONGODB_CANVAS_COLLECTION=canvas_layouts

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Umgebungs-/Zugangsdaten niemals einchecken
.env
# Build-Artefakte
target/
node_modules/
# Vaadin generierte Dateien
src/main/frontend/generated/
src/main/bundles/
vite.generated.ts
# IDE
.idea/
*.iml
.vscode/
# OS
.DS_Store

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM eclipse-temurin:21-jre
ARG JAR_FILE=target/*.jar
# Zeitzone auf Berlin setzen und 24h-Format konfigurieren
ENV TZ=Europe/Berlin
ENV LC_TIME=de_DE.UTF-8
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
COPY ${JAR_FILE} app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=production"]

39
README.md Normal file
View File

@@ -0,0 +1,39 @@
# Workflow Editor (TaskList Editor)
n8n-inspirierter Editor zum Erstellen von Aufgabenlisten (Tasks) für die
stadtbote-App. Funktionsblöcke werden per Drag & Drop auf einem Canvas
angeordnet und verbunden; daraus wird das app-konforme `tasks`-JSON erzeugt.
## Funktionen
- Funktionsblöcke für die stadtbote-Task-Typen (Ankunft, Unterschrift, Fotos,
Quittungsgeber, Stationshinweis, Kommissionsnummer, Abhol-/Lieferstatus)
- Erzeugen des app-konformen Task-JSON und Speichern in MongoDB
- Speichern und Wiederherstellen des Canvas-Standes in MongoDB
- Aufruf per Deep-Link mit Übergabe von Task-Daten als JSON-Body
## Technik
- Java 21, Spring Boot 3, Vaadin 24
- MongoDB (Spring Data) zur Persistenz
- Drawflow als Canvas-/Node-Editor
## Konfiguration
Zugangsdaten und Collections werden über eine `.env`-Datei gesetzt
(Vorlage: `.env.example`):
```
MONGODB_URI=mongodb://localhost:27017
MONGODB_DATABASE=tasklist_editor
MONGODB_COLLECTION=tasks_json
MONGODB_CANVAS_COLLECTION=canvas_layouts
```
## Start
```bash
mvn spring-boot:run
```
Die Anwendung läuft anschließend unter http://localhost:8080/.

15718
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

259
package.json Normal file
View File

@@ -0,0 +1,259 @@
{
"name": "no-name",
"license": "UNLICENSED",
"type": "module",
"dependencies": {
"@polymer/polymer": "3.5.2",
"@vaadin/a11y-base": "24.6.4",
"@vaadin/accordion": "24.6.4",
"@vaadin/app-layout": "24.6.4",
"@vaadin/avatar": "24.6.4",
"@vaadin/avatar-group": "24.6.4",
"@vaadin/bundles": "24.6.4",
"@vaadin/button": "24.6.4",
"@vaadin/checkbox": "24.6.4",
"@vaadin/checkbox-group": "24.6.4",
"@vaadin/combo-box": "24.6.4",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/component-base": "24.6.4",
"@vaadin/confirm-dialog": "24.6.4",
"@vaadin/context-menu": "24.6.4",
"@vaadin/custom-field": "24.6.4",
"@vaadin/date-picker": "24.6.4",
"@vaadin/date-time-picker": "24.6.4",
"@vaadin/details": "24.6.4",
"@vaadin/dialog": "24.6.4",
"@vaadin/email-field": "24.6.4",
"@vaadin/field-base": "24.6.4",
"@vaadin/field-highlighter": "24.6.4",
"@vaadin/form-layout": "24.6.4",
"@vaadin/grid": "24.6.4",
"@vaadin/horizontal-layout": "24.6.4",
"@vaadin/icon": "24.6.4",
"@vaadin/icons": "24.6.4",
"@vaadin/input-container": "24.6.4",
"@vaadin/integer-field": "24.6.4",
"@vaadin/item": "24.6.4",
"@vaadin/list-box": "24.6.4",
"@vaadin/lit-renderer": "24.6.4",
"@vaadin/login": "24.6.4",
"@vaadin/menu-bar": "24.6.4",
"@vaadin/message-input": "24.6.4",
"@vaadin/message-list": "24.6.4",
"@vaadin/multi-select-combo-box": "24.6.4",
"@vaadin/notification": "24.6.4",
"@vaadin/number-field": "24.6.4",
"@vaadin/overlay": "24.6.4",
"@vaadin/password-field": "24.6.4",
"@vaadin/polymer-legacy-adapter": "24.6.4",
"@vaadin/popover": "24.6.4",
"@vaadin/progress-bar": "24.6.4",
"@vaadin/radio-group": "24.6.4",
"@vaadin/router": "2.0.0",
"@vaadin/scroller": "24.6.4",
"@vaadin/select": "24.6.4",
"@vaadin/side-nav": "24.6.4",
"@vaadin/split-layout": "24.6.4",
"@vaadin/tabs": "24.6.4",
"@vaadin/tabsheet": "24.6.4",
"@vaadin/text-area": "24.6.4",
"@vaadin/text-field": "24.6.4",
"@vaadin/time-picker": "24.6.4",
"@vaadin/tooltip": "24.6.4",
"@vaadin/upload": "24.6.4",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.6.4",
"@vaadin/vaadin-material-styles": "24.6.4",
"@vaadin/vaadin-themable-mixin": "24.6.4",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"@vaadin/vertical-layout": "24.6.4",
"@vaadin/virtual-list": "24.6.4",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"drawflow": "0.0.59",
"lit": "3.2.1"
},
"devDependencies": {
"@babel/preset-react": "7.26.3",
"@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@vitejs/plugin-react": "4.3.4",
"async": "3.2.6",
"glob": "10.4.5",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.7.3",
"vite": "6.0.11",
"vite-plugin-checker": "0.8.0",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
},
"vaadin": {
"dependencies": {
"@polymer/polymer": "3.5.2",
"@vaadin/a11y-base": "24.6.4",
"@vaadin/accordion": "24.6.4",
"@vaadin/app-layout": "24.6.4",
"@vaadin/avatar": "24.6.4",
"@vaadin/avatar-group": "24.6.4",
"@vaadin/bundles": "24.6.4",
"@vaadin/button": "24.6.4",
"@vaadin/checkbox": "24.6.4",
"@vaadin/checkbox-group": "24.6.4",
"@vaadin/combo-box": "24.6.4",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/component-base": "24.6.4",
"@vaadin/confirm-dialog": "24.6.4",
"@vaadin/context-menu": "24.6.4",
"@vaadin/custom-field": "24.6.4",
"@vaadin/date-picker": "24.6.4",
"@vaadin/date-time-picker": "24.6.4",
"@vaadin/details": "24.6.4",
"@vaadin/dialog": "24.6.4",
"@vaadin/email-field": "24.6.4",
"@vaadin/field-base": "24.6.4",
"@vaadin/field-highlighter": "24.6.4",
"@vaadin/form-layout": "24.6.4",
"@vaadin/grid": "24.6.4",
"@vaadin/horizontal-layout": "24.6.4",
"@vaadin/icon": "24.6.4",
"@vaadin/icons": "24.6.4",
"@vaadin/input-container": "24.6.4",
"@vaadin/integer-field": "24.6.4",
"@vaadin/item": "24.6.4",
"@vaadin/list-box": "24.6.4",
"@vaadin/lit-renderer": "24.6.4",
"@vaadin/login": "24.6.4",
"@vaadin/menu-bar": "24.6.4",
"@vaadin/message-input": "24.6.4",
"@vaadin/message-list": "24.6.4",
"@vaadin/multi-select-combo-box": "24.6.4",
"@vaadin/notification": "24.6.4",
"@vaadin/number-field": "24.6.4",
"@vaadin/overlay": "24.6.4",
"@vaadin/password-field": "24.6.4",
"@vaadin/polymer-legacy-adapter": "24.6.4",
"@vaadin/popover": "24.6.4",
"@vaadin/progress-bar": "24.6.4",
"@vaadin/radio-group": "24.6.4",
"@vaadin/router": "2.0.0",
"@vaadin/scroller": "24.6.4",
"@vaadin/select": "24.6.4",
"@vaadin/side-nav": "24.6.4",
"@vaadin/split-layout": "24.6.4",
"@vaadin/tabs": "24.6.4",
"@vaadin/tabsheet": "24.6.4",
"@vaadin/text-area": "24.6.4",
"@vaadin/text-field": "24.6.4",
"@vaadin/time-picker": "24.6.4",
"@vaadin/tooltip": "24.6.4",
"@vaadin/upload": "24.6.4",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.6.4",
"@vaadin/vaadin-material-styles": "24.6.4",
"@vaadin/vaadin-themable-mixin": "24.6.4",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"@vaadin/vertical-layout": "24.6.4",
"@vaadin/virtual-list": "24.6.4",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"drawflow": "0.0.59",
"lit": "3.2.1"
},
"devDependencies": {
"@babel/preset-react": "7.26.3",
"@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@vitejs/plugin-react": "4.3.4",
"async": "3.2.6",
"glob": "10.4.5",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.7.3",
"vite": "6.0.11",
"vite-plugin-checker": "0.8.0",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
},
"hash": "afafb3c48ddfed6e38c5ce6dc755c8d4b156cbae485ff1645d0d524c5a5c1de0"
},
"overrides": {
"@vaadin/bundles": "$@vaadin/bundles",
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
"@vaadin/common-frontend": "$@vaadin/common-frontend",
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
"lit": "$lit",
"@polymer/polymer": "$@polymer/polymer",
"date-fns": "$date-fns",
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
"drawflow": "$drawflow",
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles",
"@vaadin/a11y-base": "$@vaadin/a11y-base",
"@vaadin/accordion": "$@vaadin/accordion",
"@vaadin/app-layout": "$@vaadin/app-layout",
"@vaadin/avatar": "$@vaadin/avatar",
"@vaadin/avatar-group": "$@vaadin/avatar-group",
"@vaadin/button": "$@vaadin/button",
"@vaadin/checkbox": "$@vaadin/checkbox",
"@vaadin/checkbox-group": "$@vaadin/checkbox-group",
"@vaadin/combo-box": "$@vaadin/combo-box",
"@vaadin/component-base": "$@vaadin/component-base",
"@vaadin/confirm-dialog": "$@vaadin/confirm-dialog",
"@vaadin/context-menu": "$@vaadin/context-menu",
"@vaadin/custom-field": "$@vaadin/custom-field",
"@vaadin/date-picker": "$@vaadin/date-picker",
"@vaadin/date-time-picker": "$@vaadin/date-time-picker",
"@vaadin/details": "$@vaadin/details",
"@vaadin/dialog": "$@vaadin/dialog",
"@vaadin/email-field": "$@vaadin/email-field",
"@vaadin/field-base": "$@vaadin/field-base",
"@vaadin/field-highlighter": "$@vaadin/field-highlighter",
"@vaadin/form-layout": "$@vaadin/form-layout",
"@vaadin/grid": "$@vaadin/grid",
"@vaadin/horizontal-layout": "$@vaadin/horizontal-layout",
"@vaadin/icon": "$@vaadin/icon",
"@vaadin/icons": "$@vaadin/icons",
"@vaadin/input-container": "$@vaadin/input-container",
"@vaadin/integer-field": "$@vaadin/integer-field",
"@vaadin/item": "$@vaadin/item",
"@vaadin/list-box": "$@vaadin/list-box",
"@vaadin/lit-renderer": "$@vaadin/lit-renderer",
"@vaadin/login": "$@vaadin/login",
"@vaadin/menu-bar": "$@vaadin/menu-bar",
"@vaadin/message-input": "$@vaadin/message-input",
"@vaadin/message-list": "$@vaadin/message-list",
"@vaadin/multi-select-combo-box": "$@vaadin/multi-select-combo-box",
"@vaadin/notification": "$@vaadin/notification",
"@vaadin/number-field": "$@vaadin/number-field",
"@vaadin/overlay": "$@vaadin/overlay",
"@vaadin/password-field": "$@vaadin/password-field",
"@vaadin/popover": "$@vaadin/popover",
"@vaadin/progress-bar": "$@vaadin/progress-bar",
"@vaadin/radio-group": "$@vaadin/radio-group",
"@vaadin/scroller": "$@vaadin/scroller",
"@vaadin/select": "$@vaadin/select",
"@vaadin/side-nav": "$@vaadin/side-nav",
"@vaadin/split-layout": "$@vaadin/split-layout",
"@vaadin/tabs": "$@vaadin/tabs",
"@vaadin/tabsheet": "$@vaadin/tabsheet",
"@vaadin/text-area": "$@vaadin/text-area",
"@vaadin/text-field": "$@vaadin/text-field",
"@vaadin/time-picker": "$@vaadin/time-picker",
"@vaadin/tooltip": "$@vaadin/tooltip",
"@vaadin/upload": "$@vaadin/upload",
"@vaadin/router": "$@vaadin/router",
"@vaadin/vertical-layout": "$@vaadin/vertical-layout",
"@vaadin/virtual-list": "$@vaadin/virtual-list"
}
}

124
pom.xml Normal file
View File

@@ -0,0 +1,124 @@
<?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.4.3</version>
<relativePath/>
</parent>
<groupId>de.assecutor</groupId>
<artifactId>tasklist-editor</artifactId>
<version>0.2.0</version>
<name>TaskList Editor</name>
<description>n8n-inspired editor for task list workflows</description>
<properties>
<java.version>21</java.version>
<vaadin.version>24.6.5</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-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<!-- Lädt Variablen aus einer .env-Datei in die Spring-Umgebung. -->
<dependency>
<groupId>me.paulschwarz</groupId>
<artifactId>spring-dotenv</artifactId>
<version>4.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</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>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.version}</version>
<configuration>
<reactEnable>false</reactEnable>
</configuration>
<executions>
<execution>
<goals>
<goal>prepare-frontend</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>production</id>
<build>
<plugins>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.version}</version>
<executions>
<execution>
<goals>
<goal>build-frontend</goal>
</goals>
<phase>compile</phase>
</execution>
</executions>
<configuration>
<productionMode>true</productionMode>
<reactEnable>false</reactEnable>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

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, viewport-fit=cover" />
<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,216 @@
import Drawflow from 'drawflow';
import 'drawflow/dist/drawflow.min.css';
function customCurvature(sx, sy, ex, ey) {
const dx = ex - sx;
const dy = ey - sy;
const adyt = Math.abs(dy);
const minOffset = 40;
if (dy >= -8) {
const offset = Math.max(adyt * 0.5, minOffset);
const c1x = sx + dx * 0.5;
const c1y = sy + offset;
const c2x = ex;
const c2y = ey - offset;
return ` M ${sx} ${sy} C ${c1x} ${c1y} ${c2x} ${c2y} ${ex} ${ey}`;
}
const loopOffset = adyt * 0.5 + 80;
const sign = Math.sign(dx) || 1;
const c1x = sx + sign * 60;
const c1y = sy + loopOffset;
const c2x = ex - sign * 60;
const c2y = ey - loopOffset;
return ` M ${sx} ${sy} C ${c1x} ${c1y} ${c2x} ${c2y} ${ex} ${ey}`;
}
if (Drawflow && Drawflow.prototype) {
Drawflow.prototype.createCurvature = customCurvature;
}
const editors = new WeakMap();
if (!window.__taskListEditorDragInit) {
window.__taskListEditorDragInit = true;
document.addEventListener('dragstart', e => {
const target = e.target instanceof Element ? e.target.closest('[data-blocktype]') : null;
if (target && e.dataTransfer) {
e.dataTransfer.setData('application/x-blocktype', target.dataset.blocktype);
e.dataTransfer.effectAllowed = 'copy';
}
}, true);
}
function buildNodeHtml(label, color) {
const safe = String(label).replace(/[<>&"']/g, c => ({
'<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;'
}[c]));
return `
<div class="task-node" data-color="${color}">
<div class="task-node-header" style="background:${color}"></div>
<div class="task-node-body">
<div class="task-node-title">${safe}</div>
</div>
</div>
`;
}
function attach(host) {
if (editors.has(host)) {
return editors.get(host);
}
host.classList.add('task-list-editor');
host.innerHTML = '';
const container = document.createElement('div');
container.className = 'task-list-editor-container';
container.style.width = '100%';
container.style.height = '100%';
host.appendChild(container);
const editor = new Drawflow(container);
editor.reroute = true;
editor.reroute_fix_curvature = true;
editor.curvature = 0.5;
editor.createCurvature = customCurvature;
editor.start();
const state = {
editor,
container,
registry: new Map()
};
editors.set(host, state);
container.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
container.addEventListener('drop', e => {
e.preventDefault();
const typeId = e.dataTransfer.getData('application/x-blocktype');
if (!typeId) return;
const def = state.registry.get(typeId);
if (!def) return;
const rect = container.getBoundingClientRect();
const zoom = editor.zoom;
const px = (e.clientX - rect.left - editor.canvas_x) / zoom;
const py = (e.clientY - rect.top - editor.canvas_y) / zoom;
addNodeInternal(host, def, px, py);
});
editor.on('nodeSelected', id => {
host.$server && host.$server.onNodeSelected(parseInt(id, 10));
});
editor.on('nodeUnselected', () => {
host.$server && host.$server.onNodeUnselected();
});
editor.on('nodeRemoved', id => {
host.$server && host.$server.onNodeRemoved(parseInt(id, 10));
});
return state;
}
function addNodeInternal(host, def, x, y) {
const state = editors.get(host);
if (!state) return null;
const html = buildNodeHtml(def.label, def.color);
const id = state.editor.addNode(
def.id,
def.inputs,
def.outputs,
x,
y,
`task-node-wrapper type-${def.id}`,
{ typeId: def.id, label: def.label },
html
);
if (host.$server) {
host.$server.onNodeAdded(id, def.id);
}
return id;
}
window.taskListEditor = {
attach,
registerBlockTypes(host, types) {
const state = attach(host);
for (const t of types) {
state.registry.set(t.id, t);
}
},
addNode(host, typeId, x, y) {
const state = attach(host);
const def = state.registry.get(typeId);
if (!def) return;
addNodeInternal(host, def, x, y);
},
updateNodeLabel(host, nodeId, label) {
const state = editors.get(host);
if (!state) return;
const node = state.editor.getNodeFromId(nodeId);
if (!node) return;
const html = buildNodeHtml(label, state.registry.get(node.data.typeId)?.color || '#444');
state.editor.updateNodeDataFromId(nodeId, { ...node.data, label });
const el = state.container.querySelector(`#node-${nodeId} .drawflow_content_node`);
if (el) el.innerHTML = html;
},
clear(host) {
const state = editors.get(host);
if (state) state.editor.clear();
},
exportGraph(host) {
const state = editors.get(host);
if (!state) return '';
return JSON.stringify(state.editor.export());
},
importTasks(host, tasks) {
const state = attach(host);
state.editor.clear();
const ids = [];
const x = 160;
const step = 200;
let y = 60;
for (const t of tasks) {
const def = state.registry.get(t.typeId);
if (!def) continue;
const label = t.topic || def.label;
const html = buildNodeHtml(label, def.color);
const id = state.editor.addNode(
def.id,
def.inputs,
def.outputs,
x,
y,
`task-node-wrapper type-${def.id}`,
{ typeId: def.id, label },
html
);
ids.push(id);
y += step;
}
// Knoten in der übergebenen Reihenfolge verketten.
for (let i = 0; i < ids.length - 1; i++) {
state.editor.addConnection(ids[i], ids[i + 1], 'output_1', 'input_1');
}
return JSON.stringify(ids);
},
importGraph(host, graphJson) {
const state = attach(host);
if (!graphJson) return;
try {
state.editor.import(JSON.parse(graphJson));
} catch (e) {
console.error('importGraph failed', e);
}
}
};

View File

@@ -0,0 +1,318 @@
html {
--tle-bg: #f4f5f7;
--tle-panel: #ffffff;
--tle-border: #e3e5ea;
--tle-text: #111827;
--tle-muted: #6b7280;
--tle-canvas-bg: #f8fafc;
--tle-canvas-grid: #dfe3e8;
--tle-accent: #2563eb;
}
body, .tasklist-app {
background: var(--tle-bg);
color: var(--tle-text);
}
.tasklist-header {
background: var(--tle-panel);
border-bottom: 1px solid var(--tle-border);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
flex-shrink: 0;
}
.tasklist-title {
margin: 0;
font-size: var(--lumo-font-size-l);
color: var(--tle-text);
}
.tasklist-subtitle {
color: var(--tle-muted);
font-size: var(--lumo-font-size-s);
}
.tasklist-body {
display: flex;
flex: 1;
min-height: 0;
}
.palette-panel,
.properties-panel {
background: var(--tle-panel);
padding: var(--lumo-space-m);
overflow-y: auto;
border-right: 1px solid var(--tle-border);
box-sizing: border-box;
flex-shrink: 0;
height: 100%;
}
.palette-panel {
width: 280px;
}
.properties-panel {
width: 360px;
border-right: 0;
border-left: 1px solid var(--tle-border);
}
.panel-heading {
margin: 0 0 var(--lumo-space-xs);
font-size: var(--lumo-font-size-m);
color: var(--tle-text);
font-weight: 600;
}
.panel-hint {
margin: 0 0 var(--lumo-space-m);
color: var(--tle-muted);
font-size: var(--lumo-font-size-s);
}
.palette-item {
display: flex;
align-items: center;
gap: var(--lumo-space-s);
padding: var(--lumo-space-s) var(--lumo-space-m);
border: 1px solid var(--tle-border);
border-radius: 10px;
background: var(--tle-panel);
margin-bottom: var(--lumo-space-s);
cursor: grab;
user-select: none;
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
}
.palette-item:hover {
border-color: var(--block-color, var(--tle-accent));
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
transform: translateY(-1px);
}
.palette-item:active {
cursor: grabbing;
}
.palette-item-icon {
width: 36px;
height: 36px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
}
.palette-item-icon vaadin-icon {
width: 18px;
height: 18px;
}
.palette-item-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.palette-item-label {
font-weight: 600;
color: var(--tle-text);
font-size: var(--lumo-font-size-s);
}
.palette-item-desc {
color: var(--tle-muted);
font-size: var(--lumo-font-size-xs);
}
.node-editor-host {
background:
radial-gradient(circle, var(--tle-canvas-grid) 1px, transparent 1px) 0 0 / 24px 24px,
var(--tle-canvas-bg);
height: 100%;
overflow: hidden;
position: relative;
}
.task-list-editor,
.task-list-editor-container,
.task-list-editor .drawflow {
width: 100%;
height: 100%;
}
.task-list-editor .drawflow {
background: transparent;
}
.task-list-editor .drawflow .drawflow-node {
background: transparent;
border: none;
padding: 0;
box-shadow: none;
min-width: 250px;
z-index: 1;
}
.task-list-editor .drawflow .connection {
z-index: 5;
pointer-events: none;
overflow: visible;
}
.task-list-editor .drawflow .connection .main-path {
pointer-events: none;
}
.task-list-editor .drawflow .connection[class*="node_in_node_"] .main-path {
pointer-events: stroke;
}
.task-list-editor .drawflow .drawflow-node.selected {
background: transparent;
}
.task-list-editor .drawflow .drawflow-node.selected .task-node {
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.35);
}
.task-node {
background: var(--tle-panel);
border: 1px solid var(--tle-border);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08);
min-width: 250px;
min-height: 90px;
display: flex;
flex-direction: column;
}
.task-node-header {
height: 6px;
width: 100%;
flex-shrink: 0;
}
.task-node-body {
padding: var(--lumo-space-m) var(--lumo-space-l);
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.task-node-title {
font-weight: 600;
color: var(--tle-text);
font-size: var(--lumo-font-size-s);
text-align: center;
width: 100%;
}
.task-list-editor .drawflow .drawflow-node .inputs,
.task-list-editor .drawflow .drawflow-node .outputs {
position: absolute !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
height: 0 !important;
display: flex !important;
flex-direction: row !important;
justify-content: center !important;
align-items: center !important;
gap: 16px;
pointer-events: none;
z-index: 10;
}
.task-list-editor .drawflow .drawflow-node .inputs {
top: 0 !important;
bottom: auto !important;
}
.task-list-editor .drawflow .drawflow-node .outputs {
top: auto !important;
bottom: 0 !important;
}
.task-list-editor .drawflow .input,
.task-list-editor .drawflow .output {
position: relative !important;
top: auto !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
width: 14px !important;
height: 14px !important;
box-sizing: border-box !important;
background: var(--tle-panel);
border: 2px solid #94a3b8;
border-radius: 50%;
margin: 0 !important;
flex: 0 0 auto;
pointer-events: auto;
}
.task-list-editor .drawflow .input:hover,
.task-list-editor .drawflow .output:hover {
border-color: var(--tle-accent);
}
.task-list-editor .drawflow .connection .main-path {
stroke: #94a3b8;
stroke-width: 2.5px;
}
.task-list-editor .drawflow .connection .main-path:hover {
stroke: var(--tle-accent);
stroke-width: 3px;
}
.properties-content {
display: flex;
flex-direction: column;
gap: var(--lumo-space-s);
}
.properties-empty {
color: var(--tle-muted);
font-size: var(--lumo-font-size-s);
}
.properties-title {
margin-top: var(--lumo-space-xs);
margin-bottom: 0;
gap: var(--lumo-space-s);
}
.properties-badge {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
}
.properties-badge vaadin-icon {
width: 16px;
height: 16px;
}
.properties-name {
font-weight: 600;
font-size: var(--lumo-font-size-m);
}
.properties-desc {
color: var(--tle-muted);
font-size: var(--lumo-font-size-s);
margin: 0 0 var(--lumo-space-s);
}

View File

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

View File

@@ -0,0 +1,15 @@
package de.assecutor.tasklisteditor;
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("tasklist-editor")
public class Application implements AppShellConfigurator {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

View File

@@ -0,0 +1,170 @@
package de.assecutor.tasklisteditor.component;
import com.vaadin.flow.component.ClientCallable;
import com.vaadin.flow.component.ComponentEvent;
import com.vaadin.flow.component.ComponentEventListener;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.shared.Registration;
import de.assecutor.tasklisteditor.model.BlockType;
import elemental.json.Json;
import elemental.json.JsonArray;
import elemental.json.JsonObject;
import java.util.List;
@JsModule("./node-editor/node-editor.js")
@NpmPackage(value = "drawflow", version = "0.0.59")
public class NodeEditor extends Div {
public NodeEditor() {
addClassName("node-editor-host");
getElement().getStyle().set("flex", "1").set("minWidth", "0").set("minHeight", "0");
}
@Override
protected void onAttach(com.vaadin.flow.component.AttachEvent attachEvent) {
super.onAttach(attachEvent);
getElement().executeJs("window.taskListEditor.attach(this)");
}
public void registerBlockTypes(List<BlockType> types) {
JsonArray array = Json.createArray();
int i = 0;
for (BlockType type : types) {
JsonObject obj = Json.createObject();
obj.put("id", type.id());
obj.put("label", type.label());
obj.put("color", type.color());
obj.put("inputs", type.inputs());
obj.put("outputs", type.outputs());
array.set(i++, obj);
}
getElement().executeJs("window.taskListEditor.registerBlockTypes(this, $0)", array);
}
public void addNode(String typeId, double x, double y) {
getElement().executeJs("window.taskListEditor.addNode(this, $0, $1, $2)", typeId, x, y);
}
public void updateNodeLabel(int nodeId, String label) {
getElement().executeJs("window.taskListEditor.updateNodeLabel(this, $0, $1)", nodeId, label);
}
public void clear() {
getElement().executeJs("window.taskListEditor.clear(this)");
}
/**
* Liest den aktuellen Drawflow-Graphen (Knoten + Verbindungen) als
* JSON-String aus dem Client und übergibt ihn an den Callback.
*/
public void exportGraph(SerializableConsumer<String> callback) {
getElement().executeJs("return window.taskListEditor.exportGraph(this)")
.then(String.class, callback::accept);
}
/**
* Baut den Canvas aus der übergebenen Task-Beschreibung neu auf
* (Knoten in Reihenfolge + Verkettung). Der Callback erhält die im
* Client vergebenen Knoten-IDs als JSON-Array (in derselben Reihenfolge),
* damit serverseitig die passenden {@code BlockInstance}s angelegt werden
* können.
*
* @param tasks JSON-Array aus Objekten mit {@code typeId} und {@code topic}
* @param idsCallback erhält das JSON-Array der vergebenen Knoten-IDs
*/
public void importTasks(JsonArray tasks, SerializableConsumer<String> idsCallback) {
getElement().executeJs("return window.taskListEditor.importTasks(this, $0)", tasks)
.then(String.class, idsCallback::accept);
}
/**
* Stellt den Canvas vollständig aus einem zuvor exportierten
* Drawflow-Graphen wieder her (Knoten, Positionen, Verbindungen).
* Die Knoten-IDs bleiben dabei erhalten.
*/
public void importGraph(String graphJson) {
getElement().executeJs("window.taskListEditor.importGraph(this, $0)", graphJson);
}
public Registration addNodeAddedListener(ComponentEventListener<NodeAddedEvent> listener) {
return addListener(NodeAddedEvent.class, listener);
}
public Registration addNodeSelectedListener(ComponentEventListener<NodeSelectedEvent> listener) {
return addListener(NodeSelectedEvent.class, listener);
}
public Registration addNodeUnselectedListener(ComponentEventListener<NodeUnselectedEvent> listener) {
return addListener(NodeUnselectedEvent.class, listener);
}
public Registration addNodeRemovedListener(ComponentEventListener<NodeRemovedEvent> listener) {
return addListener(NodeRemovedEvent.class, listener);
}
@ClientCallable
private void onNodeAdded(int nodeId, String typeId) {
fireEvent(new NodeAddedEvent(this, nodeId, typeId));
}
@ClientCallable
private void onNodeSelected(int nodeId) {
fireEvent(new NodeSelectedEvent(this, nodeId));
}
@ClientCallable
private void onNodeUnselected() {
fireEvent(new NodeUnselectedEvent(this));
}
@ClientCallable
private void onNodeRemoved(int nodeId) {
fireEvent(new NodeRemovedEvent(this, nodeId));
}
public static class NodeAddedEvent extends ComponentEvent<NodeEditor> {
private final int nodeId;
private final String typeId;
public NodeAddedEvent(NodeEditor source, int nodeId, String typeId) {
super(source, true);
this.nodeId = nodeId;
this.typeId = typeId;
}
public int nodeId() { return nodeId; }
public String typeId() { return typeId; }
}
public static class NodeSelectedEvent extends ComponentEvent<NodeEditor> {
private final int nodeId;
public NodeSelectedEvent(NodeEditor source, int nodeId) {
super(source, true);
this.nodeId = nodeId;
}
public int nodeId() { return nodeId; }
}
public static class NodeUnselectedEvent extends ComponentEvent<NodeEditor> {
public NodeUnselectedEvent(NodeEditor source) {
super(source, true);
}
}
public static class NodeRemovedEvent extends ComponentEvent<NodeEditor> {
private final int nodeId;
public NodeRemovedEvent(NodeEditor source, int nodeId) {
super(source, true);
this.nodeId = nodeId;
}
public int nodeId() { return nodeId; }
}
}

View File

@@ -0,0 +1,79 @@
package de.assecutor.tasklisteditor.document;
import de.assecutor.tasklisteditor.model.CanvasBlockState;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.Instant;
import java.util.List;
/**
* In MongoDB gespeicherter Canvas-Stand für die spätere Weiterbearbeitung
* im Editor.
*
* <p>{@link #layout} enthält den vollständigen Drawflow-Graphen (Knoten,
* Positionen, Verbindungen) als JSON. {@link #blocks} hält die serverseitigen
* Property-Werte je Knoten, damit der Canvas vollständig wiederhergestellt
* werden kann.
*/
@Document(collection = "${MONGODB_CANVAS_COLLECTION:canvas_layouts}")
public class CanvasDocument {
@Id
private String id;
private String name;
private Instant createdAt;
private String layout;
private List<CanvasBlockState> blocks;
public CanvasDocument() {
}
public CanvasDocument(String name, Instant createdAt, String layout, List<CanvasBlockState> blocks) {
this.name = name;
this.createdAt = createdAt;
this.layout = layout;
this.blocks = blocks;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public String getLayout() {
return layout;
}
public void setLayout(String layout) {
this.layout = layout;
}
public List<CanvasBlockState> getBlocks() {
return blocks;
}
public void setBlocks(List<CanvasBlockState> blocks) {
this.blocks = blocks;
}
}

View File

@@ -0,0 +1,80 @@
package de.assecutor.tasklisteditor.document;
import de.assecutor.tasklisteditor.model.TaskDto;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.Instant;
import java.util.List;
/**
* In MongoDB gespeicherte Task-Liste. Das Feld {@link #tasks} entspricht
* exakt dem {@code tasks}-Array, das zur stadtbote-App gesendet wird.
* {@link #layout} hält zusätzlich den rohen Editor-Graphen (Drawflow),
* damit die Liste später im Editor wieder geöffnet werden kann.
*/
@Document(collection = "${MONGODB_COLLECTION:tasks_json}")
public class TaskListDocument {
/**
* Eindeutige Dokument-ID. Wird beim Speichern automatisch als
* MongoDB-ObjectId vergeben, falls nicht gesetzt.
*/
@Id
private String id;
private String name;
private Instant createdAt;
private List<TaskDto> tasks;
private String layout;
public TaskListDocument() {
}
public TaskListDocument(String name, Instant createdAt, List<TaskDto> tasks, String layout) {
this.name = name;
this.createdAt = createdAt;
this.tasks = tasks;
this.layout = layout;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public List<TaskDto> getTasks() {
return tasks;
}
public void setTasks(List<TaskDto> tasks) {
this.tasks = tasks;
}
public String getLayout() {
return layout;
}
public void setLayout(String layout) {
this.layout = layout;
}
}

View File

@@ -0,0 +1,50 @@
package de.assecutor.tasklisteditor.model;
import java.util.HashMap;
import java.util.Map;
public class BlockInstance {
private final int id;
private final BlockType type;
private final Map<String, Object> values = new HashMap<>();
private String topic;
public BlockInstance(int id, BlockType type) {
this.id = id;
this.type = type;
this.topic = type.label();
for (PropertyDefinition def : type.properties()) {
values.put(def.id(), def.defaultValue());
}
}
public int id() {
return id;
}
public BlockType type() {
return type;
}
/** Anzeigename des Tasks (entspricht {@code TourTask.topic} im App-JSON). */
public String topic() {
return topic;
}
public void setTopic(String topic) {
this.topic = topic;
}
public Map<String, Object> values() {
return values;
}
public Object get(String key) {
return values.get(key);
}
public void set(String key, Object value) {
values.put(key, value);
}
}

View File

@@ -0,0 +1,16 @@
package de.assecutor.tasklisteditor.model;
import java.util.List;
public record BlockType(
String id,
String label,
String description,
String icon,
String color,
int taskType,
int inputs,
int outputs,
List<PropertyDefinition> properties
) {
}

View File

@@ -0,0 +1,146 @@
package de.assecutor.tasklisteditor.model;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Registriert die im Editor verfügbaren Funktionsblöcke.
*
* <p>Die Block-Typen entsprechen den Task-Typen der stadtbote-App
* ({@code TourTask.type}). Die Property-IDs sind bewusst auf die
* JSON-Feldnamen der App ausgerichtet (opt, reenter, min, max, max_len,
* txt, track, entries …), damit der Export ohne Umbenennung ein
* app-konformes {@code tasks}-JSON erzeugen kann.
*/
@Component
public class BlockTypeRegistry {
private final Map<String, BlockType> types = new LinkedHashMap<>();
public BlockTypeRegistry() {
register(new BlockType(
"ankunft-melden",
"Ankunft melden",
"Erfasst die Ankunft am Einsatzort (Bestätigungsschaltfläche).",
"vaadin:map-marker",
"#2563eb",
3,
1, 1,
List.of(
PropertyDefinition.bool("track", "GPS-Position erfassen", true),
PropertyDefinition.textArea("txt", "Hinweis für den Bearbeiter"),
PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false)
)
));
register(new BlockType(
"unterschrift-erfassen",
"Unterschrift erfassen",
"Lässt eine Unterschrift auf dem Display erfassen.",
"vaadin:edit",
"#7c3aed",
2,
1, 1,
List.of(
PropertyDefinition.textArea("txt", "Hinweistext"),
PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false)
)
));
register(new BlockType(
"fotos-aufnehmen",
"Fotos aufnehmen",
"Nimmt eine Anzahl von Fotos auf.",
"vaadin:camera",
"#059669",
4,
1, 1,
List.of(
PropertyDefinition.number("min", "Mindestanzahl Fotos", 1),
PropertyDefinition.number("max", "Maximale Anzahl Fotos", 10),
PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false)
)
));
register(new BlockType(
"quittungsgeber-erfassen",
"Quittungsgeber erfassen",
"Erfasst den Namen des Quittungsgebers und dessen Anrede aus einer Auswahlliste.",
"vaadin:user-check",
"#dc2626",
7,
1, 1,
List.of(
PropertyDefinition.textArea("entries", "Auswahloptionen (eine pro Zeile)"),
PropertyDefinition.number("max_len", "Maximale Zeichenanzahl Name", 40),
PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false)
)
));
register(new BlockType(
"stationshinweis",
"Stationshinweis",
"Lässt einen mehrzeiligen Hinweis zur Station erfassen.",
"vaadin:clipboard-text",
"#0891b2",
8,
1, 1,
List.of(
PropertyDefinition.textArea("txt", "Vorgabetext / Hinweis"),
PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false)
)
));
register(new BlockType(
"kommissionsnummer-erfassen",
"Kommissionsnummer erfassen",
"Erfasst eine Kommissionsnummer per Texteingabe.",
"vaadin:hash",
"#d97706",
9,
1, 1,
List.of(
PropertyDefinition.number("max_len", "Maximale Zeichenanzahl", 0),
PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false)
)
));
register(new BlockType(
"abhol-lieferstatus",
"Abhol-/Lieferstatus",
"Lässt einen Status aus einer Auswahlliste wählen, optional mit Bemerkung.",
"vaadin:truck",
"#4f46e5",
11,
1, 1,
List.of(
PropertyDefinition.textArea("entries", "Statusoptionen (eine pro Zeile)"),
PropertyDefinition.bool("query_remark", "Bemerkung abfragen", false),
PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false)
)
));
}
private void register(BlockType type) {
types.put(type.id(), type);
}
public List<BlockType> all() {
return List.copyOf(types.values());
}
public Optional<BlockType> find(String id) {
return Optional.ofNullable(types.get(id));
}
}

View File

@@ -0,0 +1,59 @@
package de.assecutor.tasklisteditor.model;
import java.util.Map;
/**
* Serverseitiger Zustand eines Canvas-Knotens (Property-Werte + Bezeichnung),
* der zusammen mit dem Drawflow-Layout gespeichert wird. Über die
* {@link #nodeId} wird der Zustand beim Laden wieder dem importierten Knoten
* zugeordnet (Drawflow erhält die IDs beim Import).
*/
public class CanvasBlockState {
private int nodeId;
private String typeId;
private String topic;
private Map<String, Object> values;
public CanvasBlockState() {
}
public CanvasBlockState(int nodeId, String typeId, String topic, Map<String, Object> values) {
this.nodeId = nodeId;
this.typeId = typeId;
this.topic = topic;
this.values = values;
}
public int getNodeId() {
return nodeId;
}
public void setNodeId(int nodeId) {
this.nodeId = nodeId;
}
public String getTypeId() {
return typeId;
}
public void setTypeId(String typeId) {
this.typeId = typeId;
}
public String getTopic() {
return topic;
}
public void setTopic(String topic) {
this.topic = topic;
}
public Map<String, Object> getValues() {
return values;
}
public void setValues(Map<String, Object> values) {
this.values = values;
}
}

View File

@@ -0,0 +1,19 @@
package de.assecutor.tasklisteditor.model;
public record PropertyDefinition(String id, String label, PropertyType type, Object defaultValue) {
public static PropertyDefinition text(String id, String label) {
return new PropertyDefinition(id, label, PropertyType.TEXT, "");
}
public static PropertyDefinition textArea(String id, String label) {
return new PropertyDefinition(id, label, PropertyType.TEXTAREA, "");
}
public static PropertyDefinition number(String id, String label, int defaultValue) {
return new PropertyDefinition(id, label, PropertyType.NUMBER, defaultValue);
}
public static PropertyDefinition bool(String id, String label, boolean defaultValue) {
return new PropertyDefinition(id, label, PropertyType.BOOLEAN, defaultValue);
}
}

View File

@@ -0,0 +1,8 @@
package de.assecutor.tasklisteditor.model;
public enum PropertyType {
TEXT,
TEXTAREA,
NUMBER,
BOOLEAN
}

View File

@@ -0,0 +1,160 @@
package de.assecutor.tasklisteditor.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import java.util.List;
/**
* Ein einzelner Task, so wie er im {@code tasks}-Array zur stadtbote-App
* gesendet wird (entspricht {@code TourTask}).
*
* <p>Optionale Felder werden bei {@code null} aus dem JSON ausgelassen,
* damit nur die für den jeweiligen Task-Typ relevanten Einstellungen
* erscheinen.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({"s_id", "type", "topic", "opt", "reenter", "min", "max",
"max_len", "rt", "track", "txt", "task_arrival", "task_fin_work", "entries"})
public class TaskDto {
@JsonProperty("s_id")
private int sId;
private int type;
private String topic;
private Boolean opt;
private Boolean reenter;
private Integer min;
private Integer max;
@JsonProperty("max_len")
private Integer maxLen;
private Boolean rt;
private Boolean track;
private String txt;
@JsonProperty("task_arrival")
private Integer taskArrival;
@JsonProperty("task_fin_work")
private Integer taskFinWork;
private List<TaskEntryDto> entries;
public int getSId() {
return sId;
}
public void setSId(int sId) {
this.sId = sId;
}
public int getType() {
return type;
}
public void setType(int type) {
this.type = type;
}
public String getTopic() {
return topic;
}
public void setTopic(String topic) {
this.topic = topic;
}
public Boolean getOpt() {
return opt;
}
public void setOpt(Boolean opt) {
this.opt = opt;
}
public Boolean getReenter() {
return reenter;
}
public void setReenter(Boolean reenter) {
this.reenter = reenter;
}
public Integer getMin() {
return min;
}
public void setMin(Integer min) {
this.min = min;
}
public Integer getMax() {
return max;
}
public void setMax(Integer max) {
this.max = max;
}
public Integer getMaxLen() {
return maxLen;
}
public void setMaxLen(Integer maxLen) {
this.maxLen = maxLen;
}
public Boolean getRt() {
return rt;
}
public void setRt(Boolean rt) {
this.rt = rt;
}
public Boolean getTrack() {
return track;
}
public void setTrack(Boolean track) {
this.track = track;
}
public String getTxt() {
return txt;
}
public void setTxt(String txt) {
this.txt = txt;
}
public Integer getTaskArrival() {
return taskArrival;
}
public void setTaskArrival(Integer taskArrival) {
this.taskArrival = taskArrival;
}
public Integer getTaskFinWork() {
return taskFinWork;
}
public void setTaskFinWork(Integer taskFinWork) {
this.taskFinWork = taskFinWork;
}
public List<TaskEntryDto> getEntries() {
return entries;
}
public void setEntries(List<TaskEntryDto> entries) {
this.entries = entries;
}
}

View File

@@ -0,0 +1,49 @@
package de.assecutor.tasklisteditor.model;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Eintrag einer Auswahlliste innerhalb eines Tasks.
* Entspricht {@code TourEntry} im JSON der stadtbote-App.
*/
public class TaskEntryDto {
private int code;
private String text;
@JsonProperty("query_remark")
private boolean queryRemark;
public TaskEntryDto() {
}
public TaskEntryDto(int code, String text, boolean queryRemark) {
this.code = code;
this.text = text;
this.queryRemark = queryRemark;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
public boolean isQueryRemark() {
return queryRemark;
}
public void setQueryRemark(boolean queryRemark) {
this.queryRemark = queryRemark;
}
}

View File

@@ -0,0 +1,32 @@
package de.assecutor.tasklisteditor.model;
import java.util.List;
/**
* Eingehende Nutzlast beim Aufruf des Editors per Deep-Link.
* Entspricht dem JSON-Body, der per Request übergeben wird.
*
* <p>Erlaubt entweder ein Objekt {@code {"name": ..., "tasks": [ ... ]}}
* oder über den Controller ein nacktes {@code tasks}-Array.
*/
public class TaskListPayload {
private String name;
private List<TaskDto> tasks;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public List<TaskDto> getTasks() {
return tasks;
}
public void setTasks(List<TaskDto> tasks) {
this.tasks = tasks;
}
}

View File

@@ -0,0 +1,14 @@
package de.assecutor.tasklisteditor.repository;
import de.assecutor.tasklisteditor.document.CanvasDocument;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List;
/**
* Spring-Data-Repository für die in MongoDB abgelegten Canvas-Stände.
*/
public interface CanvasRepository extends MongoRepository<CanvasDocument, String> {
List<CanvasDocument> findAllByOrderByCreatedAtDesc();
}

View File

@@ -0,0 +1,10 @@
package de.assecutor.tasklisteditor.repository;
import de.assecutor.tasklisteditor.document.TaskListDocument;
import org.springframework.data.mongodb.repository.MongoRepository;
/**
* Spring-Data-Repository für die in MongoDB abgelegten Task-Listen.
*/
public interface TaskListRepository extends MongoRepository<TaskListDocument, String> {
}

View File

@@ -0,0 +1,81 @@
package de.assecutor.tasklisteditor.rest;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.tasklisteditor.model.TaskDto;
import de.assecutor.tasklisteditor.model.TaskListPayload;
import de.assecutor.tasklisteditor.service.PendingImportStore;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.util.List;
/**
* Nimmt die per Deep-Link zu übergebenden Task-Daten als JSON-Body entgegen
* und liefert einen Deep-Link zurück, mit dem der Editor geöffnet wird.
*
* <p>Aufruf (Beispiel):
* <pre>
* POST /api/editor/import
* Content-Type: application/json
*
* { "name": "Tour 4711", "tasks": [ { "type": 3, "topic": "Ankunft", ... } ] }
* </pre>
* Alternativ kann der Body direkt das {@code tasks}-Array sein.
*
* <p>Antwort:
* <pre>
* { "token": "...", "deepLink": "http://host/?import=..." }
* </pre>
*/
@RestController
@RequestMapping("/api/editor")
public class EditorImportController {
private final PendingImportStore importStore;
private final ObjectMapper objectMapper;
public EditorImportController(PendingImportStore importStore, ObjectMapper objectMapper) {
this.importStore = importStore;
this.objectMapper = objectMapper;
}
public record ImportResponse(String token, String deepLink) {
}
@PostMapping("/import")
public ResponseEntity<ImportResponse> importTasks(@RequestBody JsonNode body) {
TaskListPayload payload = toPayload(body);
if (payload.getTasks() == null || payload.getTasks().isEmpty()) {
return ResponseEntity.badRequest().build();
}
String token = importStore.put(payload);
String deepLink = ServletUriComponentsBuilder.fromCurrentContextPath()
.path("/")
.queryParam("import", token)
.build()
.toUriString();
return ResponseEntity.ok(new ImportResponse(token, deepLink));
}
private TaskListPayload toPayload(JsonNode body) {
if (body == null || body.isNull()) {
return new TaskListPayload();
}
if (body.isArray()) {
TaskListPayload payload = new TaskListPayload();
payload.setTasks(objectMapper.convertValue(body, new TypeReference<List<TaskDto>>() {
}));
return payload;
}
// Objekt mit { name?, tasks: [...] }
return objectMapper.convertValue(body, TaskListPayload.class);
}
}

View File

@@ -0,0 +1,36 @@
package de.assecutor.tasklisteditor.service;
import de.assecutor.tasklisteditor.document.CanvasDocument;
import de.assecutor.tasklisteditor.model.CanvasBlockState;
import de.assecutor.tasklisteditor.repository.CanvasRepository;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/**
* Speichert und lädt Canvas-Stände (Drawflow-Layout + Block-Werte) in MongoDB.
*/
@Service
public class CanvasService {
private final CanvasRepository repository;
public CanvasService(CanvasRepository repository) {
this.repository = repository;
}
public String save(String name, String layout, List<CanvasBlockState> blocks) {
CanvasDocument document = new CanvasDocument(name, Instant.now(), layout, blocks);
return repository.save(document).getId();
}
public List<CanvasDocument> listNewestFirst() {
return repository.findAllByOrderByCreatedAtDesc();
}
public Optional<CanvasDocument> load(String id) {
return repository.findById(id);
}
}

View File

@@ -0,0 +1,58 @@
package de.assecutor.tasklisteditor.service;
import de.assecutor.tasklisteditor.model.TaskListPayload;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Kurzlebiger Zwischenspeicher für per Deep-Link übergebene Task-Daten.
*
* <p>Da ein Deep-Link im Browser per {@code GET} geöffnet wird und keinen
* Request-Body tragen kann, nimmt der REST-Endpunkt die Daten per {@code POST}
* entgegen, legt sie hier unter einem Token ab und liefert einen Deep-Link mit
* diesem Token zurück. Der Editor liest die Daten beim Öffnen anhand des Tokens
* wieder aus (und entfernt sie dabei).
*
* <p>Bewusst In-Memory gehalten (transienter Hand-off). Für einen
* mehrinstanzigen Betrieb müsste hier ein geteilter Speicher (z.&nbsp;B.
* MongoDB mit TTL oder Redis) verwendet werden.
*/
@Component
public class PendingImportStore {
private static final Duration TTL = Duration.ofMinutes(30);
private record Entry(TaskListPayload payload, Instant expiresAt) {
}
private final Map<String, Entry> entries = new ConcurrentHashMap<>();
/** Legt eine Nutzlast ab und liefert das Zugriffs-Token. */
public String put(TaskListPayload payload) {
purgeExpired();
String token = UUID.randomUUID().toString();
entries.put(token, new Entry(payload, Instant.now().plus(TTL)));
return token;
}
/** Liest die Nutzlast einmalig aus und entfernt sie. */
public Optional<TaskListPayload> consume(String token) {
purgeExpired();
Entry entry = entries.remove(token);
if (entry == null || entry.expiresAt().isBefore(Instant.now())) {
return Optional.empty();
}
return Optional.of(entry.payload());
}
private void purgeExpired() {
Instant now = Instant.now();
entries.entrySet().removeIf(e -> e.getValue().expiresAt().isBefore(now));
}
}

View File

@@ -0,0 +1,188 @@
package de.assecutor.tasklisteditor.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.tasklisteditor.model.BlockInstance;
import de.assecutor.tasklisteditor.model.TaskDto;
import de.assecutor.tasklisteditor.model.TaskEntryDto;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Wandelt den Editor-Zustand (Drawflow-Graph + serverseitige
* {@link BlockInstance}-Werte) in die geordnete Task-Liste um, wie sie
* die stadtbote-App erwartet.
*
* <p>Die Reihenfolge ergibt sich aus den Verbindungen: ausgehend von den
* Start-Knoten (ohne eingehende Verbindung) werden die Knoten entlang ihrer
* Ausgänge durchlaufen. Zyklen (z.&nbsp;B. Wiedereinstieg) werden über ein
* Besucht-Set abgefangen; nicht verbundene Knoten werden nach ihrer
* vertikalen Position angehängt.
*/
@Component
public class TaskListExporter {
private final ObjectMapper objectMapper;
public TaskListExporter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public List<TaskDto> export(String drawflowJson, Map<Integer, BlockInstance> instances) {
List<TaskDto> tasks = new ArrayList<>();
if (drawflowJson == null || drawflowJson.isBlank()) {
return tasks;
}
JsonNode data;
try {
data = objectMapper.readTree(drawflowJson).path("drawflow").path("Home").path("data");
} catch (Exception ex) {
throw new IllegalStateException("Editor-Graph konnte nicht gelesen werden: " + ex.getMessage(), ex);
}
if (!data.isObject() || data.isEmpty()) {
return tasks;
}
List<Integer> order = determineOrder(data);
int sId = 1;
for (int nodeId : order) {
BlockInstance instance = instances.get(nodeId);
if (instance == null) {
continue;
}
TaskDto task = toTask(instance);
task.setSId(sId++);
tasks.add(task);
}
return tasks;
}
private List<Integer> determineOrder(JsonNode data) {
// Knoten einsammeln + eingehende Verbindungen zählen.
List<Integer> nodeIds = new ArrayList<>();
data.fieldNames().forEachRemaining(name -> nodeIds.add(Integer.parseInt(name)));
Set<Integer> hasIncoming = new LinkedHashSet<>();
for (int id : nodeIds) {
for (int target : outgoing(data, id)) {
hasIncoming.add(target);
}
}
// Start-Knoten = ohne eingehende Verbindung, sortiert nach Position (oben -> unten).
List<Integer> roots = new ArrayList<>();
for (int id : nodeIds) {
if (!hasIncoming.contains(id)) {
roots.add(id);
}
}
roots.sort(Comparator.comparingDouble(id -> posY(data, id)));
Set<Integer> visited = new LinkedHashSet<>();
for (int root : roots) {
visit(data, root, visited);
}
// Übrig gebliebene (z.B. reine Zyklen) nach Position anhängen.
nodeIds.stream()
.filter(id -> !visited.contains(id))
.sorted(Comparator.comparingDouble(id -> posY(data, id)))
.forEach(visited::add);
return new ArrayList<>(visited);
}
private void visit(JsonNode data, int nodeId, Set<Integer> visited) {
if (!visited.add(nodeId)) {
return;
}
for (int target : outgoing(data, nodeId)) {
visit(data, target, visited);
}
}
private List<Integer> outgoing(JsonNode data, int nodeId) {
List<Integer> targets = new ArrayList<>();
JsonNode outputs = data.path(String.valueOf(nodeId)).path("outputs");
outputs.forEach(output -> output.path("connections").forEach(conn -> {
JsonNode node = conn.path("node");
if (!node.isMissingNode()) {
targets.add(Integer.parseInt(node.asText()));
}
}));
return targets;
}
private double posY(JsonNode data, int nodeId) {
return data.path(String.valueOf(nodeId)).path("pos_y").asDouble(0);
}
private TaskDto toTask(BlockInstance instance) {
TaskDto task = new TaskDto();
task.setType(instance.type().taskType());
String topic = instance.topic();
task.setTopic(topic == null || topic.isBlank() ? instance.type().label() : topic);
Map<String, Object> v = instance.values();
if (v.containsKey("opt")) task.setOpt(asBool(v.get("opt")));
if (v.containsKey("reenter")) task.setReenter(asBool(v.get("reenter")));
if (v.containsKey("rt")) task.setRt(asBool(v.get("rt")));
if (v.containsKey("track")) task.setTrack(asBool(v.get("track")));
if (v.containsKey("min")) task.setMin(asInt(v.get("min")));
if (v.containsKey("max")) task.setMax(asInt(v.get("max")));
if (v.containsKey("max_len")) task.setMaxLen(asInt(v.get("max_len")));
if (v.containsKey("txt")) task.setTxt(asText(v.get("txt")));
if (v.containsKey("entries")) {
boolean queryRemark = v.containsKey("query_remark") && asBool(v.get("query_remark"));
task.setEntries(parseEntries(asText(v.get("entries")), queryRemark));
}
return task;
}
private List<TaskEntryDto> parseEntries(String raw, boolean queryRemark) {
if (raw == null || raw.isBlank()) {
return null;
}
List<TaskEntryDto> entries = new ArrayList<>();
int code = 1;
for (String line : raw.split("\\r?\\n")) {
String text = line.trim();
if (!text.isEmpty()) {
entries.add(new TaskEntryDto(code++, text, queryRemark));
}
}
return entries.isEmpty() ? null : entries;
}
private Boolean asBool(Object o) {
return o instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(o));
}
private Integer asInt(Object o) {
if (o instanceof Number n) {
return n.intValue();
}
try {
return Integer.parseInt(String.valueOf(o));
} catch (NumberFormatException ex) {
return null;
}
}
private String asText(Object o) {
if (o == null) {
return null;
}
String s = o.toString();
return s.isBlank() ? null : s;
}
}

View File

@@ -0,0 +1,100 @@
package de.assecutor.tasklisteditor.service;
import de.assecutor.tasklisteditor.model.BlockType;
import de.assecutor.tasklisteditor.model.BlockTypeRegistry;
import de.assecutor.tasklisteditor.model.PropertyDefinition;
import de.assecutor.tasklisteditor.model.TaskDto;
import de.assecutor.tasklisteditor.model.TaskEntryDto;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Wandelt das eingehende {@code tasks}-JSON in editierbare Blöcke um
* die Umkehrung von {@link TaskListExporter}.
*
* <p>Anhand von {@code TourTask.type} wird der passende Block-Typ gesucht und
* die Property-Werte (opt, reenter, min, max, max_len, txt, track, entries …)
* gefüllt. Tasks mit unbekanntem Typ werden übersprungen.
*/
@Component
public class TaskListImporter {
private final BlockTypeRegistry registry;
public TaskListImporter(BlockTypeRegistry registry) {
this.registry = registry;
}
/** Ein aufgelöster Task, bereit zum Anlegen eines Knotens im Editor. */
public record ResolvedTask(BlockType type, String topic, Map<String, Object> values) {
}
public List<ResolvedTask> resolve(List<TaskDto> tasks) {
List<ResolvedTask> resolved = new ArrayList<>();
if (tasks == null) {
return resolved;
}
for (TaskDto task : tasks) {
BlockType type = findByTaskType(task.getType());
if (type == null) {
continue;
}
String topic = task.getTopic() == null || task.getTopic().isBlank()
? type.label()
: task.getTopic();
resolved.add(new ResolvedTask(type, topic, valuesFor(type, task)));
}
return resolved;
}
private BlockType findByTaskType(int taskType) {
return registry.all().stream()
.filter(t -> t.taskType() == taskType)
.findFirst()
.orElse(null);
}
private Map<String, Object> valuesFor(BlockType type, TaskDto task) {
Map<String, Object> values = new LinkedHashMap<>();
for (PropertyDefinition def : type.properties()) {
Object value = switch (def.id()) {
case "opt" -> orDefault(task.getOpt(), def.defaultValue());
case "reenter" -> orDefault(task.getReenter(), def.defaultValue());
case "rt" -> orDefault(task.getRt(), def.defaultValue());
case "track" -> orDefault(task.getTrack(), def.defaultValue());
case "min" -> orDefault(task.getMin(), def.defaultValue());
case "max" -> orDefault(task.getMax(), def.defaultValue());
case "max_len" -> orDefault(task.getMaxLen(), def.defaultValue());
case "txt" -> orDefault(task.getTxt(), def.defaultValue());
case "query_remark" -> anyQueryRemark(task);
case "entries" -> entriesText(task);
default -> def.defaultValue();
};
values.put(def.id(), value);
}
return values;
}
private Object orDefault(Object value, Object fallback) {
return value != null ? value : fallback;
}
private boolean anyQueryRemark(TaskDto task) {
return task.getEntries() != null
&& task.getEntries().stream().anyMatch(TaskEntryDto::isQueryRemark);
}
private String entriesText(TaskDto task) {
if (task.getEntries() == null || task.getEntries().isEmpty()) {
return "";
}
return task.getEntries().stream()
.map(TaskEntryDto::getText)
.collect(Collectors.joining("\n"));
}
}

View File

@@ -0,0 +1,57 @@
package de.assecutor.tasklisteditor.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.tasklisteditor.document.TaskListDocument;
import de.assecutor.tasklisteditor.model.BlockInstance;
import de.assecutor.tasklisteditor.model.TaskDto;
import de.assecutor.tasklisteditor.repository.TaskListRepository;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* Erzeugt aus dem Editor-Zustand das app-konforme Task-JSON und speichert
* die Liste in MongoDB.
*/
@Service
public class TaskListService {
private final TaskListExporter exporter;
private final TaskListRepository repository;
private final ObjectMapper objectMapper;
public TaskListService(TaskListExporter exporter,
TaskListRepository repository,
ObjectMapper objectMapper) {
this.exporter = exporter;
this.repository = repository;
this.objectMapper = objectMapper;
}
/** Ergebnis eines Exports: typisierte Tasks plus formatiertes JSON. */
public record ExportResult(List<TaskDto> tasks, String json) {
}
public ExportResult build(String drawflowJson, Map<Integer, BlockInstance> instances) {
List<TaskDto> tasks = exporter.export(drawflowJson, instances);
try {
String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(tasks);
return new ExportResult(tasks, json);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("Task-JSON konnte nicht erzeugt werden: " + ex.getMessage(), ex);
}
}
/**
* Speichert die Task-Liste in MongoDB.
*
* @return die vergebene Dokument-ID
*/
public String save(String name, List<TaskDto> tasks, String layout) {
TaskListDocument document = new TaskListDocument(name, Instant.now(), tasks, layout);
return repository.save(document).getId();
}
}

View File

@@ -0,0 +1,545 @@
package de.assecutor.tasklisteditor.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.dialog.Dialog;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.tasklisteditor.component.NodeEditor;
import de.assecutor.tasklisteditor.model.BlockInstance;
import de.assecutor.tasklisteditor.model.BlockType;
import de.assecutor.tasklisteditor.model.BlockTypeRegistry;
import de.assecutor.tasklisteditor.model.CanvasBlockState;
import de.assecutor.tasklisteditor.model.PropertyDefinition;
import de.assecutor.tasklisteditor.model.TaskListPayload;
import de.assecutor.tasklisteditor.document.CanvasDocument;
import de.assecutor.tasklisteditor.service.CanvasService;
import de.assecutor.tasklisteditor.service.PendingImportStore;
import de.assecutor.tasklisteditor.service.TaskListImporter;
import de.assecutor.tasklisteditor.service.TaskListService;
import elemental.json.Json;
import elemental.json.JsonArray;
import elemental.json.JsonObject;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Route("")
@PageTitle("TaskList Editor")
public class MainView extends VerticalLayout implements BeforeEnterObserver {
/** Query-Parameter des Deep-Links, der das Übergabe-Token enthält. */
public static final String IMPORT_PARAM = "import";
private final BlockTypeRegistry registry;
private final TaskListService taskListService;
private final TaskListImporter taskListImporter;
private final PendingImportStore importStore;
private final CanvasService canvasService;
private final ObjectMapper objectMapper;
private final NodeEditor nodeEditor;
private final Map<Integer, BlockInstance> instances = new HashMap<>();
private final Div propertiesContent;
private final Paragraph propertiesEmpty;
private String currentListName;
public MainView(BlockTypeRegistry registry,
TaskListService taskListService,
TaskListImporter taskListImporter,
PendingImportStore importStore,
CanvasService canvasService,
ObjectMapper objectMapper) {
this.registry = registry;
this.taskListService = taskListService;
this.taskListImporter = taskListImporter;
this.importStore = importStore;
this.canvasService = canvasService;
this.objectMapper = objectMapper;
setSizeFull();
setPadding(false);
setSpacing(false);
addClassName("tasklist-app");
add(buildHeader());
HorizontalLayout body = new HorizontalLayout();
body.setSizeFull();
body.setPadding(false);
body.setSpacing(false);
body.addClassName("tasklist-body");
Div palette = buildPalette();
nodeEditor = new NodeEditor();
Div propertiesPanel = buildPropertiesPanel();
propertiesContent = (Div) propertiesPanel.getChildren()
.filter(c -> c.getElement().getClassList().contains("properties-content"))
.findFirst().orElseThrow();
propertiesEmpty = new Paragraph("Klicke auf einen Funktionsblock, um seine Eigenschaften zu bearbeiten.");
propertiesEmpty.addClassName("properties-empty");
propertiesContent.add(propertiesEmpty);
body.add(palette, nodeEditor, propertiesPanel);
body.expand(nodeEditor);
add(body);
nodeEditor.registerBlockTypes(registry.all());
nodeEditor.addNodeAddedListener(e -> {
BlockType type = registry.find(e.typeId()).orElse(null);
if (type == null) return;
instances.put(e.nodeId(), new BlockInstance(e.nodeId(), type));
});
nodeEditor.addNodeSelectedListener(e -> showProperties(e.nodeId()));
nodeEditor.addNodeUnselectedListener(e -> showEmpty());
nodeEditor.addNodeRemovedListener(e -> {
instances.remove(e.nodeId());
showEmpty();
});
}
private Component buildHeader() {
HorizontalLayout header = new HorizontalLayout();
header.addClassName("tasklist-header");
header.setWidthFull();
header.setAlignItems(FlexComponent.Alignment.CENTER);
header.setPadding(true);
header.setSpacing(true);
H2 title = new H2("TaskList Editor");
title.addClassName("tasklist-title");
Span subtitle = new Span("Funktionsblöcke per Drag & Drop in eine Reihenfolge bringen");
subtitle.addClassName("tasklist-subtitle");
Button clear = new Button("Canvas leeren", new Icon(VaadinIcon.TRASH));
clear.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
clear.addClickListener(e -> {
nodeEditor.clear();
instances.clear();
showEmpty();
Notification.show("Canvas geleert");
});
Button saveCanvas = new Button("Canvas speichern", new Icon(VaadinIcon.HARDDRIVE));
saveCanvas.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
saveCanvas.addClickListener(e -> saveCanvas());
Button loadCanvas = new Button("Canvas laden", new Icon(VaadinIcon.FOLDER_OPEN));
loadCanvas.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
loadCanvas.addClickListener(e -> openLoadCanvasDialog());
Button export = new Button("JSON erzeugen & speichern", new Icon(VaadinIcon.DOWNLOAD));
export.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
export.addClickListener(e -> exportTasks());
Span spacer = new Span();
spacer.getStyle().set("flex", "1");
header.add(title, subtitle, spacer, clear, saveCanvas, loadCanvas, export);
return header;
}
private Div buildPalette() {
Div panel = new Div();
panel.addClassName("palette-panel");
H3 heading = new H3("Funktionsblöcke");
heading.addClassName("panel-heading");
panel.add(heading);
Paragraph hint = new Paragraph("Per Drag & Drop auf das Canvas ziehen.");
hint.addClassName("panel-hint");
panel.add(hint);
for (BlockType type : registry.all()) {
panel.add(buildPaletteItem(type));
}
return panel;
}
private Component buildPaletteItem(BlockType type) {
Div tile = new Div();
tile.addClassName("palette-item");
tile.getElement().setAttribute("draggable", "true");
tile.getElement().setAttribute("data-blocktype", type.id());
tile.getElement().getStyle().set("--block-color", type.color());
Div iconWrap = new Div();
iconWrap.addClassName("palette-item-icon");
iconWrap.getStyle().set("background", type.color());
iconWrap.add(new Icon(parseIcon(type.icon())));
Div text = new Div();
text.addClassName("palette-item-text");
Span label = new Span(type.label());
label.addClassName("palette-item-label");
Span desc = new Span(type.description());
desc.addClassName("palette-item-desc");
text.add(label, desc);
tile.add(iconWrap, text);
return tile;
}
private VaadinIcon parseIcon(String key) {
if (key == null || !key.startsWith("vaadin:")) return VaadinIcon.CIRCLE;
try {
return VaadinIcon.valueOf(key.substring("vaadin:".length()).toUpperCase().replace('-', '_'));
} catch (IllegalArgumentException ex) {
return VaadinIcon.CIRCLE;
}
}
private Div buildPropertiesPanel() {
Div panel = new Div();
panel.addClassName("properties-panel");
H3 heading = new H3("Eigenschaften");
heading.addClassName("panel-heading");
panel.add(heading);
Div content = new Div();
content.addClassName("properties-content");
panel.add(content);
return panel;
}
private void showProperties(int nodeId) {
BlockInstance instance = instances.get(nodeId);
if (instance == null) {
showEmpty();
return;
}
propertiesContent.removeAll();
Div badge = new Div();
badge.addClassName("properties-badge");
badge.getStyle().set("background", instance.type().color());
badge.add(new Icon(parseIcon(instance.type().icon())));
Span name = new Span(instance.type().label());
name.addClassName("properties-name");
HorizontalLayout title = new HorizontalLayout(badge, name);
title.setAlignItems(FlexComponent.Alignment.CENTER);
title.addClassName("properties-title");
propertiesContent.add(title);
Paragraph desc = new Paragraph(instance.type().description());
desc.addClassName("properties-desc");
propertiesContent.add(desc);
TextField topic = new TextField("Bezeichnung");
topic.setWidthFull();
topic.setHelperText("Wird dem Bearbeiter angezeigt (topic).");
topic.setValue(instance.topic() == null ? "" : instance.topic());
topic.addValueChangeListener(e -> {
String value = e.getValue();
instance.setTopic(value);
nodeEditor.updateNodeLabel(instance.id(),
value == null || value.isBlank() ? instance.type().label() : value);
});
propertiesContent.add(topic);
for (PropertyDefinition def : instance.type().properties()) {
propertiesContent.add(buildField(instance, def));
}
}
private Component buildField(BlockInstance instance, PropertyDefinition def) {
Object current = instance.get(def.id());
switch (def.type()) {
case TEXT -> {
TextField field = new TextField(def.label());
field.setWidthFull();
field.setValue(current == null ? "" : current.toString());
field.addValueChangeListener(e -> instance.set(def.id(), e.getValue()));
return field;
}
case TEXTAREA -> {
TextArea field = new TextArea(def.label());
field.setWidthFull();
field.setValue(current == null ? "" : current.toString());
field.addValueChangeListener(e -> instance.set(def.id(), e.getValue()));
return field;
}
case NUMBER -> {
IntegerField field = new IntegerField(def.label());
field.setWidthFull();
field.setStepButtonsVisible(true);
field.setValue(current instanceof Number n ? n.intValue() : 0);
field.addValueChangeListener(e -> instance.set(def.id(), e.getValue()));
return field;
}
case BOOLEAN -> {
Checkbox box = new Checkbox(def.label());
box.setValue(Boolean.TRUE.equals(current));
box.addValueChangeListener(e -> instance.set(def.id(), e.getValue()));
return box;
}
}
return new Span();
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
List<String> tokens = event.getLocation().getQueryParameters()
.getParameters().getOrDefault(IMPORT_PARAM, List.of());
if (tokens.isEmpty()) {
return;
}
importStore.consume(tokens.get(0)).ifPresent(this::loadPayload);
}
/** Baut den Canvas aus den per Deep-Link übergebenen Daten auf. */
private void loadPayload(TaskListPayload payload) {
List<TaskListImporter.ResolvedTask> resolved = taskListImporter.resolve(payload.getTasks());
if (resolved.isEmpty()) {
return;
}
currentListName = payload.getName();
JsonArray descriptors = Json.createArray();
for (int i = 0; i < resolved.size(); i++) {
TaskListImporter.ResolvedTask task = resolved.get(i);
JsonObject obj = Json.createObject();
obj.put("typeId", task.type().id());
obj.put("topic", task.topic());
descriptors.set(i, obj);
}
instances.clear();
showEmpty();
nodeEditor.importTasks(descriptors, idsJson -> applyImportedIds(idsJson, resolved));
}
private void applyImportedIds(String idsJson, List<TaskListImporter.ResolvedTask> resolved) {
int[] ids;
try {
ids = objectMapper.readValue(idsJson, int[].class);
} catch (Exception ex) {
Notification.show("Import fehlgeschlagen: " + ex.getMessage());
return;
}
int count = Math.min(ids.length, resolved.size());
for (int i = 0; i < count; i++) {
TaskListImporter.ResolvedTask task = resolved.get(i);
BlockInstance instance = new BlockInstance(ids[i], task.type());
instance.setTopic(task.topic());
task.values().forEach(instance::set);
instances.put(ids[i], instance);
}
Notification.show(count + " Task(s) aus Deep-Link geladen.");
}
// --- Canvas speichern / laden (MongoDB) ---
private void saveCanvas() {
if (instances.isEmpty()) {
Notification.show("Der Canvas ist leer nichts zu speichern.");
return;
}
nodeEditor.exportGraph(layout -> openSaveCanvasDialog(layout));
}
private void openSaveCanvasDialog(String layout) {
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Canvas speichern");
dialog.setWidth("440px");
TextField name = new TextField("Name");
name.setWidthFull();
name.setValue(currentListName != null && !currentListName.isBlank()
? currentListName
: "Canvas " + instances.size() + " Blöcke");
dialog.add(name);
Button save = new Button("Speichern", new Icon(VaadinIcon.HARDDRIVE), ev -> {
try {
String id = canvasService.save(name.getValue(), layout, collectBlocks());
currentListName = name.getValue();
Notification.show("Canvas gespeichert (ID: " + id + ")");
dialog.close();
} catch (Exception ex) {
Notification.show("Speichern fehlgeschlagen (läuft MongoDB?): " + ex.getMessage());
}
});
save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button cancel = new Button("Abbrechen", ev -> dialog.close());
cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
dialog.getFooter().add(cancel, save);
dialog.open();
}
private List<CanvasBlockState> collectBlocks() {
List<CanvasBlockState> blocks = new ArrayList<>();
instances.forEach((id, instance) -> blocks.add(new CanvasBlockState(
id,
instance.type().id(),
instance.topic(),
new LinkedHashMap<>(instance.values()))));
return blocks;
}
private void openLoadCanvasDialog() {
List<CanvasDocument> saved;
try {
saved = canvasService.listNewestFirst();
} catch (Exception ex) {
Notification.show("Laden fehlgeschlagen (läuft MongoDB?): " + ex.getMessage());
return;
}
if (saved.isEmpty()) {
Notification.show("Keine gespeicherten Canvas-Stände vorhanden.");
return;
}
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Canvas laden");
dialog.setWidth("560px");
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
.withZone(ZoneId.systemDefault());
Grid<CanvasDocument> grid = new Grid<>();
grid.addColumn(CanvasDocument::getName).setHeader("Name").setAutoWidth(true);
grid.addColumn(d -> d.getBlocks() == null ? 0 : d.getBlocks().size()).setHeader("Blöcke");
grid.addColumn(d -> d.getCreatedAt() == null ? "" : fmt.format(d.getCreatedAt()))
.setHeader("Gespeichert");
grid.setItems(saved);
grid.setHeight("320px");
dialog.add(grid);
Button load = new Button("Laden", new Icon(VaadinIcon.FOLDER_OPEN), ev -> {
CanvasDocument selected = grid.asSingleSelect().getValue();
if (selected == null) {
Notification.show("Bitte einen Eintrag auswählen.");
return;
}
loadCanvas(selected);
dialog.close();
});
load.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button cancel = new Button("Abbrechen", ev -> dialog.close());
cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
dialog.getFooter().add(cancel, load);
dialog.open();
}
private void loadCanvas(CanvasDocument document) {
instances.clear();
if (document.getBlocks() != null) {
for (CanvasBlockState block : document.getBlocks()) {
BlockType type = registry.find(block.getTypeId()).orElse(null);
if (type == null) {
continue;
}
BlockInstance instance = new BlockInstance(block.getNodeId(), type);
instance.setTopic(block.getTopic());
if (block.getValues() != null) {
block.getValues().forEach(instance::set);
}
instances.put(block.getNodeId(), instance);
}
}
currentListName = document.getName();
nodeEditor.importGraph(document.getLayout());
showEmpty();
Notification.show("Canvas „" + document.getName() + "“ geladen.");
}
private void exportTasks() {
if (instances.isEmpty()) {
Notification.show("Es sind keine Funktionsblöcke auf dem Canvas.");
return;
}
nodeEditor.exportGraph(graphJson -> {
try {
TaskListService.ExportResult result = taskListService.build(graphJson, instances);
if (result.tasks().isEmpty()) {
Notification.show("Keine Tasks gefunden.");
return;
}
openExportDialog(result, graphJson);
} catch (Exception ex) {
Notification.show("Export fehlgeschlagen: " + ex.getMessage());
}
});
}
private void openExportDialog(TaskListService.ExportResult result, String layout) {
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Task-JSON (" + result.tasks().size() + " Tasks)");
dialog.setWidth("680px");
TextField name = new TextField("Name der Task-Liste");
name.setWidthFull();
name.setValue(currentListName != null && !currentListName.isBlank()
? currentListName
: "Task-Liste (" + result.tasks().size() + " Tasks)");
TextArea json = new TextArea("App-JSON (tasks-Array)");
json.setWidthFull();
json.setHeight("360px");
json.setReadOnly(true);
json.setValue(result.json());
VerticalLayout content = new VerticalLayout(name, json);
content.setPadding(false);
content.setSpacing(true);
dialog.add(content);
Button save = new Button("In MongoDB speichern", new Icon(VaadinIcon.DATABASE), ev -> {
try {
String id = taskListService.save(name.getValue(), result.tasks(), layout);
Notification.show("In MongoDB gespeichert (ID: " + id + ")");
dialog.close();
} catch (Exception ex) {
Notification.show("Speichern fehlgeschlagen (läuft MongoDB?): " + ex.getMessage());
}
});
save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button close = new Button("Schließen", ev -> dialog.close());
close.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
dialog.getFooter().add(close, save);
dialog.open();
}
private void showEmpty() {
propertiesContent.removeAll();
propertiesContent.add(propertiesEmpty);
}
}

View File

@@ -0,0 +1,21 @@
server.port=8080
vaadin.launch-browser=true
vaadin.allowed-packages=de.assecutor.tasklisteditor
management.endpoints.web.exposure.include=health,info
management.endpoint.health.probes.enabled=true
management.health.livenessstate.enabled=true
management.health.readinessstate.enabled=true
logging.level.org.atmosphere=WARN
# MongoDB Speicherung der erzeugten Task-Listen.
# Zugangsdaten werden aus der .env-Datei geladen (spring-dotenv).
# Verbindung wird erst beim ersten Speichern aufgebaut, d.h. die Anwendung
# startet auch ohne laufende MongoDB.
spring.data.mongodb.uri=${MONGODB_URI:mongodb://localhost:27017}
spring.data.mongodb.database=${MONGODB_DATABASE:tasklist_editor}
# Health-Check soll nicht fehlschlagen, wenn keine MongoDB läuft.
management.health.mongo.enabled=false

40
tsconfig.json Normal file
View File

@@ -0,0 +1,40 @@
// This TypeScript configuration file is generated by vaadin-maven-plugin.
// This is needed for TypeScript compiler to compile your TypeScript code in the project.
// It is recommended to commit this file to the VCS.
// You might want to change the configurations to fit your preferences
// For more information about the configurations, please refer to http://www.typescriptlang.org/docs/handbook/tsconfig-json.html
{
"_version": "9.1",
"compilerOptions": {
"sourceMap": true,
"jsx": "react-jsx",
"inlineSources": true,
"module": "esNext",
"target": "es2020",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"ignoreDeprecations": "5.0",
"baseUrl": "src/main/frontend",
"paths": {
"@vaadin/flow-frontend": ["generated/jar-resources"],
"@vaadin/flow-frontend/*": ["generated/jar-resources/*"],
"Frontend/*": ["*"]
}
},
"include": [
"src/main/frontend/**/*",
"types.d.ts"
],
"exclude": [
"src/main/frontend/generated/jar-resources/**"
]
}

17
types.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
// This TypeScript modules definition file is generated by vaadin-maven-plugin.
// You can not directly import your different static files into TypeScript,
// This is needed for TypeScript compiler to declare and export as a TypeScript module.
// It is recommended to commit this file to the VCS.
// You might want to change the configurations to fit your preferences
declare module '*.css?inline' {
import type { CSSResultGroup } from 'lit';
const content: CSSResultGroup;
export default content;
}
// Allow any CSS Custom Properties
declare module 'csstype' {
interface Properties {
[index: `--${string}`]: any;
}
}

9
vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { UserConfigFn } from 'vite';
import { overrideVaadinConfig } from './vite.generated';
const customConfig: UserConfigFn = (env) => ({
// Here you can add custom Vite parameters
// https://vitejs.dev/config/
});
export default overrideVaadinConfig(customConfig);