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:
1 parent
675d7775c1
commit
5abcb2d96c
6 files changed
+600
-648
No files matched your search
+205
-648
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user