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
+587 -635

No files matched your search

+34
View File
@@ -276,4 +276,38 @@ class CallLog(dbapp.Model):
return result 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") print("########## crm/model importiert")
+11
View File
@@ -221,6 +221,17 @@ def _db_V01_09():
session.commit() 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(): def checkupdates():
""" """
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
+181
View File
@@ -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
+61
View File
@@ -27,6 +27,7 @@ from crm.model import projekt as projekte
from crm.model import projekt_user as projekt_users from crm.model import projekt_user as projekt_users
from crm.model import kontaktadresse as kontaktadressen from crm.model import kontaktadresse as kontaktadressen
from crm.model import call_log as call_logs from crm.model import call_log as call_logs
from crm.model import tag as tags
print("crm-routes") print("crm-routes")
@@ -380,4 +381,64 @@ def calllog_delete():
return jsonify({}), 200 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") print("########## crm.routes importiert")
+192 -635
View File
@@ -1,7 +1,11 @@
import m from "mithril" import m from "mithril"
import {Form, iForm} from "./widgets/form" import {Form, iForm} from "./widgets/form"
import { Input, iInput} from "./widgets/inputs" 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[] = [ const colorPalette: string[] = [
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff", "#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 { function createTagTreenodes(hierarchy: iTagWithChildren[]): iTreenode[] {
id: number; // Numerische ID (für Kompatibilität mit bestehendem Code) return hierarchy.map((tag) => createTreenodeFromTag(tag))
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));
} }
/** /**
* Rekursive Funktion zum Erstellen eines Treenode aus einem Tag mit Kindern. * Rekursive Funktion zum Erstellen eines Treenode aus einem Tag mit Kindern.
*/ */
function createTreenodeFromTag(tag: iTagWithChildren): iTreenode { function createTreenodeFromTag(tag: iTagWithChildren): iTreenode {
const children = tag.children.map(child => createTreenodeFromTag(child)); const children = tag.children.map(child => createTreenodeFromTag(child))
return new Treenode(tag, true, children); 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 // KOMPONENTEN
// ============================================================================ // ============================================================================
export const Tag: m.ClosureComponent = (vnode) => { export const Tag: m.ClosureComponent = () => {
let currentTag: iTag = { let currentTag: iTag = {
id: -1, id: -1,
name: "", name: "",
description: "", description: "",
color: "", color: "",
parentId: null, parent_id: null,
is_parent: false, is_parent: false,
}; }
const setColor = (color: string) => { const setColor = (color: string) => {
currentTag.color = color; currentTag.color = color
}; }
// Liste aller Parent-Tags für die Eltern-Auswahl (nur Parent-Tags können übergeordnet sein) // Liste aller Parent-Tags fuer die Eltern-Auswahl (nur Parent-Tags
let allParentTags: iTag[] = []; // koennen uebergeordnet sein)
let allParentTags: iTag[] = []
return { return {
oninit: (vnode) => { oninit: (vnode) => {
const routeParams = m.route.param(); const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1; const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
if (id !== -1) { if (id !== -1) {
// Bestehenden Tag aus den Daten laden (frisch, nicht aus Route-Params) // Bestehenden Tag frisch vom Server laden
const tag = findTagById(id); fetchTagById(id).then((result) => {
if (tag) { currentTag = result
currentTag = {...tag}; }).catch((e) => showServerError(e))
}
} else if (routeParams) { } else if (routeParams) {
// Neuer Tag: vorbelegte Werte aus Route-Params übernehmen // Neuer Tag: vorbelegte Werte aus Route-Params uebernehmen
currentTag = { currentTag = {
id: -1, id: -1,
name: routeParams.name || "", name: routeParams.name || "",
description: routeParams.description || "", description: routeParams.description || "",
color: routeParams.color || "", 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, 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) => { 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", return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"), m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"),
m("div", {style: {flex: '1', 'min-height': '0'}}, m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [ m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text"} as iInput)), 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"} 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) // Parent-Tag-Umschalter: true = Gruppierungs-Knoten (nicht zuordenbar)
m("div", {style: {'margin-bottom': '5px'}}, m("div", {style: {'margin-bottom': '5px'}},
@@ -627,7 +150,7 @@ export const Tag: m.ClosureComponent = (vnode) => {
checked: currentTag.is_parent, checked: currentTag.is_parent,
style: {'margin-right': '8px'}, style: {'margin-right': '8px'},
onclick: (e: Event) => { 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" "Nur zur Gruppierung, kann nicht zugeordnet werden"
@@ -636,10 +159,10 @@ export const Tag: m.ClosureComponent = (vnode) => {
// Eltern-Tag-Auswahl // Eltern-Tag-Auswahl
m("label", "Übergeordneter Tag (optional)"), 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 --"), m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
allParentTags.map(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'}}, m("div", {style: {display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px'}},
colorPalette.map(color => colorPalette.map(color =>
m("button", { m("button", {
type: "button",
style: { style: {
width: '32px', width: '32px',
height: '32px', height: '32px',
@@ -661,36 +185,89 @@ export const Tag: m.ClosureComponent = (vnode) => {
), ),
]) ])
) )
); )
}
}
} }
};
};
export const TagList: m.ClosureComponent = (vnode) => { export const TagList: m.ClosureComponent = () => {
let treenodes: iTreenode[] = createTagTreenodes(); let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null; let selectedNode: iTreenode | null = null
// Aktualisiere treenodes, wenn sich die Daten ändern // Laedt die Hierarchie vom Server und erzeugt Treenodes
const updateTreenodes = () => { const loadTreenodes = () => {
treenodes = createTagTreenodes(); fetchTagHierarchy().then((hierarchy) => {
selectedNode = null; treenodes = createTagTreenodes(hierarchy)
}; selectedNode = null
}).catch((e) => showServerError(e))
}
const handleRowMarked = (node: iTreenode) => { const handleRowMarked = (node: iTreenode) => {
selectedNode = node; selectedNode = node
}; }
const editTag = (tag: iTag, e: Event) => { const editTag = (tag: iTag, e: Event) => {
e.stopPropagation(); e.stopPropagation()
m.route.set("/tag", {id: tag.id}); 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) => { 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 // Parent-Tags erhalten ein "folder"-Icon, echte Tags ein "label"-Icon
const tagIcon = tag.is_parent ? "folder" : "label"; const tagIcon = tag.is_parent ? "folder" : "label"
const tagTitle = tag.is_parent ? "Parent-Tag (nicht zuordenbar)" : ""; const tagTitle = tag.is_parent ? "Parent-Tag (nicht zuordenbar)" : ""
return [ return [
m("span", m("span",
@@ -716,47 +293,45 @@ export const TagList: m.ClosureComponent = (vnode) => {
}, tag.name) }, tag.name)
), ),
m("span", tag.description), m("span", tag.description),
m("div", m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}},
m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editTag(tag, e)}, 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("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 => { const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let tag: iTag = node.nodevalue; let tag: iTag = node.nodevalue
let searchLower = searchvalue.toLowerCase(); let searchLower = searchvalue.toLowerCase()
// Prüft, ob der Tag selbst auf den Suchbegriff passt (nur Name und Beschreibung) // Prueft, ob der Tag selbst auf den Suchbegriff passt (nur Name und Beschreibung)
const selfMatch = tag.name.toLowerCase().includes(searchLower) || const selfMatch = tag.name.toLowerCase().includes(searchLower) ||
tag.description.toLowerCase().includes(searchLower); (tag.description || "").toLowerCase().includes(searchLower)
// Wenn der Tag selbst passt, anzeigen // Wenn der Tag selbst passt, anzeigen
if (selfMatch) return true; if (selfMatch) return true
// Wenn der Tag Kinder hat, prüfen, ob ein Nachfahne passt // Wenn der Tag Kinder hat, pruefen, ob ein Nachfahre passt
if (searchLower === "") return true; if (searchLower === "") return true
const descendants = getDescendants(tag.id); return descendantMatches(node, searchLower)
return descendants.some((d) => }
d.name.toLowerCase().includes(searchLower) ||
d.description.toLowerCase().includes(searchLower)
);
};
return { return {
oninit: (vnode) => {
loadTreenodes()
},
view: (vnode) => { view: (vnode) => {
return m("div.w3-container", { return m("div.w3-container.w3-auto", {
style: { style: {
'max-width': '80%',
'margin-left': 'auto',
'margin-right': 'auto',
display: 'flex', display: 'flex',
'flex-direction': 'column', 'flex-direction': 'column',
height: 'inherit', height: 'inherit',
'overflow-y': 'inherit'}}, 'overflow-y': 'inherit'}},
m("h1", "Tags"), 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("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable, m(Treetable,
{ {
@@ -765,31 +340,13 @@ export const TagList: m.ClosureComponent = (vnode) => {
filter: filterFunction, filter: filterFunction,
onRowMarked: handleRowMarked, onRowMarked: handleRowMarked,
isSelected: (node: iTreenode) => node === selectedNode 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,
};
+108
View File
@@ -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)
}