feat(tags): Backend mit REST-API und Client-Anbindung fuer hierarchische Tags

- SQLAlchemy-Modell Tag mit Selbst-Referenz (parent_id), is_parent-Flag
- DB-Migration V01.10 (idempotent via create_all)
- Geschaeftslogik tag.py: CRUD, Hierarchie-Aufbau, kaskadierendes Loeschen,
  Zirkelbezug-Pruefung
- REST-Endpunkte /xhr/tag/*: Lesen fuer Mitarbeiter, Schreiben fuer AppAdmin
- Client-Modell tag.ts mit REST-Funktionen
- Frontend tags.ts auf REST-API umgestellt, hartcodierte Daten entfernt,
  Loesch-Dialog mit kaskadierender Bestaetigung

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
gg committed 2026-09-19 14:08:34 +02:00
1 parent 675d7775c1
commit 5abcb2d96c
6 files changed
+600 -648

No files matched your search

+205 -648
View File
@@ -1,7 +1,11 @@
import m from "mithril"
import {Form, iForm} from "./widgets/form"
import { Input, iInput} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable} from "./widgets/Treetable_V2"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {ModalType, ModalView, iModalView} from "./widgets/Modal"
import {gridMountpoints} from "./mainlayout"
import {showServerError} from "./auth/authmessages"
import {iTag, iTagWithChildren, fetchTags, fetchTagHierarchy, fetchTagById, createTagREST, updateTagREST, deleteTagREST} from "../models/tag"
const colorPalette: string[] = [
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
@@ -10,613 +14,132 @@ const colorPalette: string[] = [
]
// ============================================================================
// INTERFACES FÜR HIERARCHISCHE TAGS
// HILFSFUNKTIONEN
// ============================================================================
/**
* Basis-Tag-Interface für die hierarchische Struktur.
* Erzeugt Treenodes aus der vom Server geladenen Hierarchie.
* Beruecksichtigt die Verschachtelung der Tags.
*/
export interface iTag {
id: number; // Numerische ID (für Kompatibilität mit bestehendem Code)
name: string; // Anzeigename des Tags
description: string; // Beschreibung
color: string; // Farbcode (z. B. "#ff0000")
parentId: number | null; // Referenz auf den Eltern-Tag (null = Wurzelebene)
is_parent: boolean; // true = Gruppierungs-Knoten (nicht zuordenbar), false = echter Tag (zuordenbar)
icon?: string; // Optional: Icon-Name oder URL
order?: number; // Optional: Sortierreihenfolge innerhalb der Ebene
}
/**
* Tag mit verschachtelten Kindern für die Hierarchie-Darstellung.
*/
interface iTagWithChildren extends iTag {
children: iTagWithChildren[];
}
// ============================================================================
// BEISPIELDATEN (HIERARCHISCH)
// ============================================================================
/**
* Flache Liste aller Tags (für Datenbank-ähnliche Speicherung).
* Hierarchie wird über parentId abgebildet.
*/
const tagDaten: iTag[] = [
// ========== WURZEL-TAGS (Top-Level) ==========
{
id: 1,
name: "Wichtig",
description: "Hochpriorisierte Aufgaben",
color: "#ff0000",
parentId: null,
is_parent: false,
icon: "priority_high",
order: 1,
},
{
id: 2,
name: "Kunde",
description: "Kundenbezogene Tags",
color: "#00ff00",
parentId: null,
is_parent: false,
icon: "person",
order: 2,
},
{
id: 3,
name: "Projekt",
description: "Projektbezogene Tags",
color: "#0000ff",
parentId: null,
is_parent: false,
icon: "folder",
order: 3,
},
{
id: 4,
name: "Follow-up",
description: "Nachverfolgung erforderlich",
color: "#ffff00",
parentId: null,
is_parent: false,
icon: "schedule",
order: 4,
},
{
id: 5,
name: "Archiv",
description: "Archivierte Einträge",
color: "#808080",
parentId: null,
is_parent: false,
icon: "archive",
order: 5,
},
// ========== TECHNISCHE KATEGORIEN (neu) ==========
{
id: 100,
name: "Backend",
description: "Server-seitige Technologien und Frameworks",
color: "#4285F4",
parentId: null,
is_parent: true,
icon: "server",
order: 6,
},
{
id: 101,
name: "Frontend",
description: "Client-seitige Technologien und Frameworks",
color: "#34A853",
parentId: null,
is_parent: true,
icon: "browser",
order: 7,
},
{
id: 102,
name: "DevOps",
description: "Infrastruktur, Deployment und CI/CD",
color: "#EA4335",
parentId: null,
is_parent: true,
icon: "cog",
order: 8,
},
{
id: 103,
name: "Daten",
description: "Datenbanken, Analyse und KI",
color: "#FBBC04",
parentId: null,
is_parent: true,
icon: "database",
order: 9,
},
// Backend -> Programmiersprachen
{
id: 200,
name: "Python",
description: "Allgemeine Python-Entwicklung",
color: "#3776AB",
parentId: 100,
is_parent: true,
icon: "python",
order: 1,
},
{
id: 201,
name: "Java",
description: "Java und JVM-Ökosystem",
color: "#007396",
parentId: 100,
is_parent: false,
icon: "java",
order: 2,
},
{
id: 202,
name: "Node.js",
description: "JavaScript auf dem Server",
color: "#339933",
parentId: 100,
is_parent: true,
icon: "nodejs",
order: 3,
},
// Backend -> Python -> Frameworks
{
id: 300,
name: "Flask",
description: "Mikro-Webframework für Python",
color: "#000000",
parentId: 200,
is_parent: false,
icon: "flask",
order: 1,
},
{
id: 301,
name: "Django",
description: "Full-Stack Webframework für Python",
color: "#092E20",
parentId: 200,
is_parent: false,
icon: "django",
order: 2,
},
{
id: 302,
name: "FastAPI",
description: "Moderne API-Entwicklung mit Python",
color: "#005577",
parentId: 200,
is_parent: false,
icon: "fastapi",
order: 3,
},
// Backend -> Node.js -> Frameworks
{
id: 310,
name: "Express",
description: "Minimalistisches Webframework für Node.js",
color: "#000000",
parentId: 202,
is_parent: false,
icon: "express",
order: 1,
},
{
id: 311,
name: "NestJS",
description: "Progressives Node.js-Framework für Enterprise-Anwendungen",
color: "#E0234E",
parentId: 202,
is_parent: false,
icon: "nestjs",
order: 2,
},
// Backend -> Datenbanken
{
id: 210,
name: "Datenbanken",
description: "Backend-Datenbank-Systeme",
color: "#336791",
parentId: 100,
is_parent: true,
icon: "database",
order: 4,
},
{
id: 211,
name: "PostgreSQL",
description: "Relationale Datenbank mit erweiterten Features",
color: "#336791",
parentId: 210,
is_parent: false,
icon: "postgres",
order: 1,
},
{
id: 212,
name: "MongoDB",
description: "Dokumentenorientierte NoSQL-Datenbank",
color: "#47A248",
parentId: 210,
is_parent: false,
icon: "mongodb",
order: 2,
},
// Frontend -> JavaScript
{
id: 220,
name: "JavaScript",
description: "Die Hauptsprache des Web",
color: "#F7DF1E",
parentId: 101,
is_parent: true,
icon: "js",
order: 1,
},
{
id: 221,
name: "TypeScript",
description: "Typsicheres JavaScript",
color: "#3178C6",
parentId: 101,
is_parent: false,
icon: "ts",
order: 2,
},
// Frontend -> JavaScript -> Frameworks
{
id: 320,
name: "React",
description: "Komponentenbasiertes UI-Framework",
color: "#61DAFB",
parentId: 220,
is_parent: false,
icon: "react",
order: 1,
},
{
id: 321,
name: "Vue.js",
description: "Progressives JavaScript-Framework",
color: "#4FC08D",
parentId: 220,
is_parent: false,
icon: "vue",
order: 2,
},
{
id: 322,
name: "Angular",
description: "Full-Stack Framework von Google",
color: "#DD1B16",
parentId: 220,
is_parent: false,
icon: "angular",
order: 3,
},
// Frontend -> CSS
{
id: 230,
name: "CSS",
description: "Stildefinition für Webseiten",
color: "#1572B6",
parentId: 101,
is_parent: true,
icon: "css",
order: 3,
},
{
id: 330,
name: "Tailwind CSS",
description: "Utility-first CSS-Framework",
color: "#06B6D4",
parentId: 230,
is_parent: false,
icon: "tailwind",
order: 1,
},
// DevOps
{
id: 240,
name: "Docker",
description: "Containerisierung von Anwendungen",
color: "#2496ED",
parentId: 102,
is_parent: false,
icon: "docker",
order: 1,
},
{
id: 241,
name: "Kubernetes",
description: "Orchestrierung von Containern",
color: "#326CE5",
parentId: 102,
is_parent: false,
icon: "kubernetes",
order: 2,
},
{
id: 242,
name: "CI/CD",
description: "Continuous Integration und Deployment",
color: "#FFA500",
parentId: 102,
is_parent: false,
icon: "cicd",
order: 3,
},
// Daten
{
id: 250,
name: "Künstliche Intelligenz",
description: "Machine Learning und KI-Technologien",
color: "#8B00FF",
parentId: 103,
is_parent: false,
icon: "ai",
order: 1,
},
{
id: 251,
name: "Big Data",
description: "Verarbeitung großer Datenmengen",
color: "#FF6B6B",
parentId: 103,
is_parent: false,
icon: "bigdata",
order: 2,
},
];
// ============================================================================
// HILFSFUNKTIONEN FÜR HIERARCHISCHE TAGS
// ============================================================================
/**
* Gibt alle Wurzel-Tags (ohne Eltern) zurück.
*/
function getRootTags(): iTag[] {
return tagDaten.filter((tag) => tag.parentId === null);
}
/**
* Baut aus der flachen Liste eine hierarchische Struktur mit Kindern.
* @param parentId - ID des Eltern-Tags (null für Wurzelebene)
* @returns Array von Tags mit verschachtelten Kindern
*/
function buildHierarchy(parentId: number | null = null): iTagWithChildren[] {
const children = tagDaten.filter((tag) => tag.parentId === parentId);
// Sortieren nach order-Feld (falls vorhanden)
children.sort((a, b) => (a.order || 0) - (b.order || 0));
return children.map((tag) => ({
...tag,
children: buildHierarchy(tag.id),
}));
}
/**
* Gibt die vollständige Hierarchie als verschachteltes Objekt zurück.
*/
function getFullHierarchy(): iTagWithChildren[] {
return buildHierarchy(null);
}
/**
* Sucht einen Tag nach ID.
*/
function findTagById(id: number): iTag | undefined {
return tagDaten.find((tag) => tag.id === id);
}
/**
* Gibt den vollständigen Pfad eines Tags als String zurück (z. B. "/Backend/Python/Flask").
*/
function getTagPath(tagId: number): string {
const tag = findTagById(tagId);
if (!tag) return "";
if (tag.parentId === null) return `/${tag.name}`;
const parentPath = getTagPath(tag.parentId);
return `${parentPath}/${tag.name}`;
}
/**
* Gibt alle Nachfahren (Kinder, Enkel, ...) eines Tags zurück (flache Liste).
*/
function getDescendants(tagId: number): iTag[] {
const descendants: iTag[] = [];
const children = tagDaten.filter((tag) => tag.parentId === tagId);
for (const child of children) {
descendants.push(child);
descendants.push(...getDescendants(child.id));
}
return descendants;
}
/**
* Liefert alle echten (zuordenbaren) Tags, d. h. alle Tags, die keine
* Parent-Tags sind (is_parent === false).
*/
function getRealTags(): iTag[] {
return tagDaten.filter((tag) => !tag.is_parent);
}
/**
* Liefert alle echten (zuordenbaren) Tags, die Nachfahren eines Parent-Tags
* sind (flach, rekursiv). Parent-Tags selbst werden herausgefiltert.
*/
function getRealDescendants(parentId: number): iTag[] {
return getDescendants(parentId).filter((tag) => !tag.is_parent);
}
/**
* Löst eine Tag-Auswahl auf echte (zuordenbare) Tag-IDs auf.
* Wurde ein Parent-Tag ausgewählt, werden alle echten Nachfahren geliefert.
* Wurde ein echter Tag ausgewählt, wird dessen eigene ID geliefert.
* Rückgabe der Tag-IDs (für Filter/Auswertungen).
*/
function resolveToRealTagIds(tagId: number): number[] {
const tag = findTagById(tagId);
if (!tag) return [];
if (tag.is_parent) {
return getRealDescendants(tagId).map((t) => t.id);
}
return [tagId];
}
/**
* Prüft, ob ein Tag zuordenbar ist, d. h. ein echter Tag (kein Parent-Tag).
*/
function isAssignable(tagId: number): boolean {
const tag = findTagById(tagId);
return tag ? !tag.is_parent : false;
}
/**
* Gibt alle Vorfahren (Eltern, Großeltern, ...) eines Tags zurück.
*/
function getAncestors(tagId: number): iTag[] {
const ancestors: iTag[] = [];
const tag = findTagById(tagId);
if (!tag || tag.parentId === null) return ancestors;
const parent = findTagById(tag.parentId);
if (parent) {
ancestors.push(parent);
ancestors.push(...getAncestors(parent.id));
}
return ancestors;
}
/**
* Sucht Tags nach Namen (case-insensitive, partiell).
*/
function searchTagsByName(query: string): iTag[] {
const lowerQuery = query.toLowerCase();
return tagDaten.filter((tag) => tag.name.toLowerCase().includes(lowerQuery));
}
/**
* Fügt einen neuen Tag hinzu (mit automatischer ID-Vergabe).
*/
function addTag(name: string, parentId: number | null = null, description: string = "", color: string = "#808080", icon?: string, order?: number, is_parent: boolean = false): iTag {
const newId = tagDaten.length > 0 ? Math.max(...tagDaten.map(t => t.id)) + 1 : 1;
const tag: iTag = {
id: newId,
name,
description,
color,
parentId,
is_parent,
icon,
order,
};
tagDaten.push(tag);
return tag;
}
/**
* Ersetzt einen bestehenden Tag (z. B. nach Bearbeitung).
*/
function updateTag(updatedTag: iTag): void {
const index = tagDaten.findIndex(t => t.id === updatedTag.id);
if (index !== -1) {
tagDaten[index] = updatedTag;
}
}
// ============================================================================
// TREENODE-ERZEUGUNG (FÜR DIE TREETABLE)
// ============================================================================
/**
* Erzeugt Treenodes aus der hierarchischen Tag-Struktur.
* Berücksichtigt die Verschachtelung der Tags.
*/
function createTagTreenodes(): iTreenode[] {
const hierarchy = getFullHierarchy();
return hierarchy.map((tag) => createTreenodeFromTag(tag));
function createTagTreenodes(hierarchy: iTagWithChildren[]): iTreenode[] {
return hierarchy.map((tag) => createTreenodeFromTag(tag))
}
/**
* Rekursive Funktion zum Erstellen eines Treenode aus einem Tag mit Kindern.
*/
function createTreenodeFromTag(tag: iTagWithChildren): iTreenode {
const children = tag.children.map(child => createTreenodeFromTag(child));
return new Treenode(tag, true, children);
const children = tag.children.map(child => createTreenodeFromTag(child))
return new Treenode(tag, true, children)
}
/**
* Zaehlt alle Nachfahren eines Treenodes rekursiv (fuer die
* Loesch-Bestaetigungsmeldung).
*/
function countDescendants(node: iTreenode): number {
let count = 0
for (const child of node.children) {
count++
count += countDescendants(child)
}
return count
}
/**
* Prueft rekursiv, ob irgendein Nachfahre eines Treenodes auf den
* Suchbegriff passt (Name oder Beschreibung, case-insensitive).
*/
function descendantMatches(node: iTreenode, searchLower: string): boolean {
for (const child of node.children) {
const tag: iTag = child.nodevalue
if (tag.name.toLowerCase().includes(searchLower) ||
(tag.description || "").toLowerCase().includes(searchLower)) {
return true
}
if (descendantMatches(child, searchLower)) {
return true
}
}
return false
}
// ============================================================================
// KOMPONENTEN
// ============================================================================
export const Tag: m.ClosureComponent = (vnode) => {
export const Tag: m.ClosureComponent = () => {
let currentTag: iTag = {
id: -1,
name: "",
description: "",
color: "",
parentId: null,
parent_id: null,
is_parent: false,
};
}
const setColor = (color: string) => {
currentTag.color = color;
};
// Liste aller Parent-Tags für die Eltern-Auswahl (nur Parent-Tags können übergeordnet sein)
let allParentTags: iTag[] = [];
currentTag.color = color
}
// Liste aller Parent-Tags fuer die Eltern-Auswahl (nur Parent-Tags
// koennen uebergeordnet sein)
let allParentTags: iTag[] = []
return {
oninit: (vnode) => {
const routeParams = m.route.param();
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1;
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
if (id !== -1) {
// Bestehenden Tag aus den Daten laden (frisch, nicht aus Route-Params)
const tag = findTagById(id);
if (tag) {
currentTag = {...tag};
}
// Bestehenden Tag frisch vom Server laden
fetchTagById(id).then((result) => {
currentTag = result
}).catch((e) => showServerError(e))
} else if (routeParams) {
// Neuer Tag: vorbelegte Werte aus Route-Params übernehmen
// Neuer Tag: vorbelegte Werte aus Route-Params uebernehmen
currentTag = {
id: -1,
name: routeParams.name || "",
description: routeParams.description || "",
color: routeParams.color || "",
parentId: routeParams.parentId ? parseInt(routeParams.parentId) : null,
parent_id: routeParams.parent_id ? parseInt(routeParams.parent_id) : null,
is_parent: routeParams.is_parent ? routeParams.is_parent === "true" : false,
icon: routeParams.icon || undefined,
order: routeParams.order ? parseInt(routeParams.order) : undefined,
};
}
}
// Parent-Tag-Liste erst hier berechnen, da currentTag.id jetzt bekannt ist
allParentTags = tagDaten.filter(t => t.id !== currentTag.id && t.is_parent);
// Parent-Tag-Liste vom Server laden (nur is_parent-Tags sind
// als uebergeordnete Tags zulaessig). Der aktuelle Tag wird
// ausgeschlossen, damit er nicht sein eigener Parent wird.
fetchTags().then((result) => {
allParentTags = result.filter(t => t.id !== currentTag.id && t.is_parent)
}).catch((e) => showServerError(e))
},
view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
const gespeicherterTag: iTag = {
id: currentTag.id,
name: (formData.get("name") as string) || "",
description: (formData.get("description") as string) || "",
color: currentTag.color || "",
parent_id: formData.get("parent_id") ? parseInt(formData.get("parent_id") as string) : null,
is_parent: currentTag.is_parent,
}
if (currentTag.id === -1) {
createTagREST(gespeicherterTag).then(() => m.route.set("/taglist")).catch((e) => showServerError(e))
} else {
updateTagREST(gespeicherterTag).then(() => m.route.set("/taglist")).catch((e) => showServerError(e))
}
return true
}
const cancel = (e: Event) => {
m.route.set("/taglist")
}
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [
m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text"} as iInput)),
m("label", "Beschreibung", m(Input, {name: "description", value: currentTag.description, type: "text"} as iInput)),
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text", required: true, oninput: (e: Event) => { currentTag.name = (e.target as HTMLInputElement).value }} as iInput)),
m("label", "Beschreibung", m(Input, {name: "description", value: currentTag.description, type: "text", oninput: (e: Event) => { currentTag.description = (e.target as HTMLInputElement).value }} as iInput)),
// Parent-Tag-Umschalter: true = Gruppierungs-Knoten (nicht zuordenbar)
m("div", {style: {'margin-bottom': '5px'}},
@@ -627,7 +150,7 @@ export const Tag: m.ClosureComponent = (vnode) => {
checked: currentTag.is_parent,
style: {'margin-right': '8px'},
onclick: (e: Event) => {
currentTag.is_parent = (e.target as HTMLInputElement).checked;
currentTag.is_parent = (e.target as HTMLInputElement).checked
}
}),
"Nur zur Gruppierung, kann nicht zugeordnet werden"
@@ -636,10 +159,10 @@ export const Tag: m.ClosureComponent = (vnode) => {
// Eltern-Tag-Auswahl
m("label", "Übergeordneter Tag (optional)"),
m("select.w3-select", {name: "parentId", value: currentTag.parentId || ""},
m("select.w3-select", {name: "parent_id", value: currentTag.parent_id || ""},
m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
allParentTags.map(tag =>
m("option", {value: tag.id, selected: currentTag.parentId === tag.id}, tag.name)
m("option", {value: tag.id, selected: currentTag.parent_id === tag.id}, tag.name)
)
),
@@ -647,6 +170,7 @@ export const Tag: m.ClosureComponent = (vnode) => {
m("div", {style: {display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px'}},
colorPalette.map(color =>
m("button", {
type: "button",
style: {
width: '32px',
height: '32px',
@@ -661,36 +185,89 @@ export const Tag: m.ClosureComponent = (vnode) => {
),
])
)
);
)
}
};
};
}
}
export const TagList: m.ClosureComponent = () => {
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
// Laedt die Hierarchie vom Server und erzeugt Treenodes
const loadTreenodes = () => {
fetchTagHierarchy().then((hierarchy) => {
treenodes = createTagTreenodes(hierarchy)
selectedNode = null
}).catch((e) => showServerError(e))
}
export const TagList: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = createTagTreenodes();
let selectedNode: iTreenode | null = null;
// Aktualisiere treenodes, wenn sich die Daten ändern
const updateTreenodes = () => {
treenodes = createTagTreenodes();
selectedNode = null;
};
const handleRowMarked = (node: iTreenode) => {
selectedNode = node;
};
selectedNode = node
}
const editTag = (tag: iTag, e: Event) => {
e.stopPropagation();
m.route.set("/tag", {id: tag.id});
};
e.stopPropagation()
m.route.set("/tag", {id: tag.id})
}
const deleteTag = (tag: iTag, node: iTreenode, e: Event) => {
e.stopPropagation()
const childCount = countDescendants(node)
if (childCount === 0) {
// Keine Kinder: einfache Bestaetigung
if (window.confirm("Tag '" + tag.name + "' wirklich löschen?")) {
deleteTagREST(tag.id).then(() => {
loadTreenodes()
}).catch((e) => showServerError(e))
}
} else {
// Mit Kindern: Modal mit Hinweis auf kaskadierende Loeschung
m.mount(gridMountpoints.modalhook, {
view: () => {
return m(ModalView,
{
title: "Tag löschen",
type: ModalType.MESSAGE,
width: '40%'
} as iModalView,
m("div.w3-panel",
m("p", "Der Tag '" + tag.name + "' hat " + childCount + " untergeordnete Tags."),
m("p", "Soll er inkl. aller untergeordneten Tags gelöscht werden?")
),
m("div.w3-bar.w3-padding-16",
m("button.w3-button.w3-red.w3-left",
{
onclick: () => {
deleteTagREST(tag.id).then(() => {
m.mount(gridMountpoints.modalhook, null)
loadTreenodes()
}).catch((e) => showServerError(e))
}
},
"Inkl. aller löschen"
),
m("button.w3-button.w3-theme.w3-right",
{onclick: () => {m.mount(gridMountpoints.modalhook, null)}},
"Abbrechen"
)
)
)
}
})
}
}
const addTagRoute = () => {
m.route.set("/tag", {id: -1})
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let tag: iTag = node.nodevalue;
let tag: iTag = node.nodevalue
// Parent-Tags erhalten ein "folder"-Icon, echte Tags ein "label"-Icon
const tagIcon = tag.is_parent ? "folder" : "label";
const tagTitle = tag.is_parent ? "Parent-Tag (nicht zuordenbar)" : "";
const tagIcon = tag.is_parent ? "folder" : "label"
const tagTitle = tag.is_parent ? "Parent-Tag (nicht zuordenbar)" : ""
return [
m("span",
@@ -716,47 +293,45 @@ export const TagList: m.ClosureComponent = (vnode) => {
}, tag.name)
),
m("span", tag.description),
m("div",
m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editTag(tag, e)},
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}},
m("button.w3-button.w3-small", {title: "Tag bearbeiten", style: {padding: '0 2px'}, onclick: (e: Event) => editTag(tag, e)},
m("i.material-icons", "edit")
),
m("button.w3-button.w3-small", {title: "Tag löschen", style: {padding: '0 2px'}, onclick: (e: Event) => deleteTag(tag, node, e)},
m("i.material-icons", "delete")
)
)
];
};
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let tag: iTag = node.nodevalue;
let searchLower = searchvalue.toLowerCase();
]
}
// Prüft, ob der Tag selbst auf den Suchbegriff passt (nur Name und Beschreibung)
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let tag: iTag = node.nodevalue
let searchLower = searchvalue.toLowerCase()
// Prueft, ob der Tag selbst auf den Suchbegriff passt (nur Name und Beschreibung)
const selfMatch = tag.name.toLowerCase().includes(searchLower) ||
tag.description.toLowerCase().includes(searchLower);
(tag.description || "").toLowerCase().includes(searchLower)
// Wenn der Tag selbst passt, anzeigen
if (selfMatch) return true;
if (selfMatch) return true
// Wenn der Tag Kinder hat, pruefen, ob ein Nachfahre passt
if (searchLower === "") return true
return descendantMatches(node, searchLower)
}
// Wenn der Tag Kinder hat, prüfen, ob ein Nachfahne passt
if (searchLower === "") return true;
const descendants = getDescendants(tag.id);
return descendants.some((d) =>
d.name.toLowerCase().includes(searchLower) ||
d.description.toLowerCase().includes(searchLower)
);
};
return {
oninit: (vnode) => {
loadTreenodes()
},
view: (vnode) => {
return m("div.w3-container", {
return m("div.w3-container.w3-auto", {
style: {
'max-width': '80%',
'margin-left': 'auto',
'margin-right': 'auto',
display: 'flex',
'flex-direction': 'column',
height: 'inherit',
display: 'flex',
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", "Tags"),
m("p.w3-text-grey", "Hierarchie: Tags können beliebig verschachtelt werden (z. B. Backend → Python → Flask)."),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
@@ -765,31 +340,13 @@ export const TagList: m.ClosureComponent = (vnode) => {
filter: filterFunction,
onRowMarked: handleRowMarked,
isSelected: (node: iTreenode) => node === selectedNode
} as iTreetable
} as iTreetable,
m(TopbarButton, {onclick: addTagRoute, tooltip: "Neuen Tag anlegen"} as iTopbarButton,
m("i.material-icons", "add")
)
)
)
);
)
}
};
};
// ============================================================================
// EXPORTE FÜR EXTERNE NUTZUNG
// ============================================================================
export {
tagDaten as tags, // Flache Liste aller Tags
getFullHierarchy as hierarchy, // Vollständige hierarchische Struktur
findTagById,
getTagPath,
getDescendants,
getAncestors,
searchTagsByName,
addTag,
updateTag,
getRootTags,
getRealTags,
getRealDescendants,
resolveToRealTagIds,
isAssignable,
};
}
}