diff --git a/.env.example b/.env.example index 7bf9735..0206298 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,11 @@ MONGODB_COLLECTION=tasks_json # Collection, in der die Canvas-Stände (Layout + Werte) gespeichert werden: MONGODB_CANVAS_COLLECTION=canvas_layouts + +# Collection, in der die benutzerdefinierten Typen gespeichert werden: +MONGODB_CUSTOM_TYPE_COLLECTION=custom_types + +# Script-Block (grafisches Logik-Element) ausblenden: +# true = Script-Element wird im Editor nicht angezeigt +# false = Script-Element ist verfügbar (Standard) +HIDE_SCRIPT_BLOCK=false diff --git a/src/main/frontend/node-editor/node-editor.js b/src/main/frontend/node-editor/node-editor.js index ab17f33..65b4a80 100644 --- a/src/main/frontend/node-editor/node-editor.js +++ b/src/main/frontend/node-editor/node-editor.js @@ -282,9 +282,112 @@ function attach(host) { host.$server && host.$server.onNodeRemoved(parseInt(id, 10)); }); + // 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. + container.addEventListener('mousedown', e => { + if (e.button !== 0) return; + const cls = e.target instanceof Element ? e.target.classList : null; + const onBackground = cls && (cls.contains('parent-drawflow') || cls.contains('drawflow')); + if (!onBackground) return; // Klicks auf Knoten/Ports/Linien normal behandeln + const path = nearestConnectionPath(state, e.clientX, e.clientY, 12); + if (!path) return; + e.stopPropagation(); + selectConnection(state, path); + }, true); + + // Eine angeklickte Verbindung erhält den Fokus auf dem Canvas-Container, + // damit die Entf-/Backspace-Taste sie löschen kann. + editor.on('connectionSelected', () => { + container.focus({ preventScroll: true }); + }); + + // Absicherung: Verbindung auch dann löschen, wenn der Canvas-Container nicht + // den Tastaturfokus hat (Drawflows eigener Handler hängt am Container). + const deleteSelectedConnection = e => { + if (e.key !== 'Delete' && e.key !== 'Backspace') return; + if (editor.connection_selected == null) return; + const active = document.activeElement; + const tag = active && active.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' + || (active && active.isContentEditable)) { + return; // Eingaben in Textfeldern nicht abfangen + } + e.preventDefault(); + editor.removeConnection(); + }; + document.addEventListener('keydown', deleteSelectedConnection); + state.cleanup = () => document.removeEventListener('keydown', deleteSelectedConnection); + return state; } +// Wandelt Client-Koordinaten in das (unskalierte) Canvas-Koordinatensystem um, +// in dem auch die Verbindungspfade definiert sind. +function toCanvasPoint(state, clientX, clientY) { + const editor = state.editor; + const rect = state.container.getBoundingClientRect(); + const zoom = editor.zoom || 1; + return { + x: (clientX - rect.left - editor.canvas_x) / zoom, + y: (clientY - rect.top - editor.canvas_y) / zoom, + zoom + }; +} + +// Sucht den Verbindungspfad, der einem Klickpunkt am nächsten liegt (innerhalb +// der Toleranz). Nur fertige Verbindungen (Drawflow-Klasse node_in_node-). +function nearestConnectionPath(state, clientX, clientY, tolerancePx) { + const { x, y, zoom } = toCanvasPoint(state, clientX, clientY); + const tolerance = tolerancePx / zoom; + let best = null; + let bestDist = tolerance; + state.container + .querySelectorAll('.connection[class*="node_in_node-"] .main-path') + .forEach(path => { + let length; + try { length = path.getTotalLength(); } catch (e) { return; } + if (!length) return; + const step = Math.max(5, length / 80); + for (let d = 0; d <= length; d += step) { + const pt = path.getPointAtLength(d); + const dist = Math.hypot(pt.x - x, pt.y - y); + if (dist < bestDist) { + bestDist = dist; + best = path; + } + } + }); + return best; +} + +// Wählt eine Verbindung programmatisch aus (analog zu Drawflows interner +// main-path-Auswahl), damit sie hervorgehoben und per Entf löschbar ist. +function selectConnection(state, pathEl) { + const editor = state.editor; + if (editor.node_selected) { + editor.node_selected.classList.remove('selected'); + editor.node_selected = null; + editor.dispatch('nodeUnselected', true); + } + if (editor.connection_selected) { + editor.connection_selected.classList.remove('selected'); + } + editor.connection_selected = pathEl; + const parent = pathEl.parentElement; + parent.querySelectorAll('.main-path').forEach(p => p.classList.add('selected')); + const cl = parent.classList; + if (cl.length > 1) { + editor.dispatch('connectionSelected', { + output_id: cl[2].slice(14), + input_id: cl[1].slice(13), + output_class: cl[3], + input_class: cl[4] + }); + } + state.container.focus({ preventScroll: true }); +} + function addNodeInternal(host, def, x, y) { const state = editors.get(host); if (!state) return null; diff --git a/src/main/frontend/themes/tasklist-editor/styles.css b/src/main/frontend/themes/tasklist-editor/styles.css index 4d063d2..7ce7e8c 100644 --- a/src/main/frontend/themes/tasklist-editor/styles.css +++ b/src/main/frontend/themes/tasklist-editor/styles.css @@ -116,6 +116,9 @@ body, .tasklist-app { display: flex; flex-direction: column; gap: 2px; + /* Erlaubt dem Textblock, im Flex-Layout zu schrumpfen, statt zu überlaufen. */ + min-width: 0; + flex: 1; } .palette-item-label { @@ -127,6 +130,67 @@ body, .tasklist-app { .palette-item-desc { color: var(--tle-muted); font-size: var(--lumo-font-size-xs); + /* Lange Wörter umbrechen, damit der Text nicht in den Papierkorb läuft. */ + overflow-wrap: anywhere; +} + +/* Trenner zwischen festen und benutzerdefinierten Elementen. */ +.palette-divider { + display: flex; + align-items: center; + gap: var(--lumo-space-s); + margin: var(--lumo-space-m) 0 var(--lumo-space-s); + color: var(--tle-muted); + font-size: var(--lumo-font-size-xs); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.palette-divider::before, +.palette-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--tle-border); +} + +.palette-divider-label { + white-space: nowrap; + font-weight: 600; +} + +/* Markierung der benutzerdefinierten Elemente. */ +.palette-item-custom { + position: relative; + /* Rechts einen vertikalen Streifen für den Papierkorb reservieren, + damit der Text nicht hineinläuft und unten keine Extra-Zeile entsteht. */ + padding-right: calc(var(--lumo-space-m) + 26px); + border-style: dashed; + background: + linear-gradient(0deg, rgba(37, 99, 235, 0.14), rgba(37, 99, 235, 0.14)), + var(--tle-panel); +} + +.palette-item-delete { + position: absolute; + right: var(--lumo-space-s); + bottom: var(--lumo-space-s); + width: 18px; + height: 18px; + padding: 2px; + color: var(--tle-muted); + cursor: pointer; + opacity: 0.5; + transition: color 120ms ease, opacity 120ms ease, transform 120ms ease; +} + +.palette-item-custom:hover .palette-item-delete { + opacity: 1; +} + +.palette-item-delete:hover { + color: var(--lumo-error-color, #e53935); + transform: scale(1.15); } .node-editor-host { @@ -168,7 +232,11 @@ body, .tasklist-app { pointer-events: none; } -.task-list-editor .drawflow .connection[class*="node_in_node_"] .main-path { +/* Nur fertige Verbindungen sind anklickbar; die in Entstehung befindliche + Verbindung (ohne node_in_node--Klasse) bleibt unberührt, damit das + Verbinden per Drag nicht gestört wird. + Hinweis: Drawflow vergibt die Klasse mit Bindestrich (node_in_node-). */ +.task-list-editor .drawflow .connection[class*="node_in_node-"] .main-path { pointer-events: stroke; } @@ -291,12 +359,19 @@ body, .tasklist-app { .task-list-editor .drawflow .connection .main-path { stroke: #94a3b8; - stroke-width: 2.5px; + stroke-width: 4px; } .task-list-editor .drawflow .connection .main-path:hover { stroke: var(--tle-accent); - stroke-width: 3px; + stroke-width: 5px; + cursor: pointer; +} + +/* Ausgewählte Verbindung – hervorgehoben und als löschbar markiert. */ +.task-list-editor .drawflow .connection .main-path.selected { + stroke: var(--lumo-error-color, #e53935); + stroke-width: 5px; } .properties-content { diff --git a/src/main/java/de/assecutor/tasklisteditor/document/CustomTypeDocument.java b/src/main/java/de/assecutor/tasklisteditor/document/CustomTypeDocument.java new file mode 100644 index 0000000..a3a58ae --- /dev/null +++ b/src/main/java/de/assecutor/tasklisteditor/document/CustomTypeDocument.java @@ -0,0 +1,135 @@ +package de.assecutor.tasklisteditor.document; + +import de.assecutor.tasklisteditor.model.CustomPropertyState; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; +import java.util.List; + +/** + * In MongoDB gespeicherter benutzerdefinierter Typ. Enthält alle Daten, um den + * {@code BlockType} ohne Rückgriff auf den Basis-Typ wiederherstellen zu können + * (Aussehen, Ein-/Ausgänge und die Eigenschaften mit ihren erfassten Vorgaben). + */ +@Document(collection = "${MONGODB_CUSTOM_TYPE_COLLECTION:custom_types}") +public class CustomTypeDocument { + + @Id + private String id; + + /** Eindeutige Typnummer (>= 1000); dient zugleich als Schlüssel. */ + @Indexed(unique = true) + private int taskType; + + private String typeId; + private String label; + private String baseTypeId; + private String description; + private String icon; + private String color; + private int inputs; + private int outputs; + private List properties; + private Instant createdAt; + + public CustomTypeDocument() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public int getTaskType() { + return taskType; + } + + public void setTaskType(int taskType) { + this.taskType = taskType; + } + + public String getTypeId() { + return typeId; + } + + public void setTypeId(String typeId) { + this.typeId = typeId; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getBaseTypeId() { + return baseTypeId; + } + + public void setBaseTypeId(String baseTypeId) { + this.baseTypeId = baseTypeId; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } + + public int getInputs() { + return inputs; + } + + public void setInputs(int inputs) { + this.inputs = inputs; + } + + public int getOutputs() { + return outputs; + } + + public void setOutputs(int outputs) { + this.outputs = outputs; + } + + public List getProperties() { + return properties; + } + + public void setProperties(List properties) { + this.properties = properties; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java b/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java index efd8b8e..862cf6a 100644 --- a/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java +++ b/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java @@ -1,5 +1,6 @@ package de.assecutor.tasklisteditor.model; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.LinkedHashMap; @@ -21,14 +22,14 @@ public class BlockTypeRegistry { private final Map types = new LinkedHashMap<>(); - public BlockTypeRegistry() { + public BlockTypeRegistry(@Value("${tasklist.hide-script-block:false}") boolean hideScriptBlock) { register(new BlockType( "ankunft-melden", "Ankunft melden", "Erfasst die Ankunft am Einsatzort (Bestätigungsschaltfläche).", "vaadin:map-marker", "#2563eb", - 3, + 1, 1, 1, List.of( PropertyDefinition.bool("track", "GPS-Position erfassen", true), @@ -59,7 +60,7 @@ public class BlockTypeRegistry { "Nimmt eine Anzahl von Fotos auf.", "vaadin:camera", "#059669", - 4, + 3, 1, 1, List.of( PropertyDefinition.number("min", "Mindestanzahl Fotos", 1), @@ -69,78 +70,96 @@ public class BlockTypeRegistry { ) )); + // Fasst die früheren App-Typen Quittungsgeber, Bemerkung und + // Kommissionsnummer zu einem einzigen Texterfassungs-Task zusammen. register(new BlockType( - "quittungsgeber-erfassen", - "Quittungsgeber erfassen", - "Erfasst den Namen des Quittungsgebers und dessen Anrede aus einer Auswahlliste.", - "vaadin:user-check", + "text-erfassen", + "Text erfassen", + "Erfasst einen Text per Eingabefeld (z. B. Name, Bemerkung oder Nummer).", + "vaadin:input", "#dc2626", + 4, + 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) + ) + )); + + register(new BlockType( + "zahl-erfassen", + "Zahl erfassen", + "Erfasst einen numerischen Wert per Zahleneingabe.", + "vaadin:abacus", + "#0891b2", + 5, + 1, 1, + List.of( + PropertyDefinition.number("min", "Mindestwert", 0), + PropertyDefinition.number("max", "Maximalwert (0 = unbegrenzt)", 0), + PropertyDefinition.bool("opt", "Optionale Aufgabe", false), + PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false) + ) + )); + + register(new BlockType( + "checkbox", + "Checkbox", + "Einfache Bestätigung per Häkchen ohne weitere Eingabe.", + "vaadin:check-square-o", + "#16a34a", + 6, + 1, 1, + List.of( + PropertyDefinition.bool("opt", "Optionale Aufgabe", false), + PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false) + ) + )); + + register(new BlockType( + "barcode-scannen", + "Barcode scannen", + "Erfasst eine Liste gescannter Barcodes. (In der Flutter-App deaktiviert.)", + "vaadin:barcode", + "#0d9488", 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) ) )); + // Vereint den früheren "Status" und "Abhol-/Lieferstatus" der App + // zu einer Status-Auswahlliste auf Basis der choices-Struktur. register(new BlockType( - "stationshinweis", - "Stationshinweis", - "Lässt einen mehrzeiligen Hinweis zur Station erfassen.", - "vaadin:clipboard-text", - "#0891b2", + "statusauswahl", + "Statusauswahl", + "Lässt einen Status aus einer Auswahlliste wählen.", + "vaadin:list-select", + "#ea580c", 8, 1, 1, List.of( - PropertyDefinition.textArea("txt", "Vorgabetext / Hinweis"), + PropertyDefinition.textArea("choices", "Statusoptionen (eine pro Zeile)"), 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) - ) - )); - - register(new BlockType( - "script", - "Script", - "Kleines Logik-Snippet grafisch zusammenstellen (if/else, Vergleiche …).", - "vaadin:code", - "#475569", - 100, - 1, 0, - List.of() - )); + if (!hideScriptBlock) { + register(new BlockType( + "script", + "Script", + "Kleines Logik-Snippet grafisch zusammenstellen (if/else, Vergleiche …).", + "vaadin:code", + "#475569", + 0, + 1, 0, + List.of() + )); + } } private void register(BlockType type) { @@ -154,4 +173,78 @@ public class BlockTypeRegistry { public Optional find(String id) { return Optional.ofNullable(types.get(id)); } + + /** Die festen Standard-Typen (Typnummer 1–8), auf denen eigene Typen aufbauen. */ + public List baseTypes() { + return types.values().stream() + .filter(t -> t.taskType() >= 1 && t.taskType() < CUSTOM_TYPE_START) + .toList(); + } + + /** Startwert für benutzerdefinierte Typnummern. */ + public static final int CUSTOM_TYPE_START = 1000; + + /** Nächste freie Typnummer ab {@value #CUSTOM_TYPE_START}. */ + public synchronized int nextCustomTaskType() { + int next = CUSTOM_TYPE_START; + for (BlockType type : types.values()) { + if (type.taskType() >= next) { + next = type.taskType() + 1; + } + } + return next; + } + + /** + * Legt einen benutzerdefinierten Typ an. Er übernimmt Aussehen, Ein-/Ausgänge + * und Eigenschaftsstruktur des Basis-Typs, erhält aber eine eigene Typnummer + * ({@code >= 1000}) und eine eigene Bezeichnung. + * + * @param taskType die (freie) Typnummer + * @param base der zugrunde liegende Standard-Typ + * @param label Bezeichnung des neuen Typs + * @param properties Eigenschaften (i. d. R. die des Basis-Typs mit erfassten Vorgaben) + */ + public synchronized BlockType registerCustom(int taskType, BlockType base, String label, + List properties) { + String id = "custom-" + taskType; + BlockType custom = new BlockType( + id, + label, + base.description(), + base.icon(), + base.color(), + taskType, + base.inputs(), + base.outputs(), + List.copyOf(properties) + ); + register(custom); + return custom; + } + + /** + * Registriert einen bereits vollständig aufgebauten benutzerdefinierten Typ + * (z. B. aus MongoDB geladen). Bereits vorhandene Typen mit gleicher ID + * werden überschrieben. + */ + public synchronized BlockType registerCustom(BlockType custom) { + register(custom); + return custom; + } + + /** + * Entfernt einen benutzerdefinierten Typ (Typnummer {@code >= 1000}). + * Feste Standard-Typen werden nicht entfernt. + * + * @return {@code true}, wenn ein benutzerdefinierter Typ entfernt wurde + */ + public synchronized boolean removeCustom(String id) { + BlockType existing = types.get(id); + if (existing == null || existing.taskType() < CUSTOM_TYPE_START) { + return false; + } + types.remove(id); + return true; + } } diff --git a/src/main/java/de/assecutor/tasklisteditor/model/ChoiceDto.java b/src/main/java/de/assecutor/tasklisteditor/model/ChoiceDto.java new file mode 100644 index 0000000..6937c12 --- /dev/null +++ b/src/main/java/de/assecutor/tasklisteditor/model/ChoiceDto.java @@ -0,0 +1,82 @@ +package de.assecutor.tasklisteditor.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +/** + * Eine Auswahloption eines Status-Tasks (Typ 6). + * Entspricht {@code Choice} der stadtbote-App. + * + *

Die Feldnamen sind klein geschrieben; die App deserialisiert per + * Newtonsoft case-insensitiv (txt, ret, term, quest, action, price). + * {@code null}-Felder (quest, price) werden ausgelassen. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({"txt", "ret", "term", "quest", "action", "price"}) +public class ChoiceDto { + + private String txt; + private int ret; + private boolean term; + private String quest; + private String action; + private String price; + + public ChoiceDto() { + } + + public ChoiceDto(String txt, int ret) { + this.txt = txt; + this.ret = ret; + this.term = false; + this.action = "cont"; + } + + public String getTxt() { + return txt; + } + + public void setTxt(String txt) { + this.txt = txt; + } + + public int getRet() { + return ret; + } + + public void setRet(int ret) { + this.ret = ret; + } + + public boolean isTerm() { + return term; + } + + public void setTerm(boolean term) { + this.term = term; + } + + public String getQuest() { + return quest; + } + + public void setQuest(String quest) { + this.quest = quest; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getPrice() { + return price; + } + + public void setPrice(String price) { + this.price = price; + } +} diff --git a/src/main/java/de/assecutor/tasklisteditor/model/CustomPropertyState.java b/src/main/java/de/assecutor/tasklisteditor/model/CustomPropertyState.java new file mode 100644 index 0000000..4c5a5e5 --- /dev/null +++ b/src/main/java/de/assecutor/tasklisteditor/model/CustomPropertyState.java @@ -0,0 +1,64 @@ +package de.assecutor.tasklisteditor.model; + +/** + * In MongoDB ablegbarer Zustand einer {@link PropertyDefinition} eines + * benutzerdefinierten Typs (inkl. der erfassten Vorgabe). Beim Laden wird + * daraus wieder eine {@link PropertyDefinition} aufgebaut. + */ +public class CustomPropertyState { + + private String id; + private String label; + private PropertyType type; + private Object defaultValue; + + public CustomPropertyState() { + } + + public CustomPropertyState(String id, String label, PropertyType type, Object defaultValue) { + this.id = id; + this.label = label; + this.type = type; + this.defaultValue = defaultValue; + } + + public static CustomPropertyState of(PropertyDefinition def) { + return new CustomPropertyState(def.id(), def.label(), def.type(), def.defaultValue()); + } + + public PropertyDefinition toDefinition() { + return new PropertyDefinition(id, label, type, defaultValue); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public PropertyType getType() { + return type; + } + + public void setType(PropertyType type) { + this.type = type; + } + + public Object getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(Object defaultValue) { + this.defaultValue = defaultValue; + } +} diff --git a/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java b/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java index a9414d3..356a828 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", "script"}) + "max_len", "rt", "track", "txt", "task_arrival", "task_fin_work", "entries", "choices", "script"}) public class TaskDto { @JsonProperty("s_id") @@ -47,6 +47,9 @@ public class TaskDto { private List entries; + /** Auswahloptionen eines Status-Tasks (Typ 6). */ + private List choices; + /** Logik-Graph eines Script-Blocks (Drawflow-Definition als JSON). */ private JsonNode script; @@ -162,6 +165,14 @@ public class TaskDto { this.entries = entries; } + public List getChoices() { + return choices; + } + + public void setChoices(List choices) { + this.choices = choices; + } + public JsonNode getScript() { return script; } diff --git a/src/main/java/de/assecutor/tasklisteditor/repository/CustomTypeRepository.java b/src/main/java/de/assecutor/tasklisteditor/repository/CustomTypeRepository.java new file mode 100644 index 0000000..8af8430 --- /dev/null +++ b/src/main/java/de/assecutor/tasklisteditor/repository/CustomTypeRepository.java @@ -0,0 +1,16 @@ +package de.assecutor.tasklisteditor.repository; + +import de.assecutor.tasklisteditor.document.CustomTypeDocument; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.List; + +/** + * Spring-Data-Repository für die in MongoDB abgelegten benutzerdefinierten Typen. + */ +public interface CustomTypeRepository extends MongoRepository { + + List findAllByOrderByTaskTypeAsc(); + + long deleteByTaskType(int taskType); +} diff --git a/src/main/java/de/assecutor/tasklisteditor/service/CustomTypeService.java b/src/main/java/de/assecutor/tasklisteditor/service/CustomTypeService.java new file mode 100644 index 0000000..9944968 --- /dev/null +++ b/src/main/java/de/assecutor/tasklisteditor/service/CustomTypeService.java @@ -0,0 +1,72 @@ +package de.assecutor.tasklisteditor.service; + +import de.assecutor.tasklisteditor.document.CustomTypeDocument; +import de.assecutor.tasklisteditor.model.BlockType; +import de.assecutor.tasklisteditor.model.CustomPropertyState; +import de.assecutor.tasklisteditor.repository.CustomTypeRepository; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; + +/** + * Speichert benutzerdefinierte Typen in MongoDB und stellt sie als + * {@link BlockType} wieder her. + */ +@Service +public class CustomTypeService { + + private final CustomTypeRepository repository; + + public CustomTypeService(CustomTypeRepository repository) { + this.repository = repository; + } + + /** Persistiert einen benutzerdefinierten Typ mit allen Daten. */ + public String save(BlockType custom, String baseTypeId) { + CustomTypeDocument document = new CustomTypeDocument(); + document.setTaskType(custom.taskType()); + document.setTypeId(custom.id()); + document.setLabel(custom.label()); + document.setBaseTypeId(baseTypeId); + document.setDescription(custom.description()); + document.setIcon(custom.icon()); + document.setColor(custom.color()); + document.setInputs(custom.inputs()); + document.setOutputs(custom.outputs()); + document.setProperties(custom.properties().stream() + .map(CustomPropertyState::of) + .toList()); + document.setCreatedAt(Instant.now()); + return repository.save(document).getId(); + } + + /** Löscht den gespeicherten benutzerdefinierten Typ mit der angegebenen Typnummer. */ + public void deleteByTaskType(int taskType) { + repository.deleteByTaskType(taskType); + } + + /** Lädt alle gespeicherten Typen und baut sie als {@link BlockType} auf. */ + public List loadAll() { + return repository.findAllByOrderByTaskTypeAsc().stream() + .map(CustomTypeService::toBlockType) + .toList(); + } + + private static BlockType toBlockType(CustomTypeDocument doc) { + return new BlockType( + doc.getTypeId(), + doc.getLabel(), + doc.getDescription(), + doc.getIcon(), + doc.getColor(), + doc.getTaskType(), + doc.getInputs(), + doc.getOutputs(), + doc.getProperties() == null ? List.of() + : doc.getProperties().stream() + .map(CustomPropertyState::toDefinition) + .toList() + ); + } +} diff --git a/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java b/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java index aefb418..2169eb3 100644 --- a/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java +++ b/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java @@ -3,6 +3,7 @@ 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.ChoiceDto; import de.assecutor.tasklisteditor.model.TaskDto; import de.assecutor.tasklisteditor.model.TaskEntryDto; import org.springframework.stereotype.Component; @@ -146,6 +147,10 @@ public class TaskListExporter { task.setEntries(parseEntries(asText(v.get("entries")), queryRemark)); } + if (v.containsKey("choices")) { + task.setChoices(parseChoices(asText(v.get("choices")))); + } + if (v.containsKey("script")) { String script = asText(v.get("script")); if (script != null) { @@ -174,6 +179,21 @@ public class TaskListExporter { return entries.isEmpty() ? null : entries; } + private List parseChoices(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + List choices = new ArrayList<>(); + int ret = 1; + for (String line : raw.split("\\r?\\n")) { + String text = line.trim(); + if (!text.isEmpty()) { + choices.add(new ChoiceDto(text, ret++)); + } + } + return choices.isEmpty() ? null : choices; + } + 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 016a14e..078ef24 100644 --- a/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java +++ b/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java @@ -2,6 +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.PropertyDefinition; import de.assecutor.tasklisteditor.model.TaskDto; import de.assecutor.tasklisteditor.model.TaskEntryDto; @@ -73,6 +74,7 @@ public class TaskListImporter { case "txt" -> orDefault(task.getTxt(), def.defaultValue()); case "query_remark" -> anyQueryRemark(task); case "entries" -> entriesText(task); + case "choices" -> choicesText(task); default -> def.defaultValue(); }; values.put(def.id(), value); @@ -97,4 +99,15 @@ public class TaskListImporter { .map(TaskEntryDto::getText) .collect(Collectors.joining("\n")); } + + private String choicesText(TaskDto task) { + if (task.getChoices() != null && !task.getChoices().isEmpty()) { + return task.getChoices().stream() + .map(ChoiceDto::getTxt) + .collect(Collectors.joining("\n")); + } + // Altdaten des früheren Typ 11 (Abhol-/Lieferstatus) liefern die Optionen + // als entries; diese als choices-Text übernehmen. + return entriesText(task); + } } diff --git a/src/main/java/de/assecutor/tasklisteditor/service/TaskListService.java b/src/main/java/de/assecutor/tasklisteditor/service/TaskListService.java index 48fedfe..d025361 100644 --- a/src/main/java/de/assecutor/tasklisteditor/service/TaskListService.java +++ b/src/main/java/de/assecutor/tasklisteditor/service/TaskListService.java @@ -9,6 +9,7 @@ import de.assecutor.tasklisteditor.repository.TaskListRepository; import org.springframework.stereotype.Service; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -31,10 +32,17 @@ public class TaskListService { this.objectMapper = objectMapper; } + /** Task-Typ des Script-Blocks – wird von der stadtbote-App nicht verarbeitet. */ + private static final int SCRIPT_TASK_TYPE = 100; + /** Ergebnis eines Exports: typisierte Tasks plus formatiertes JSON. */ public record ExportResult(List tasks, String json) { } + /** Ergebnis eines App-Job-Exports: Anzahl Tasks plus formatiertes Job-JSON. */ + public record AppJobResult(int taskCount, String json) { + } + public ExportResult build(String drawflowJson, Map instances) { List tasks = exporter.export(drawflowJson, instances); try { @@ -45,6 +53,37 @@ public class TaskListService { } } + /** + * Baut aus dem Editor-Zustand das app-konforme Task-Array, wie es die + * stadtbote-App verarbeitet (entspricht der {@code tasks}-Liste einer Tour). + * + *

Script-Blöcke (Typ 100) werden ausgelassen, da die App sie nicht + * verarbeitet; anschließend werden die {@code s_id}-Werte lückenlos neu + * vergeben. + * + * @param name nicht verwendet (bleibt für künftige Erweiterungen erhalten) + */ + public AppJobResult buildAppJob(String name, String drawflowJson, + Map instances) { + List tasks = new ArrayList<>(); + int sId = 1; + for (TaskDto task : exporter.export(drawflowJson, instances)) { + if (task.getType() == SCRIPT_TASK_TYPE) { + continue; + } + task.setScript(null); + task.setSId(sId++); + tasks.add(task); + } + + try { + String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(tasks); + return new AppJobResult(tasks.size(), json); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("App-JSON konnte nicht erzeugt werden: " + ex.getMessage(), ex); + } + } + /** * Speichert die Task-Liste in MongoDB. * diff --git a/src/main/java/de/assecutor/tasklisteditor/view/MainView.java b/src/main/java/de/assecutor/tasklisteditor/view/MainView.java index 4f50c54..ff1685d 100644 --- a/src/main/java/de/assecutor/tasklisteditor/view/MainView.java +++ b/src/main/java/de/assecutor/tasklisteditor/view/MainView.java @@ -4,6 +4,7 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.grid.Grid; import com.vaadin.flow.component.html.Div; @@ -35,6 +36,7 @@ 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.CustomTypeService; import de.assecutor.tasklisteditor.service.PendingImportStore; import de.assecutor.tasklisteditor.service.TaskListImporter; import de.assecutor.tasklisteditor.service.TaskListService; @@ -69,9 +71,12 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { private final TaskListImporter taskListImporter; private final PendingImportStore importStore; private final CanvasService canvasService; + private final CustomTypeService customTypeService; private final ObjectMapper objectMapper; private final NodeEditor nodeEditor; private final Map instances = new HashMap<>(); + private Div palettePanel; + private Component paletteDivider; private String currentListName; public MainView(BlockTypeRegistry registry, @@ -79,18 +84,22 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { TaskListImporter taskListImporter, PendingImportStore importStore, CanvasService canvasService, + CustomTypeService customTypeService, ObjectMapper objectMapper) { this.registry = registry; this.taskListService = taskListService; this.taskListImporter = taskListImporter; this.importStore = importStore; this.canvasService = canvasService; + this.customTypeService = customTypeService; this.objectMapper = objectMapper; setSizeFull(); setPadding(false); setSpacing(false); addClassName("tasklist-app"); + loadCustomTypes(); + add(buildHeader()); HorizontalLayout body = new HorizontalLayout(); @@ -152,6 +161,10 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { loadCanvas.addThemeVariants(ButtonVariant.LUMO_TERTIARY); loadCanvas.addClickListener(e -> openLoadCanvasDialog()); + Button appJson = new Button("App-JSON (stadtbote)", new Icon(VaadinIcon.MOBILE)); + appJson.addThemeVariants(ButtonVariant.LUMO_SUCCESS); + appJson.addClickListener(e -> previewAppJson()); + Button export = new Button("JSON erzeugen & speichern", new Icon(VaadinIcon.DOWNLOAD)); export.addThemeVariants(ButtonVariant.LUMO_PRIMARY); export.addClickListener(e -> exportTasks()); @@ -159,13 +172,14 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { Span spacer = new Span(); spacer.getStyle().set("flex", "1"); - header.add(title, subtitle, spacer, clear, saveCanvas, loadCanvas, export); + header.add(title, subtitle, spacer, clear, saveCanvas, loadCanvas, appJson, export); return header; } private Div buildPalette() { Div panel = new Div(); panel.addClassName("palette-panel"); + palettePanel = panel; H3 heading = new H3("Funktionsblöcke"); heading.addClassName("panel-heading"); @@ -175,15 +189,162 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { hint.addClassName("panel-hint"); panel.add(hint); + Button customType = new Button("Eigenen Typ anlegen", new Icon(VaadinIcon.PLUS)); + customType.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_TERTIARY); + customType.addClassName("palette-add-type"); + customType.setWidthFull(); + customType.addClickListener(e -> openCustomTypeDialog()); + panel.add(customType); + for (BlockType type : registry.all()) { - panel.add(buildPaletteItem(type)); + if (isCustomType(type)) { + addCustomPaletteItem(type); + } else { + panel.add(buildPaletteItem(type)); + } } return panel; } + private boolean isCustomType(BlockType type) { + return type.taskType() >= BlockTypeRegistry.CUSTOM_TYPE_START; + } + + /** Fügt ein benutzerdefiniertes Element hinzu und blendet davor einmalig einen Trenner ein. */ + private void addCustomPaletteItem(BlockType type) { + if (paletteDivider == null) { + paletteDivider = buildPaletteDivider(); + palettePanel.add(paletteDivider); + } + palettePanel.add(buildPaletteItem(type)); + } + + private Component buildPaletteDivider() { + Div divider = new Div(); + divider.addClassName("palette-divider"); + Span label = new Span("Eigene Typen"); + label.addClassName("palette-divider-label"); + divider.add(label); + return divider; + } + + /** + * Lädt die in MongoDB gespeicherten benutzerdefinierten Typen und registriert + * sie, sofern sie noch nicht bekannt sind. Fehlt die Datenbank, startet der + * Editor trotzdem (nur ohne die gespeicherten Typen). + */ + private void loadCustomTypes() { + try { + for (BlockType custom : customTypeService.loadAll()) { + if (registry.find(custom.id()).isEmpty()) { + registry.registerCustom(custom); + } + } + } catch (Exception ex) { + Notification.show("Eigene Typen konnten nicht geladen werden (läuft MongoDB?): " + + ex.getMessage()); + } + } + + /** + * Dialog zum Anlegen eines benutzerdefinierten Typs: vergibt die nächste freie + * Typnummer ab 1000, lässt einen der festen Typen (1–8) als Basis wählen und + * die zugehörigen Daten erfassen. + */ + private void openCustomTypeDialog() { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("Eigenen Typ anlegen"); + dialog.setWidth("440px"); + + int number = registry.nextCustomTaskType(); + + IntegerField numberField = new IntegerField("Typnummer"); + numberField.setWidthFull(); + numberField.setValue(number); + numberField.setReadOnly(true); + numberField.setHelperText("Nächste freie Nummer ab 1000."); + + ComboBox baseBox = new ComboBox<>("Basis-Typ"); + baseBox.setWidthFull(); + baseBox.setItems(registry.baseTypes()); + baseBox.setItemLabelGenerator(t -> t.taskType() + " – " + t.label()); + baseBox.setHelperText("Fester Typ, auf dem der eigene Typ aufbaut."); + + TextField nameField = new TextField("Bezeichnung"); + nameField.setWidthFull(); + nameField.setRequiredIndicatorVisible(true); + + Div fields = new Div(); + fields.setWidthFull(); + + // Temporäre Instanz des Basis-Typs, in der die erfassten Vorgaben landen. + BlockInstance[] draft = new BlockInstance[1]; + + baseBox.addValueChangeListener(e -> { + fields.removeAll(); + BlockType base = e.getValue(); + if (base == null) { + draft[0] = null; + return; + } + nameField.setValue(base.label()); + BlockInstance temp = new BlockInstance(-1, base); + draft[0] = temp; + for (PropertyDefinition def : base.properties()) { + fields.add(buildField(temp, def)); + } + }); + + VerticalLayout content = new VerticalLayout(numberField, baseBox, nameField, fields); + content.setPadding(false); + content.setSpacing(true); + dialog.add(content); + + Button create = new Button("Anlegen", new Icon(VaadinIcon.CHECK), ev -> { + BlockType base = baseBox.getValue(); + if (base == null) { + Notification.show("Bitte einen Basis-Typ wählen."); + return; + } + String label = nameField.getValue(); + if (label == null || label.isBlank()) { + Notification.show("Bitte eine Bezeichnung angeben."); + return; + } + List props = new ArrayList<>(); + for (PropertyDefinition def : base.properties()) { + Object value = draft[0] == null ? null : draft[0].get(def.id()); + props.add(new PropertyDefinition(def.id(), def.label(), def.type(), + value == null ? def.defaultValue() : value)); + } + BlockType custom = registry.registerCustom(number, base, label.trim(), props); + try { + customTypeService.save(custom, base.id()); + } catch (Exception ex) { + Notification.show("Typ angelegt, aber nicht gespeichert (läuft MongoDB?): " + + ex.getMessage()); + } + addCustomPaletteItem(custom); + nodeEditor.registerBlockTypes(registry.all()); + Notification.show("Typ " + number + " „" + custom.label() + "“ angelegt."); + dialog.close(); + }); + create.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Button cancel = new Button("Abbrechen", ev -> dialog.close()); + cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + dialog.getFooter().add(cancel, create); + dialog.open(); + } + private Component buildPaletteItem(BlockType type) { + boolean custom = isCustomType(type); Div tile = new Div(); tile.addClassName("palette-item"); + if (custom) { + tile.addClassName("palette-item-custom"); + } tile.getElement().setAttribute("draggable", "true"); tile.getElement().setAttribute("data-blocktype", type.id()); tile.getElement().getStyle().set("--block-color", type.color()); @@ -202,9 +363,60 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { text.add(label, desc); tile.add(iconWrap, text); + + if (custom) { + Icon trash = new Icon(VaadinIcon.TRASH); + trash.addClassName("palette-item-delete"); + trash.getElement().setAttribute("title", "Typ löschen"); + trash.getElement().setAttribute("draggable", "false"); + trash.getElement().addEventListener("click", e -> confirmDeleteCustomType(type, tile)) + .stopPropagation(); + tile.add(trash); + } return tile; } + /** Sicherheitsabfrage vor dem Löschen eines benutzerdefinierten Typs. */ + private void confirmDeleteCustomType(BlockType type, Component tile) { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("Typ löschen"); + + dialog.add(new Paragraph("Soll der benutzerdefinierte Typ „" + type.label() + + "“ (Typ " + type.taskType() + ") wirklich gelöscht werden?")); + + Button delete = new Button("Löschen", new Icon(VaadinIcon.TRASH), ev -> { + deleteCustomType(type, tile); + dialog.close(); + }); + delete.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR); + + Button cancel = new Button("Abbrechen", ev -> dialog.close()); + cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + dialog.getFooter().add(cancel, delete); + dialog.open(); + } + + private void deleteCustomType(BlockType type, Component tile) { + if (!registry.removeCustom(type.id())) { + return; + } + try { + customTypeService.deleteByTaskType(type.taskType()); + } catch (Exception ex) { + Notification.show("Typ entfernt, aber nicht aus der DB gelöscht (läuft MongoDB?): " + + ex.getMessage()); + } + palettePanel.remove(tile); + // Trenner entfernen, wenn kein benutzerdefinierter Typ mehr vorhanden ist. + if (paletteDivider != null && registry.all().stream().noneMatch(this::isCustomType)) { + palettePanel.remove(paletteDivider); + paletteDivider = null; + } + nodeEditor.registerBlockTypes(registry.all()); + Notification.show("Typ " + type.taskType() + " „" + type.label() + "“ gelöscht."); + } + private VaadinIcon parseIcon(String key) { if (key == null || !key.startsWith("vaadin:")) return VaadinIcon.CIRCLE; try { @@ -733,4 +945,61 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { dialog.getFooter().add(close, save); dialog.open(); } + + /** + * Erzeugt das vollständige, von der stadtbote-App verarbeitbare Job-JSON + * (Job → Tour → Tasks) und zeigt es in einem Dialog an. + */ + private void previewAppJson() { + if (instances.isEmpty()) { + Notification.show("Es sind keine Funktionsblöcke auf dem Canvas."); + return; + } + nodeEditor.exportGraph(graphJson -> { + try { + TaskListService.AppJobResult result = + taskListService.buildAppJob(currentListName, graphJson, instances); + if (result.taskCount() == 0) { + Notification.show("Keine app-verarbeitbaren Tasks gefunden."); + return; + } + openAppJsonDialog(result); + } catch (Exception ex) { + Notification.show("App-JSON fehlgeschlagen: " + ex.getMessage()); + } + }); + } + + private void openAppJsonDialog(TaskListService.AppJobResult result) { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("App-JSON für stadtbote (" + result.taskCount() + " Tasks)"); + dialog.setWidth("720px"); + + Paragraph hint = new Paragraph("Task-Array, das von der stadtbote-App fehlerfrei " + + "verarbeitet wird. Script-Blöcke werden ausgelassen."); + hint.addClassName("properties-desc"); + + TextArea json = new TextArea("App-JSON (tasks-Array)"); + json.setWidthFull(); + json.setHeight("420px"); + json.setReadOnly(true); + json.setValue(result.json()); + + VerticalLayout content = new VerticalLayout(hint, json); + content.setPadding(false); + content.setSpacing(true); + dialog.add(content); + + Button copy = new Button("In Zwischenablage kopieren", new Icon(VaadinIcon.COPY), ev -> { + getElement().executeJs("navigator.clipboard.writeText($0)", result.json()); + Notification.show("JSON in die Zwischenablage kopiert."); + }); + copy.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + Button close = new Button("Schließen", ev -> dialog.close()); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + dialog.getFooter().add(close, copy); + dialog.open(); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 96a0ef4..bc1d543 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -16,6 +16,12 @@ logging.level.org.atmosphere=WARN # startet auch ohne laufende MongoDB. spring.data.mongodb.uri=${MONGODB_URI:mongodb://localhost:27017} spring.data.mongodb.database=${MONGODB_DATABASE:tasklist_editor} +# Legt die @Indexed-Indizes (u. a. eindeutige Typnummer) bei Bedarf an. +spring.data.mongodb.auto-index-creation=true # Health-Check soll nicht fehlschlagen, wenn keine MongoDB läuft. management.health.mongo.enabled=false + +# Script-Block (grafisches Logik-Element) im Editor ausblenden. +# Wird per .env (HIDE_SCRIPT_BLOCK) gesteuert. +tasklist.hide-script-block=${HIDE_SCRIPT_BLOCK:false}