From 5abcb2d96c09bd0ef9b77b97c9e521fb037bbb5b Mon Sep 17 00:00:00 2001 From: Gerd Date: Sat, 19 Sep 2026 14:08:34 +0200 Subject: [PATCH] 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 --- App_Server/root/crm/model/__init__.py | 34 + App_Server/root/crm/model/checkdb.py | 11 + App_Server/root/crm/model/tag.py | 181 ++++++ App_Server/root/crm/routes.py | 61 ++ Client/Components/tags.ts | 853 +++++++------------------- Client/models/tag.ts | 108 ++++ 6 files changed, 600 insertions(+), 648 deletions(-) create mode 100644 App_Server/root/crm/model/tag.py create mode 100644 Client/models/tag.ts diff --git a/App_Server/root/crm/model/__init__.py b/App_Server/root/crm/model/__init__.py index 5f2216b..6c956e0 100644 --- a/App_Server/root/crm/model/__init__.py +++ b/App_Server/root/crm/model/__init__.py @@ -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") diff --git a/App_Server/root/crm/model/checkdb.py b/App_Server/root/crm/model/checkdb.py index f364a82..ff70b05 100644 --- a/App_Server/root/crm/model/checkdb.py +++ b/App_Server/root/crm/model/checkdb.py @@ -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 diff --git a/App_Server/root/crm/model/tag.py b/App_Server/root/crm/model/tag.py new file mode 100644 index 0000000..55c8ef1 --- /dev/null +++ b/App_Server/root/crm/model/tag.py @@ -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 +: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 diff --git a/App_Server/root/crm/routes.py b/App_Server/root/crm/routes.py index 1f18b15..f721c04 100644 --- a/App_Server/root/crm/routes.py +++ b/App_Server/root/crm/routes.py @@ -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") diff --git a/Client/Components/tags.ts b/Client/Components/tags.ts index bd30233..540eb81 100644 --- a/Client/Components/tags.ts +++ b/Client/Components/tags.ts @@ -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, -}; + } +} diff --git a/Client/models/tag.ts b/Client/models/tag.ts new file mode 100644 index 0000000..a8ebab0 --- /dev/null +++ b/Client/models/tag.ts @@ -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 { + const params: Record = {} + 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 { + 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 { + 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 { + 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 { + 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 { + return m.request({ + method: "DELETE", + url: TAG_REST_BASE + "/delete", + params: {id: id} + }).then(() => undefined) +}