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
@@ -276,4 +276,38 @@ class CallLog(dbapp.Model):
|
||||
return result
|
||||
|
||||
|
||||
class Tag(dbapp.Model):
|
||||
"""
|
||||
Hierarchischer Tag (Taxonomie). Tags koennen beliebig verschachtelt
|
||||
werden ueber die Selbst-Referenz parent_id -> tags.id. Ein Tag mit
|
||||
is_parent=True ist ein reiner Gruppierungsknoten (nicht zuordenbar);
|
||||
is_parent=False kennzeichnet einen echten, zuordenbaren Tag.
|
||||
"""
|
||||
__tablename__ = 'tags'
|
||||
__bind_key__ = BINDKEY
|
||||
__table_args__ = (
|
||||
Index('ix_tags_parent_id', 'parent_id'),
|
||||
)
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(String(2000), nullable=True)
|
||||
color = Column(String(20), nullable=True)
|
||||
parent_id = Column(Integer, ForeignKey('tags.id'), nullable=True)
|
||||
is_parent = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Selbst-Referenzierende Relationship: parent verweist auf den
|
||||
# uebergeordneten Tag, children auf alle direkten Kinder. Die
|
||||
# Sortierung der Kinder erfolgt alphabetisch nach name.
|
||||
parent = relationship(
|
||||
'Tag', remote_side='Tag.id', back_populates='children'
|
||||
)
|
||||
children = relationship(
|
||||
'Tag', back_populates='parent', order_by='Tag.name'
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""Liefert die Tag-Daten als Dictionary (fuer JSON-Serialisierung)."""
|
||||
return columns2dict(self)
|
||||
|
||||
|
||||
print("########## crm/model importiert")
|
||||
@@ -221,6 +221,17 @@ def _db_V01_09():
|
||||
session.commit()
|
||||
|
||||
|
||||
def _db_V01_10():
|
||||
"""
|
||||
Legt die Tabelle 'tags' an. Tags sind eine hierarchische Taxonomie
|
||||
mit Selbst-Referenz (parent_id -> tags.id). create_all erzeugt nur
|
||||
fehlende Tabellen, bestehende bleiben unangetastet.
|
||||
"""
|
||||
dbapp.create_all(bind_key=BINDKEY)
|
||||
_updatesysteminfo('01.10')
|
||||
session.commit()
|
||||
|
||||
|
||||
def checkupdates():
|
||||
"""
|
||||
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Geschäftslogik für die Entität Tag (hierarchische Taxonomie).
|
||||
|
||||
Tags koennen beliebig verschachtelt werden (parent_id -> tags.id).
|
||||
Ein Tag mit is_parent=True ist ein Gruppierungsknoten und nicht
|
||||
zuordenbar; is_parent=False kennzeichnet einen echten, zuordenbaren Tag.
|
||||
|
||||
Tags sind nicht projektbezogen — die projektbezogene Sichtbarkeits-
|
||||
Filterung entfällt hier vollständig.
|
||||
|
||||
:author: Mistral Vibe <vibe@mistral.ai>
|
||||
:date: 2026-09-19
|
||||
:version: 1.0
|
||||
"""
|
||||
from db import dbapp
|
||||
from crm.model import Tag
|
||||
|
||||
from auth.model.errors import ApplicationError, HtmlStatusCode
|
||||
|
||||
session = dbapp.session
|
||||
|
||||
|
||||
def get_tags(parent_id: int | None = None) -> list[Tag]:
|
||||
"""
|
||||
Liefert Tags, sortiert nach name (alphabetisch).
|
||||
|
||||
Ist parent_id gesetzt, werden nur die direkten Kinder dieses
|
||||
Tags geliefert. Ohne parent_id werden alle Tags geliefert.
|
||||
"""
|
||||
query = Tag.query
|
||||
if parent_id is not None:
|
||||
query = query.filter(Tag.parent_id == parent_id)
|
||||
return query.order_by(Tag.name).all()
|
||||
|
||||
|
||||
def get_tag(tag_id: int) -> Tag:
|
||||
"""
|
||||
Liefert einen einzelnen Tag anhand seiner ID.
|
||||
Wirft ApplicationError (404), falls der Tag nicht existiert.
|
||||
"""
|
||||
tag = Tag.query.get(tag_id)
|
||||
if tag is None:
|
||||
raise ApplicationError(
|
||||
"Tag mit der ID {} wurde nicht gefunden.".format(tag_id),
|
||||
htmlstatus=HtmlStatusCode.Not_Found,
|
||||
)
|
||||
return tag
|
||||
|
||||
|
||||
def create_tag(data: dict) -> Tag:
|
||||
"""
|
||||
Legt einen neuen Tag an. Die vom Client übergebene ID wird ignoriert;
|
||||
die ID wird serverseitig per Auto-Increment vergeben.
|
||||
|
||||
- data: dict mit den Feldern name, description, color, parent_id,
|
||||
is_parent
|
||||
"""
|
||||
if not data.get("name") or not str(data["name"]).strip():
|
||||
raise ApplicationError(
|
||||
"Der Tag-Name darf nicht leer sein.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
parent_id = data.get("parent_id")
|
||||
if parent_id is not None:
|
||||
parent_id = int(parent_id)
|
||||
# Prüfen, ob der Eltern-Tag existiert
|
||||
parent = Tag.query.get(parent_id)
|
||||
if parent is None:
|
||||
raise ApplicationError(
|
||||
"Der übergeordnete Tag existiert nicht.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
tag = Tag()
|
||||
tag.name = data["name"].strip()
|
||||
tag.description = (data.get("description") or "").strip() or None
|
||||
tag.color = (data.get("color") or "").strip() or None
|
||||
tag.parent_id = parent_id
|
||||
tag.is_parent = bool(data.get("is_parent", False))
|
||||
session.add(tag)
|
||||
session.commit()
|
||||
return tag
|
||||
|
||||
|
||||
def update_tag(data: dict) -> Tag:
|
||||
"""
|
||||
Aktualisiert einen bestehenden Tag. Die ID im data-Dict bestimmt
|
||||
den zu ändernden Datensatz.
|
||||
|
||||
Verhindert Zirkelbezüge: Ein Tag kann nicht sein eigener Vorfahr
|
||||
als Eltern-Tag gesetzt werden.
|
||||
"""
|
||||
tag = get_tag(data["id"])
|
||||
if not data.get("name") or not str(data["name"]).strip():
|
||||
raise ApplicationError(
|
||||
"Der Tag-Name darf nicht leer sein.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
parent_id = data.get("parent_id")
|
||||
if parent_id is not None:
|
||||
parent_id = int(parent_id)
|
||||
# Prüfen, ob der Eltern-Tag existiert
|
||||
parent = Tag.query.get(parent_id)
|
||||
if parent is None:
|
||||
raise ApplicationError(
|
||||
"Der übergeordnete Tag existiert nicht.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
# Zirkelbezug-Prüfung: Der neue Eltern-Tag darf nicht ein
|
||||
# Nachfahre des zu ändernden Tags sein (sonst entsteht ein Zyklus).
|
||||
if parent_id == tag.id:
|
||||
raise ApplicationError(
|
||||
"Ein Tag kann nicht sich selbst als übergeordneten Tag haben.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
descendant_ids = {d.id for d in get_descendants(tag.id)}
|
||||
if parent_id in descendant_ids:
|
||||
raise ApplicationError(
|
||||
"Der übergeordnete Tag ist ein Nachfahre dieses Tags. "
|
||||
"Das würde einen Zirkelbezug erzeugen.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
tag.name = data["name"].strip()
|
||||
tag.description = (data.get("description") or "").strip() or None
|
||||
tag.color = (data.get("color") or "").strip() or None
|
||||
tag.parent_id = parent_id
|
||||
tag.is_parent = bool(data.get("is_parent", False))
|
||||
session.commit()
|
||||
return tag
|
||||
|
||||
|
||||
def delete_tag(tag_id: int):
|
||||
"""
|
||||
Löscht einen Tag inkl. aller Nachfahren (kaskadierend).
|
||||
|
||||
Die Nachfahren werden zuerst (bottom-up) gelöscht, damit der
|
||||
Fremdschlüssel auf parent_id nicht verletzt wird. Zuletzt wird
|
||||
der Tag selbst gelöscht.
|
||||
|
||||
Wirft ApplicationError (404), falls der Tag nicht existiert.
|
||||
"""
|
||||
tag = get_tag(tag_id)
|
||||
# Alle Nachfahren ermitteln und bottom-up löschen (Kinder vor Eltern)
|
||||
descendants = get_descendants(tag_id)
|
||||
for descendant in reversed(descendants):
|
||||
session.delete(descendant)
|
||||
session.delete(tag)
|
||||
session.commit()
|
||||
|
||||
|
||||
def get_descendants(tag_id: int) -> list[Tag]:
|
||||
"""
|
||||
Liefert alle Nachfahren (Kinder, Enkel, ...) eines Tags als flache
|
||||
Liste. Die Reihenfolge ist top-down (Eltern vor Kindern), was für
|
||||
die kaskadierende Löschung bottom-up (reversed) verwendet wird.
|
||||
"""
|
||||
descendants: list[Tag] = []
|
||||
children = Tag.query.filter(Tag.parent_id == tag_id).order_by(Tag.name).all()
|
||||
for child in children:
|
||||
descendants.append(child)
|
||||
descendants.extend(get_descendants(child.id))
|
||||
return descendants
|
||||
|
||||
|
||||
def get_tag_hierarchy() -> list[dict]:
|
||||
"""
|
||||
Liefert die vollständige Tag-Hierarchie als verschachtelte Struktur.
|
||||
|
||||
Wurzel-Tags (parent_id=None) bilden die oberste Ebene. Jeder Tag
|
||||
erhält ein 'children'-Array mit seinen rekursiv eingebetteten Kindern.
|
||||
Die Sortierung erfolgt alphabetisch nach name (durch die Relationship-
|
||||
Definition order_by='Tag.name').
|
||||
"""
|
||||
roots = Tag.query.filter(Tag.parent_id.is_(None)).order_by(Tag.name).all()
|
||||
return [_tag_to_dict_with_children(t) for t in roots]
|
||||
|
||||
|
||||
def _tag_to_dict_with_children(tag: Tag) -> dict:
|
||||
"""Wandelt einen Tag inkl. Kinder in ein verschachteltes Dict um."""
|
||||
result = tag.to_dict()
|
||||
result['children'] = [_tag_to_dict_with_children(c) for c in tag.children]
|
||||
return result
|
||||
@@ -27,6 +27,7 @@ from crm.model import projekt as projekte
|
||||
from crm.model import projekt_user as projekt_users
|
||||
from crm.model import kontaktadresse as kontaktadressen
|
||||
from crm.model import call_log as call_logs
|
||||
from crm.model import tag as tags
|
||||
|
||||
print("crm-routes")
|
||||
|
||||
@@ -380,4 +381,64 @@ def calllog_delete():
|
||||
return jsonify({}), 200
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tag (hierarchische Taxonomie) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tags sind nicht projektbezogen — die projektbezogene Sichtbarkeits-
|
||||
# Filterung entfaellt. Lesen: alle Mitarbeiter. Schreiben: nur AppAdmin/SysAdmin.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/xhr/tag/list")
|
||||
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
||||
def tag_list():
|
||||
"""Liefert alle Tags als flache Liste. Optional nach parent_id gefiltert."""
|
||||
parent_id = request.args.get("parent_id", type=int)
|
||||
result = [t.to_dict() for t in tags.get_tags(parent_id)]
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.get("/xhr/tag/hierarchy")
|
||||
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
||||
def tag_hierarchy():
|
||||
"""Liefert die vollstaendige Tag-Hierarchie als verschachtelte Struktur."""
|
||||
result = tags.get_tag_hierarchy()
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.get("/xhr/tag/get")
|
||||
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
||||
def tag_get():
|
||||
"""Liefert einen einzelnen Tag anhand seiner ID (Query-Param id)."""
|
||||
tag = tags.get_tag(int(request.args.get("id")))
|
||||
return jsonify(tag.to_dict()), 200
|
||||
|
||||
|
||||
@app.post("/xhr/tag/create")
|
||||
# Leeres allowed_roles-Array: Nur AppAdmin/SysAdmin (vom Decorator
|
||||
# implizit ergaenzt). Tags sind eine globale Taxonomie — die Anlage
|
||||
# und Aenderung ist ausschliesslich den Administratoren vorbehalten.
|
||||
@authorisation_required([])
|
||||
def tag_create():
|
||||
"""Legt einen neuen Tag an. ID wird serverseitig vergeben."""
|
||||
tag = tags.create_tag(request.json)
|
||||
return jsonify(tag.to_dict()), 200
|
||||
|
||||
|
||||
@app.put("/xhr/tag/update")
|
||||
@authorisation_required([])
|
||||
def tag_update():
|
||||
"""Aktualisiert einen bestehenden Tag."""
|
||||
tag = tags.update_tag(request.json)
|
||||
return jsonify(tag.to_dict()), 200
|
||||
|
||||
|
||||
@app.delete("/xhr/tag/delete")
|
||||
@authorisation_required([])
|
||||
def tag_delete():
|
||||
"""Loescht einen Tag inkl. aller Nachfahren (kaskadierend) anhand seiner ID."""
|
||||
tags.delete_tag(int(request.args.get("id")))
|
||||
return jsonify({}), 200
|
||||
|
||||
|
||||
print("########## crm.routes importiert")
|
||||
+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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import m from "mithril"
|
||||
|
||||
/**
|
||||
* Datenmodell für einen Tag (hierarchische Taxonomie).
|
||||
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
||||
* Tags koennen beliebig verschachtelt werden (parent_id -> tags.id).
|
||||
*/
|
||||
export interface iTag {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
color: string
|
||||
parent_id: number | null
|
||||
is_parent: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag mit verschachtelten Kindern für die Hierarchie-Darstellung.
|
||||
* Das children-Array wird serverseitig im Hierarchie-Endpunkt befüllt.
|
||||
*/
|
||||
export interface iTagWithChildren extends iTag {
|
||||
children: iTagWithChildren[]
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* REST-API-Aufrufe (Client-Seite) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Die folgenden Funktionen kapseln die Kommunikation mit den */
|
||||
/* serverseitigen REST-Endpunkten (/xhr/tag/*). */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Basis-Pfad der Tag-REST-Endpunkte.
|
||||
*/
|
||||
const TAG_REST_BASE = "/xhr/tag"
|
||||
|
||||
/**
|
||||
* Lädt alle Tags als flache Liste vom Server.
|
||||
* Ist parentId gesetzt, werden nur die direkten Kinder dieses Tags
|
||||
* geliefert (serverseitige Filterung).
|
||||
*/
|
||||
export function fetchTags(parentId?: number): Promise<iTag[]> {
|
||||
const params: Record<string, any> = {}
|
||||
if (parentId !== undefined) {
|
||||
params.parent_id = parentId
|
||||
}
|
||||
return m.request({
|
||||
method: "GET",
|
||||
url: TAG_REST_BASE + "/list",
|
||||
params: params
|
||||
}).then((result) => result as iTag[])
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die vollständige Tag-Hierarchie als verschachtelte Struktur vom Server.
|
||||
* Jeder Tag enthält ein children-Array mit seinen rekursiv eingebetteten Kindern.
|
||||
*/
|
||||
export function fetchTagHierarchy(): Promise<iTagWithChildren[]> {
|
||||
return m.request({
|
||||
method: "GET",
|
||||
url: TAG_REST_BASE + "/hierarchy"
|
||||
}).then((result) => result as iTagWithChildren[])
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt einen einzelnen Tag anhand seiner ID vom Server.
|
||||
*/
|
||||
export function fetchTagById(id: number): Promise<iTag> {
|
||||
return m.request({
|
||||
method: "GET",
|
||||
url: TAG_REST_BASE + "/get",
|
||||
params: {id: id}
|
||||
}).then((result) => result as iTag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen neuen Tag serverseitig an. Die ID wird vom Server
|
||||
* vergeben und im zurückgegebenen Tag-Objekt gesetzt.
|
||||
*/
|
||||
export function createTagREST(tag: iTag): Promise<iTag> {
|
||||
return m.request({
|
||||
method: "POST",
|
||||
url: TAG_REST_BASE + "/create",
|
||||
body: tag
|
||||
}).then((result) => result as iTag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert einen bestehenden Tag serverseitig.
|
||||
*/
|
||||
export function updateTagREST(tag: iTag): Promise<iTag> {
|
||||
return m.request({
|
||||
method: "PUT",
|
||||
url: TAG_REST_BASE + "/update",
|
||||
body: tag
|
||||
}).then((result) => result as iTag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht einen Tag inkl. aller Nachfahren serverseitig anhand seiner ID.
|
||||
*/
|
||||
export function deleteTagREST(id: number): Promise<void> {
|
||||
return m.request({
|
||||
method: "DELETE",
|
||||
url: TAG_REST_BASE + "/delete",
|
||||
params: {id: id}
|
||||
}).then(() => undefined)
|
||||
}
|
||||
Reference in new issue
Block a user