diff --git a/.env.example b/.env.example
index 0206298..98430ee 100644
--- a/.env.example
+++ b/.env.example
@@ -19,6 +19,24 @@ MONGODB_CANVAS_COLLECTION=canvas_layouts
# Collection, in der die benutzerdefinierten Typen gespeichert werden:
MONGODB_CUSTOM_TYPE_COLLECTION=custom_types
+# --- Collections je Element-Typ ---------------------------------------------
+# Beim Speichern eines Canvas werden die Einstellungen jedes platzierten
+# Elements zusätzlich je Typ in eine eigene Collection geschrieben.
+# (Benutzerdefinierte Typen nutzen die Collection ihres Basistyps.)
+MONGODB_ELEMENT_ARRIVAL_COLLECTION=element_arrival
+MONGODB_ELEMENT_SIGNATURE_COLLECTION=element_signature
+MONGODB_ELEMENT_PHOTOS_COLLECTION=element_photos
+MONGODB_ELEMENT_RECEIPT_GIVER_COLLECTION=element_receipt_giver
+MONGODB_ELEMENT_REMARK_COLLECTION=element_remark
+MONGODB_ELEMENT_COMMISSION_NUMBER_COLLECTION=element_commission_number
+MONGODB_ELEMENT_CHECKBOX_COLLECTION=element_checkbox
+MONGODB_ELEMENT_BARCODE_COLLECTION=element_barcode
+MONGODB_ELEMENT_STATUS_SELECTION_COLLECTION=element_status_selection
+MONGODB_ELEMENT_TEXT_LIST_COLLECTION=element_text_list
+MONGODB_ELEMENT_NUMBER_LIST_COLLECTION=element_number_list
+MONGODB_ELEMENT_CHECKBOX_LIST_COLLECTION=element_checkbox_list
+MONGODB_ELEMENT_SCRIPT_COLLECTION=element_script
+
# Script-Block (grafisches Logik-Element) ausblenden:
# true = Script-Element wird im Editor nicht angezeigt
# false = Script-Element ist verfügbar (Standard)
diff --git a/src/main/frontend/node-editor/node-editor.js b/src/main/frontend/node-editor/node-editor.js
index 65b4a80..2957196 100644
--- a/src/main/frontend/node-editor/node-editor.js
+++ b/src/main/frontend/node-editor/node-editor.js
@@ -5,6 +5,11 @@ import 'drawflow/dist/drawflow.min.css';
// senkrecht in den Eingang des Ziels eintritt, bevor sie abbiegt.
const EXIT_STUB = 5;
+// Greif-Offset (in Bildschirm-Pixeln) zwischen Mauszeiger und linker oberer
+// Ecke des gezogenen Palette-Elements. Wird beim Drop abgezogen, damit der
+// Knoten an seiner gezogenen Position liegt statt mit der Ecke an der Maus.
+let dragGrabOffset = { x: 0, y: 0 };
+
function verticalCurvature(sx, sy, ex, ey) {
const dx = ex - sx;
const minOffset = 40;
@@ -183,6 +188,8 @@ if (!window.__taskListEditorDragInit) {
if (target && e.dataTransfer) {
e.dataTransfer.setData('application/x-blocktype', target.dataset.blocktype);
e.dataTransfer.effectAllowed = 'copy';
+ const r = target.getBoundingClientRect();
+ dragGrabOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
}
}, true);
}
@@ -251,8 +258,10 @@ function attach(host) {
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;
+ // Greif-Offset abziehen, damit der Knoten an seiner gezogenen Position
+ // liegt (linke obere Ecke wie während des Ziehens, nicht an der Maus).
+ const px = (e.clientX - rect.left - editor.canvas_x - dragGrabOffset.x) / zoom;
+ const py = (e.clientY - rect.top - editor.canvas_y - dragGrabOffset.y) / zoom;
addNodeInternal(host, def, px, py);
});
@@ -282,6 +291,40 @@ function attach(host) {
host.$server && host.$server.onNodeRemoved(parseInt(id, 10));
});
+ // Pro Ausgang ist nur eine Verbindung erlaubt. Hat ein Ausgang bereits eine
+ // Linie, wird Drawflows Verbindungs-Drag gar nicht erst gestartet: Das
+ // mousedown/touchstart auf dem Ausgangs-Punkt wird in der Capture-Phase
+ // abgefangen, bevor Drawflows eigener Handler ausgelöst wird.
+ const blockSecondConnectionStart = e => {
+ if (e.type === 'mousedown' && e.button !== 0) return;
+ const target = e.target;
+ if (!(target instanceof Element) || !target.classList.contains('output')) return;
+ const nodeEl = target.closest('.drawflow-node');
+ if (!nodeEl) return;
+ const id = parseInt(nodeEl.id.replace('node-', ''), 10);
+ const outputClass = Array.from(target.classList).find(c => c.startsWith('output_'));
+ if (!outputClass) return;
+ const node = editor.drawflow.drawflow[editor.module].data[id];
+ const output = node && node.outputs[outputClass];
+ if (output && output.connections.length >= 1) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ };
+ container.addEventListener('mousedown', blockSecondConnectionStart, true);
+ container.addEventListener('touchstart', blockSecondConnectionStart, true);
+
+ // Sicherheitsnetz: Sollte auf anderem Weg dennoch eine zweite Verbindung am
+ // selben Ausgang entstehen, wird die neu erstellte sofort wieder entfernt.
+ editor.on('connectionCreated', conn => {
+ const data = editor.drawflow.drawflow[editor.module].data;
+ const node = data[conn.output_id];
+ const output = node && node.outputs[conn.output_class];
+ if (output && output.connections.length > 1) {
+ editor.removeSingleConnection(conn.output_id, conn.input_id, conn.output_class, conn.input_class);
+ }
+ });
+
// Klick in die Nähe einer Verbindung (auf leerem Canvas-Hintergrund) wählt
// diese aus – robuster als das exakte Treffen der dünnen Linie. Läuft in der
// Capture-Phase vor Drawflow, um das Verschieben/Deselektieren zu verhindern.
@@ -296,6 +339,64 @@ function attach(host) {
selectConnection(state, path);
}, true);
+ // Verschieben von Knoten mit der RECHTEN Maustaste. Drawflow zieht Knoten
+ // nur mit links; das Ziehen mit rechts wird hier eigenständig umgesetzt.
+ let rightDrag = null;
+ let suppressContextMenu = false;
+ container.addEventListener('mousedown', e => {
+ if (e.button !== 2) return;
+ const nodeEl = e.target instanceof Element ? e.target.closest('.drawflow-node') : null;
+ if (!nodeEl) return;
+ e.preventDefault();
+ e.stopPropagation(); // Drawflows eigene (Links-)Drag-Logik nicht auslösen
+ rightDrag = {
+ nodeEl,
+ id: nodeEl.id.replace('node-', ''),
+ lastX: e.clientX,
+ lastY: e.clientY
+ };
+ suppressContextMenu = true;
+ }, true);
+
+ const onRightMove = e => {
+ if (!rightDrag) return;
+ const zoom = editor.zoom || 1;
+ const newLeft = rightDrag.nodeEl.offsetLeft + (e.clientX - rightDrag.lastX) / zoom;
+ const newTop = rightDrag.nodeEl.offsetTop + (e.clientY - rightDrag.lastY) / zoom;
+ rightDrag.lastX = e.clientX;
+ rightDrag.lastY = e.clientY;
+ rightDrag.nodeEl.style.left = newLeft + 'px';
+ rightDrag.nodeEl.style.top = newTop + 'px';
+ const data = editor.drawflow.drawflow[editor.module].data[rightDrag.id];
+ if (data) { data.pos_x = newLeft; data.pos_y = newTop; }
+ editor.updateConnectionNodes('node-' + rightDrag.id);
+ };
+ const onRightUp = () => { rightDrag = null; };
+ document.addEventListener('mousemove', onRightMove);
+ document.addEventListener('mouseup', onRightUp);
+
+ // Browser-Kontextmenü nach einem Rechts-Ziehen auf einem Knoten unterdrücken.
+ container.addEventListener('contextmenu', e => {
+ if (suppressContextMenu) {
+ e.preventDefault();
+ suppressContextMenu = false;
+ }
+ });
+
+ // Zoomen mit dem Mausrad (ohne Strg). Läuft in der Capture-Phase und stoppt
+ // die Propagation, damit Drawflows eigenes Strg+Rad-Zoom nicht zusätzlich
+ // auslöst.
+ const onWheelZoom = e => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.deltaY > 0) {
+ editor.zoom_out();
+ } else if (e.deltaY < 0) {
+ editor.zoom_in();
+ }
+ };
+ container.addEventListener('wheel', onWheelZoom, { capture: true, passive: false });
+
// Eine angeklickte Verbindung erhält den Fokus auf dem Canvas-Container,
// damit die Entf-/Backspace-Taste sie löschen kann.
editor.on('connectionSelected', () => {
@@ -317,7 +418,12 @@ function attach(host) {
editor.removeConnection();
};
document.addEventListener('keydown', deleteSelectedConnection);
- state.cleanup = () => document.removeEventListener('keydown', deleteSelectedConnection);
+ state.cleanup = () => {
+ document.removeEventListener('keydown', deleteSelectedConnection);
+ document.removeEventListener('mousemove', onRightMove);
+ document.removeEventListener('mouseup', onRightUp);
+ container.removeEventListener('wheel', onWheelZoom, { capture: true });
+ };
return state;
}
diff --git a/src/main/frontend/themes/tasklist-editor/styles.css b/src/main/frontend/themes/tasklist-editor/styles.css
index 7ce7e8c..6933995 100644
--- a/src/main/frontend/themes/tasklist-editor/styles.css
+++ b/src/main/frontend/themes/tasklist-editor/styles.css
@@ -286,6 +286,12 @@ body, .tasklist-app {
pointer-events: none;
}
+/* Virtuelle Workflow-Markierungen (Start/Abschluss) haben keine Einstellungen. */
+.type-start .task-node-gear,
+.type-abschluss .task-node-gear {
+ display: none;
+}
+
.task-node-header {
height: 6px;
width: 100%;
@@ -418,6 +424,13 @@ body, .tasklist-app {
margin: 0 0 var(--lumo-space-s);
}
+.properties-fieldlist-label {
+ color: var(--lumo-secondary-text-color);
+ font-size: var(--lumo-font-size-s);
+ font-weight: 500;
+ margin-bottom: var(--lumo-space-xs);
+}
+
/* --- Script-/Logik-Editor --- */
.script-editor,
.script-editor-container {
diff --git a/src/main/java/de/assecutor/tasklisteditor/config/ElementCollectionProperties.java b/src/main/java/de/assecutor/tasklisteditor/config/ElementCollectionProperties.java
new file mode 100644
index 0000000..d4699de
--- /dev/null
+++ b/src/main/java/de/assecutor/tasklisteditor/config/ElementCollectionProperties.java
@@ -0,0 +1,50 @@
+package de.assecutor.tasklisteditor.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Konfiguration der MongoDB-Collections, in denen die Einstellungen der
+ * einzelnen Element-Typen abgelegt werden.
+ *
+ *
Die Collection-Namen werden aus {@code application.properties} gebunden,
+ * deren Werte wiederum aus den {@code MONGODB_ELEMENT_*}-Variablen der
+ * {@code .env} stammen:
+ *
+ * tasklist.element-collections[text-liste-erfassen]=${MONGODB_ELEMENT_TEXT_LISTE_COLLECTION:element_text_liste_erfassen}
+ *
+ *
+ * Für jeden (festen wie benutzerdefinierten) Typ muss eine Zuordnung
+ * existieren; benutzerdefinierte Typen werden zuvor auf ihren statischen
+ * Basistyp aufgelöst. Fehlt ein Eintrag, ist das ein Konfigurationsfehler.
+ */
+@Component
+@ConfigurationProperties(prefix = "tasklist")
+public class ElementCollectionProperties {
+
+ /** Typ-ID → Collection-Name. */
+ private final Map elementCollections = new HashMap<>();
+
+ public Map getElementCollections() {
+ return elementCollections;
+ }
+
+ /**
+ * Liefert den Collection-Namen für die angegebene Typ-ID.
+ *
+ * @throws IllegalStateException wenn für die Typ-ID keine Zuordnung
+ * konfiguriert ist
+ */
+ public String collectionFor(String typeId) {
+ String configured = elementCollections.get(typeId);
+ if (configured == null || configured.isBlank()) {
+ throw new IllegalStateException(
+ "Keine Collection für Element-Typ '" + typeId + "' konfiguriert "
+ + "(tasklist.element-collections).");
+ }
+ return configured;
+ }
+}
diff --git a/src/main/java/de/assecutor/tasklisteditor/document/ElementSettingsDocument.java b/src/main/java/de/assecutor/tasklisteditor/document/ElementSettingsDocument.java
new file mode 100644
index 0000000..a6f0618
--- /dev/null
+++ b/src/main/java/de/assecutor/tasklisteditor/document/ElementSettingsDocument.java
@@ -0,0 +1,136 @@
+package de.assecutor.tasklisteditor.document;
+
+import org.springframework.data.annotation.Id;
+
+import java.time.Instant;
+import java.util.Map;
+
+/**
+ * Einstellungen eines einzelnen platzierten Elements (Block-Instanz) eines
+ * Canvas. Wird beim Speichern eines Canvas je Element-Typ in eine eigene
+ * MongoDB-Collection geschrieben (Collection-Name pro Typ konfigurierbar, siehe
+ * {@code tasklist.element-collections} bzw. die {@code MONGODB_ELEMENT_*}-
+ * Variablen in der {@code .env}).
+ *
+ * Die Verknüpfung zum Canvas erfolgt über {@link #canvasId}; {@link #nodeId}
+ * identifiziert den Knoten innerhalb des Canvas.
+ */
+public class ElementSettingsDocument {
+
+ @Id
+ private String id;
+
+ /** ID des zugehörigen {@link CanvasDocument}. */
+ private String canvasId;
+
+ /** Anzeigename des Canvas zum Zeitpunkt des Speicherns. */
+ private String canvasName;
+
+ /** Knoten-ID innerhalb des Canvas (Drawflow). */
+ private int nodeId;
+
+ /** Typ-ID des Elements (z. B. {@code text-liste-erfassen} oder {@code custom-1000}). */
+ private String typeId;
+
+ /**
+ * Bei benutzerdefinierten Typen die Typ-ID des statischen Basistyps, in
+ * dessen Collection das Dokument abgelegt wird; bei festen Typen {@code null}.
+ */
+ private String baseTypeId;
+
+ /** Bezeichnung des Tasks (entspricht {@code topic}). */
+ private String topic;
+
+ /** Erfasste Property-Werte des Elements. */
+ private Map values;
+
+ private Instant createdAt;
+
+ public ElementSettingsDocument() {
+ }
+
+ public ElementSettingsDocument(String canvasId, String canvasName, int nodeId,
+ String typeId, String baseTypeId, String topic,
+ Map values, Instant createdAt) {
+ this.canvasId = canvasId;
+ this.canvasName = canvasName;
+ this.nodeId = nodeId;
+ this.typeId = typeId;
+ this.baseTypeId = baseTypeId;
+ this.topic = topic;
+ this.values = values;
+ this.createdAt = createdAt;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getCanvasId() {
+ return canvasId;
+ }
+
+ public void setCanvasId(String canvasId) {
+ this.canvasId = canvasId;
+ }
+
+ public String getCanvasName() {
+ return canvasName;
+ }
+
+ public void setCanvasName(String canvasName) {
+ this.canvasName = canvasName;
+ }
+
+ 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 getBaseTypeId() {
+ return baseTypeId;
+ }
+
+ public void setBaseTypeId(String baseTypeId) {
+ this.baseTypeId = baseTypeId;
+ }
+
+ public String getTopic() {
+ return topic;
+ }
+
+ public void setTopic(String topic) {
+ this.topic = topic;
+ }
+
+ public Map getValues() {
+ return values;
+ }
+
+ public void setValues(Map values) {
+ this.values = values;
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(Instant createdAt) {
+ this.createdAt = createdAt;
+ }
+}
diff --git a/src/main/java/de/assecutor/tasklisteditor/model/BlockType.java b/src/main/java/de/assecutor/tasklisteditor/model/BlockType.java
index 9417868..2cb93ff 100644
--- a/src/main/java/de/assecutor/tasklisteditor/model/BlockType.java
+++ b/src/main/java/de/assecutor/tasklisteditor/model/BlockType.java
@@ -11,6 +11,22 @@ public record BlockType(
int taskType,
int inputs,
int outputs,
- List properties
+ List properties,
+ /** Verweis auf den statischen Basistyp (nur bei benutzerdefinierten Typen, sonst {@code null}). */
+ String baseTypeId
) {
+ /** Konstruktor für feste Standard-Typen ohne Basistyp-Verweis. */
+ public BlockType(String id, String label, String description, String icon, String color,
+ int taskType, int inputs, int outputs, List properties) {
+ this(id, label, description, icon, color, taskType, inputs, outputs, properties, null);
+ }
+
+ /**
+ * Rein virtuelle Blöcke (z. B. Start/Abschluss) dienen nur der
+ * Workflow-Darstellung im Canvas und werden nicht in die Task-Liste
+ * exportiert. Sie sind an einer negativen Typnummer erkennbar.
+ */
+ public boolean isVirtual() {
+ return taskType < 0;
+ }
}
diff --git a/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java b/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java
index 862cf6a..686a944 100644
--- a/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java
+++ b/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java
@@ -22,7 +22,39 @@ public class BlockTypeRegistry {
private final Map types = new LinkedHashMap<>();
+ /** Typnummer der virtuellen Start-Markierung (negativ = nicht exportiert). */
+ public static final int START_TASK_TYPE = -1;
+
+ /** Typnummer der virtuellen Abschluss-Markierung (negativ = nicht exportiert). */
+ public static final int END_TASK_TYPE = -2;
+
public BlockTypeRegistry(@Value("${tasklist.hide-script-block:false}") boolean hideScriptBlock) {
+ // Rein virtuelle Workflow-Markierungen: Start (nur Ausgang) und Abschluss
+ // (nur Eingang). Sie dienen ausschließlich der Darstellung eines
+ // vollständigen Ablaufs im Canvas und werden NICHT exportiert
+ // (negative Typnummer, siehe BlockType#isVirtual).
+ register(new BlockType(
+ "start",
+ "Start",
+ "Virtuelle Markierung für den Beginn des Workflows (wird nicht exportiert).",
+ "vaadin:play",
+ "#16a34a",
+ START_TASK_TYPE,
+ 0, 1,
+ List.of()
+ ));
+
+ register(new BlockType(
+ "abschluss",
+ "Abschluss",
+ "Virtuelle Markierung für das Ende des Workflows (wird nicht exportiert).",
+ "vaadin:flag-checkered",
+ "#b91c1c",
+ END_TASK_TYPE,
+ 1, 0,
+ List.of()
+ ));
+
register(new BlockType(
"ankunft-melden",
"Ankunft melden",
@@ -73,12 +105,12 @@ public class BlockTypeRegistry {
// Fasst die früheren App-Typen Quittungsgeber, Bemerkung und
// Kommissionsnummer zu einem einzigen Texterfassungs-Task zusammen.
register(new BlockType(
- "text-erfassen",
- "Text erfassen",
- "Erfasst einen Text per Eingabefeld (z. B. Name, Bemerkung oder Nummer).",
+ "quittungsgeber-erfassen",
+ "Quittungsgeber erfassen",
+ "Erfasst den Quittungsgeber per Eingabefeld.",
"vaadin:input",
"#dc2626",
- 4,
+ 7,
1, 1,
List.of(
PropertyDefinition.number("max_len", "Maximale Zeichenanzahl (0 = unbegrenzt)", 0),
@@ -88,16 +120,30 @@ public class BlockTypeRegistry {
));
register(new BlockType(
- "zahl-erfassen",
- "Zahl erfassen",
- "Erfasst einen numerischen Wert per Zahleneingabe.",
- "vaadin:abacus",
- "#0891b2",
- 5,
+ "bemerkung-erfassen",
+ "Bemerkung erfassen",
+ "Erfasst eine Bemerkung per Eingabefeld.",
+ "vaadin:input",
+ "#dc2626",
+ 8,
1, 1,
List.of(
- PropertyDefinition.number("min", "Mindestwert", 0),
- PropertyDefinition.number("max", "Maximalwert (0 = unbegrenzt)", 0),
+ PropertyDefinition.number("max_len", "Maximale Zeichenanzahl (0 = unbegrenzt)", 0),
+ PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
+ PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false)
+ )
+ ));
+
+ register(new BlockType(
+ "kommissionsnummer-erfassen",
+ "Kommissionsnummer erfassen",
+ "Erfasst eine Kommissionsnummer per Eingabefeld.",
+ "vaadin:abacus",
+ "#0891b2",
+ 9,
+ 1, 1,
+ List.of(
+ PropertyDefinition.number("max_len", "Maximale Zeichenanzahl (0 = unbegrenzt)", 0),
PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false)
)
@@ -123,7 +169,7 @@ public class BlockTypeRegistry {
"Erfasst eine Liste gescannter Barcodes. (In der Flutter-App deaktiviert.)",
"vaadin:barcode",
"#0d9488",
- 7,
+ 5,
1, 1,
List.of(
PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
@@ -139,7 +185,7 @@ public class BlockTypeRegistry {
"Lässt einen Status aus einer Auswahlliste wählen.",
"vaadin:list-select",
"#ea580c",
- 8,
+ 11,
1, 1,
List.of(
PropertyDefinition.textArea("choices", "Statusoptionen (eine pro Zeile)"),
@@ -148,6 +194,55 @@ public class BlockTypeRegistry {
)
));
+ // Listen-Tasks: erfassen mehrerer gleichartiger Werte. Die Anzahl wird
+ // analog zu "Fotos aufnehmen" über min/max gesteuert. Diese Typen haben
+ // (noch) keinen Handler in der stadtbote-App und liegen daher oberhalb
+ // des dort genutzten Bereichs (1–11).
+ register(new BlockType(
+ "text-liste-erfassen",
+ "Liste von Texten erfassen",
+ "Erfasst mehrere Texteinträge über vordefinierte Eingabefelder.",
+ "vaadin:lines-list",
+ "#4338ca",
+ 12,
+ 1, 1,
+ List.of(
+ PropertyDefinition.fieldList("fields", "Textfelder (Titel + Platzhalter)"),
+ PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
+ PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false)
+ )
+ ));
+
+ register(new BlockType(
+ "zahl-liste-erfassen",
+ "Liste von Zahlen erfassen",
+ "Erfasst mehrere numerische Werte über vordefinierte Eingabefelder.",
+ "vaadin:list-ol",
+ "#0369a1",
+ 13,
+ 1, 1,
+ List.of(
+ PropertyDefinition.fieldList("fields", "Zahlenfelder (Titel + Platzhalter)"),
+ PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
+ PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false)
+ )
+ ));
+
+ register(new BlockType(
+ "checkbox-liste-erfassen",
+ "Liste von Checkboxen erfassen",
+ "Lässt mehrere vordefinierte Optionen per Checkbox ankreuzen.",
+ "vaadin:tasks",
+ "#15803d",
+ 14,
+ 1, 1,
+ List.of(
+ PropertyDefinition.nameList("entries", "Name der Checkboxen"),
+ PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
+ PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false)
+ )
+ ));
+
if (!hideScriptBlock) {
register(new BlockType(
"script",
@@ -217,7 +312,8 @@ public class BlockTypeRegistry {
taskType,
base.inputs(),
base.outputs(),
- List.copyOf(properties)
+ List.copyOf(properties),
+ base.id()
);
register(custom);
return custom;
diff --git a/src/main/java/de/assecutor/tasklisteditor/model/FieldDto.java b/src/main/java/de/assecutor/tasklisteditor/model/FieldDto.java
new file mode 100644
index 0000000..e281107
--- /dev/null
+++ b/src/main/java/de/assecutor/tasklisteditor/model/FieldDto.java
@@ -0,0 +1,43 @@
+package de.assecutor.tasklisteditor.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+
+/**
+ * Ein einzelnes Eingabefeld eines Listen-Text-Tasks (Typ 12).
+ * Definiert Titel und Platzhalter eines vom Bearbeiter auszufüllenden
+ * Textfeldes.
+ *
+ * {@code null}-Felder werden ausgelassen.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({"title", "placeholder"})
+public class FieldDto {
+
+ private String title;
+ private String placeholder;
+
+ public FieldDto() {
+ }
+
+ public FieldDto(String title, String placeholder) {
+ this.title = title;
+ this.placeholder = placeholder;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String getPlaceholder() {
+ return placeholder;
+ }
+
+ public void setPlaceholder(String placeholder) {
+ this.placeholder = placeholder;
+ }
+}
diff --git a/src/main/java/de/assecutor/tasklisteditor/model/PropertyDefinition.java b/src/main/java/de/assecutor/tasklisteditor/model/PropertyDefinition.java
index 9e34313..ec7d72a 100644
--- a/src/main/java/de/assecutor/tasklisteditor/model/PropertyDefinition.java
+++ b/src/main/java/de/assecutor/tasklisteditor/model/PropertyDefinition.java
@@ -1,5 +1,7 @@
package de.assecutor.tasklisteditor.model;
+import java.util.List;
+
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, "");
@@ -16,4 +18,12 @@ public record PropertyDefinition(String id, String label, PropertyType type, Obj
public static PropertyDefinition bool(String id, String label, boolean defaultValue) {
return new PropertyDefinition(id, label, PropertyType.BOOLEAN, defaultValue);
}
+
+ public static PropertyDefinition fieldList(String id, String label) {
+ return new PropertyDefinition(id, label, PropertyType.FIELD_LIST, List.of());
+ }
+
+ public static PropertyDefinition nameList(String id, String label) {
+ return new PropertyDefinition(id, label, PropertyType.NAME_LIST, List.of());
+ }
}
diff --git a/src/main/java/de/assecutor/tasklisteditor/model/PropertyType.java b/src/main/java/de/assecutor/tasklisteditor/model/PropertyType.java
index 29f060f..7d99f81 100644
--- a/src/main/java/de/assecutor/tasklisteditor/model/PropertyType.java
+++ b/src/main/java/de/assecutor/tasklisteditor/model/PropertyType.java
@@ -4,5 +4,9 @@ public enum PropertyType {
TEXT,
TEXTAREA,
NUMBER,
- BOOLEAN
+ BOOLEAN,
+ /** Wiederholbare Liste aus Eingabefeldern mit Titel und Platzhalter. */
+ FIELD_LIST,
+ /** Wiederholbare Liste aus reinen Namens-Einträgen (ein Textfeld je Zeile). */
+ NAME_LIST
}
diff --git a/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java b/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java
index 356a828..176dac6 100644
--- a/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java
+++ b/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java
@@ -17,7 +17,7 @@ import java.util.List;
*/
@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", "choices", "script"})
+ "max_len", "rt", "track", "txt", "task_arrival", "task_fin_work", "entries", "choices", "fields", "script"})
public class TaskDto {
@JsonProperty("s_id")
@@ -50,6 +50,9 @@ public class TaskDto {
/** Auswahloptionen eines Status-Tasks (Typ 6). */
private List choices;
+ /** Eingabefelder eines Listen-Text-Tasks (Typ 12). */
+ private List fields;
+
/** Logik-Graph eines Script-Blocks (Drawflow-Definition als JSON). */
private JsonNode script;
@@ -173,6 +176,14 @@ public class TaskDto {
this.choices = choices;
}
+ public List getFields() {
+ return fields;
+ }
+
+ public void setFields(List fields) {
+ this.fields = fields;
+ }
+
public JsonNode getScript() {
return script;
}
diff --git a/src/main/java/de/assecutor/tasklisteditor/service/CustomTypeService.java b/src/main/java/de/assecutor/tasklisteditor/service/CustomTypeService.java
index 9944968..6529bba 100644
--- a/src/main/java/de/assecutor/tasklisteditor/service/CustomTypeService.java
+++ b/src/main/java/de/assecutor/tasklisteditor/service/CustomTypeService.java
@@ -65,8 +65,9 @@ public class CustomTypeService {
doc.getOutputs(),
doc.getProperties() == null ? List.of()
: doc.getProperties().stream()
- .map(CustomPropertyState::toDefinition)
- .toList()
+ .map(prop -> prop.toDefinition())
+ .toList(),
+ doc.getBaseTypeId()
);
}
}
diff --git a/src/main/java/de/assecutor/tasklisteditor/service/ElementSettingsService.java b/src/main/java/de/assecutor/tasklisteditor/service/ElementSettingsService.java
new file mode 100644
index 0000000..eb0ae0f
--- /dev/null
+++ b/src/main/java/de/assecutor/tasklisteditor/service/ElementSettingsService.java
@@ -0,0 +1,70 @@
+package de.assecutor.tasklisteditor.service;
+
+import de.assecutor.tasklisteditor.config.ElementCollectionProperties;
+import de.assecutor.tasklisteditor.document.ElementSettingsDocument;
+import de.assecutor.tasklisteditor.model.BlockInstance;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.stereotype.Service;
+
+import java.time.Instant;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+
+/**
+ * Speichert die Einstellungen der einzelnen platzierten Elemente eines Canvas
+ * jeweils in einer eigenen, pro Element-Typ konfigurierten MongoDB-Collection.
+ *
+ * Wird ergänzend zum {@link CanvasService} aufgerufen: Der Canvas-Stand
+ * (Layout + Block-Werte) bleibt unverändert im Canvas-Dokument; zusätzlich wird
+ * je Element ein {@link ElementSettingsDocument} in die Typ-Collection
+ * geschrieben und über die Canvas-ID verknüpft.
+ */
+@Service
+public class ElementSettingsService {
+
+ private final MongoTemplate mongoTemplate;
+ private final ElementCollectionProperties properties;
+
+ public ElementSettingsService(MongoTemplate mongoTemplate,
+ ElementCollectionProperties properties) {
+ this.mongoTemplate = mongoTemplate;
+ this.properties = properties;
+ }
+
+ /**
+ * Schreibt für jedes Element des Canvas ein Einstellungs-Dokument in die
+ * Collection seines Typs.
+ *
+ * @param canvasId ID des gespeicherten Canvas (Verknüpfung)
+ * @param canvasName Anzeigename des Canvas
+ * @param instances platzierte Block-Instanzen
+ */
+ public void saveForCanvas(String canvasId, String canvasName,
+ Collection instances) {
+ Instant now = Instant.now();
+ for (BlockInstance instance : instances) {
+ // Virtuelle Blöcke (Start/Abschluss) haben keine fachlichen
+ // Einstellungen und keine Collection – sie werden nicht persistiert.
+ if (instance.type().isVirtual()) {
+ continue;
+ }
+ String typeId = instance.type().id();
+ String baseTypeId = instance.type().baseTypeId();
+ // Benutzerdefinierte Typen landen in der Collection ihres statischen
+ // Basistyps; feste Typen (baseTypeId == null) in ihrer eigenen.
+ String collectionTypeId = (baseTypeId == null || baseTypeId.isBlank())
+ ? typeId
+ : baseTypeId;
+ ElementSettingsDocument document = new ElementSettingsDocument(
+ canvasId,
+ canvasName,
+ instance.id(),
+ typeId,
+ baseTypeId,
+ instance.topic(),
+ new LinkedHashMap<>(instance.values()),
+ now);
+ mongoTemplate.save(document, properties.collectionFor(collectionTypeId));
+ }
+ }
+}
diff --git a/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java b/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java
index 2169eb3..059527e 100644
--- a/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java
+++ b/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java
@@ -3,7 +3,9 @@ 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.BlockTypeRegistry;
import de.assecutor.tasklisteditor.model.ChoiceDto;
+import de.assecutor.tasklisteditor.model.FieldDto;
import de.assecutor.tasklisteditor.model.TaskDto;
import de.assecutor.tasklisteditor.model.TaskEntryDto;
import org.springframework.stereotype.Component;
@@ -47,6 +49,10 @@ public class TaskListExporter {
} catch (Exception ex) {
throw new IllegalStateException("Editor-Graph konnte nicht gelesen werden: " + ex.getMessage(), ex);
}
+ // Ein vollständiger Workflow muss mit einem Start- und einem Abschluss-
+ // Element gerahmt und beide müssen verdrahtet sein.
+ validateWorkflow(data, instances);
+
if (!data.isObject() || data.isEmpty()) {
return tasks;
}
@@ -59,6 +65,11 @@ public class TaskListExporter {
if (instance == null) {
continue;
}
+ // Rein virtuelle Blöcke (Start/Abschluss) sind nur Workflow-Markierungen
+ // und gehören nicht in die exportierte Task-Liste.
+ if (instance.type().isVirtual()) {
+ continue;
+ }
TaskDto task = toTask(instance);
task.setSId(sId++);
tasks.add(task);
@@ -66,6 +77,64 @@ public class TaskListExporter {
return tasks;
}
+ /**
+ * Stellt sicher, dass der Ablauf mit genau den virtuellen Markierungen
+ * gerahmt ist: Es muss mindestens ein Start- und ein Abschluss-Element
+ * geben und beide müssen mit dem Ablauf verbunden (verdrahtet) sein.
+ *
+ * @throws IllegalStateException mit einer für den Benutzer verständlichen
+ * Meldung, falls die Bedingung verletzt ist
+ */
+ private void validateWorkflow(JsonNode data, Map instances) {
+ List startNodes = new ArrayList<>();
+ List endNodes = new ArrayList<>();
+ for (Map.Entry entry : instances.entrySet()) {
+ int taskType = entry.getValue().type().taskType();
+ if (taskType == BlockTypeRegistry.START_TASK_TYPE) {
+ startNodes.add(entry.getKey());
+ } else if (taskType == BlockTypeRegistry.END_TASK_TYPE) {
+ endNodes.add(entry.getKey());
+ }
+ }
+
+ if (startNodes.isEmpty()) {
+ throw new IllegalStateException("Es muss ein Start-Element vorhanden sein.");
+ }
+ if (endNodes.isEmpty()) {
+ throw new IllegalStateException("Es muss ein Abschluss-Element vorhanden sein.");
+ }
+ for (int id : startNodes) {
+ if (!hasConnectedOutput(data, id)) {
+ throw new IllegalStateException("Das Start-Element muss mit dem Ablauf verbunden sein.");
+ }
+ }
+ for (int id : endNodes) {
+ if (!hasConnectedInput(data, id)) {
+ throw new IllegalStateException("Das Abschluss-Element muss mit dem Ablauf verbunden sein.");
+ }
+ }
+ }
+
+ private boolean hasConnectedOutput(JsonNode data, int nodeId) {
+ JsonNode outputs = data.path(String.valueOf(nodeId)).path("outputs");
+ for (JsonNode output : outputs) {
+ if (output.path("connections").size() > 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean hasConnectedInput(JsonNode data, int nodeId) {
+ JsonNode inputs = data.path(String.valueOf(nodeId)).path("inputs");
+ for (JsonNode input : inputs) {
+ if (input.path("connections").size() > 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private List determineOrder(JsonNode data) {
// Knoten einsammeln + eingehende Verbindungen zählen.
List nodeIds = new ArrayList<>();
@@ -144,13 +213,20 @@ public class TaskListExporter {
if (v.containsKey("entries")) {
boolean queryRemark = v.containsKey("query_remark") && asBool(v.get("query_remark"));
- task.setEntries(parseEntries(asText(v.get("entries")), queryRemark));
+ Object raw = v.get("entries");
+ task.setEntries(raw instanceof List> list
+ ? parseEntries(list, queryRemark)
+ : parseEntries(asText(raw), queryRemark));
}
if (v.containsKey("choices")) {
task.setChoices(parseChoices(asText(v.get("choices"))));
}
+ if (v.containsKey("fields")) {
+ task.setFields(parseFields(v.get("fields")));
+ }
+
if (v.containsKey("script")) {
String script = asText(v.get("script"));
if (script != null) {
@@ -179,6 +255,21 @@ public class TaskListExporter {
return entries.isEmpty() ? null : entries;
}
+ /** Variante für den Zeilen-Editor: Liste aus {@code {title}}-Einträgen. */
+ private List parseEntries(List> raw, boolean queryRemark) {
+ List entries = new ArrayList<>();
+ int code = 1;
+ for (Object o : raw) {
+ if (o instanceof Map, ?> m) {
+ String text = m.get("title") == null ? "" : m.get("title").toString().trim();
+ if (!text.isEmpty()) {
+ entries.add(new TaskEntryDto(code++, text, queryRemark));
+ }
+ }
+ }
+ return entries.isEmpty() ? null : entries;
+ }
+
private List parseChoices(String raw) {
if (raw == null || raw.isBlank()) {
return null;
@@ -194,6 +285,23 @@ public class TaskListExporter {
return choices.isEmpty() ? null : choices;
}
+ private List parseFields(Object raw) {
+ if (!(raw instanceof List> list) || list.isEmpty()) {
+ return null;
+ }
+ List fields = new ArrayList<>();
+ for (Object o : list) {
+ if (o instanceof Map, ?> m) {
+ String title = m.get("title") == null ? "" : m.get("title").toString().trim();
+ String placeholder = m.get("placeholder") == null ? "" : m.get("placeholder").toString().trim();
+ if (!title.isEmpty() || !placeholder.isEmpty()) {
+ fields.add(new FieldDto(title, placeholder));
+ }
+ }
+ }
+ return fields.isEmpty() ? null : fields;
+ }
+
private Boolean asBool(Object o) {
return o instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(o));
}
diff --git a/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java b/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java
index 078ef24..7ad6e75 100644
--- a/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java
+++ b/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java
@@ -2,7 +2,7 @@ package de.assecutor.tasklisteditor.service;
import de.assecutor.tasklisteditor.model.BlockType;
import de.assecutor.tasklisteditor.model.BlockTypeRegistry;
-import de.assecutor.tasklisteditor.model.ChoiceDto;
+import de.assecutor.tasklisteditor.model.FieldDto;
import de.assecutor.tasklisteditor.model.PropertyDefinition;
import de.assecutor.tasklisteditor.model.TaskDto;
import de.assecutor.tasklisteditor.model.TaskEntryDto;
@@ -63,32 +63,72 @@ public class TaskListImporter {
private Map valuesFor(BlockType type, TaskDto task) {
Map 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);
- case "choices" -> choicesText(task);
- default -> def.defaultValue();
+ // Listen-Eigenschaften (Typ 12–14) werden anhand ihres
+ // Property-Typs in die vom Zeilen-Editor erwartete Struktur
+ // (Liste aus {title}/{title,placeholder}-Maps) zurückgewandelt.
+ Object value = switch (def.type()) {
+ case FIELD_LIST -> fieldRows(task);
+ case NAME_LIST -> entryRows(task);
+ default -> scalarValue(def, task);
};
values.put(def.id(), value);
}
return values;
}
+ private Object scalarValue(PropertyDefinition def, TaskDto task) {
+ return 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);
+ case "choices" -> choicesText(task);
+ default -> def.defaultValue();
+ };
+ }
+
+ /** {@code fields}-Array (Typ 12/13) zurück in Zeilen mit Titel + Platzhalter. */
+ private List