From 6f86bc9808f0073d69eb3e585ddd042a9b0a3b50 Mon Sep 17 00:00:00 2001 From: Gerd Date: Sun, 20 Sep 2026 20:20:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(tag):=20Workflow-Felder=20f=C3=BCr=20Telef?= =?UTF-8?q?on-Akquise=20(is=5Ferledigend,=20Wiedervorlage)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tag-Modell um is_erledigend, loest_wiedervorlage_aus und wiedervorlage_abstand_tage erweitert. Migration V01_12 idempotent angelegt, Geschäftslogik mit Validierung, Client-Maske mit Controls und Workflow-Badges. Farb-Default Grau bei fehlender Farbe, parent_id im onchange direkt in currentTag übernehmen. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- App_Server/root/crm/model/__init__.py | 9 ++ App_Server/root/crm/model/checkdb.py | 32 +++++++ App_Server/root/crm/model/tag.py | 44 ++++++++++ Client/Components/tags.ts | 119 +++++++++++++++++++++++++- Client/models/tag.ts | 6 ++ 5 files changed, 206 insertions(+), 4 deletions(-) diff --git a/App_Server/root/crm/model/__init__.py b/App_Server/root/crm/model/__init__.py index b4a7a70..6b7eeaf 100644 --- a/App_Server/root/crm/model/__init__.py +++ b/App_Server/root/crm/model/__init__.py @@ -325,6 +325,15 @@ class Tag(dbapp.Model): color = Column(String(20), nullable=True) parent_id = Column(Integer, ForeignKey('tags.id'), nullable=True) is_parent = Column(Boolean, nullable=False, default=False) + # Felder für die Telefon-Akquise-Workflow-Logik: + # is_erledigend: True = Kontakt gilt als erledigt, wenn dieser Tag + # zugewiesen wird. + # loest_wiedervorlage_aus: True = Tag löst eine Wiedervorlage aus. + # wiedervorlage_abstand_tage: Tage bis zur Wiedervorlage (nur relevant, + # wenn loest_wiedervorlage_aus True ist). + is_erledigend = Column(Boolean, nullable=False, default=False) + loest_wiedervorlage_aus = Column(Boolean, nullable=False, default=False) + wiedervorlage_abstand_tage = Column(Integer, nullable=True) # Selbst-Referenzierende Relationship: parent verweist auf den # uebergeordneten Tag, children auf alle direkten Kinder. Die diff --git a/App_Server/root/crm/model/checkdb.py b/App_Server/root/crm/model/checkdb.py index 6d4199f..d4f1c0a 100644 --- a/App_Server/root/crm/model/checkdb.py +++ b/App_Server/root/crm/model/checkdb.py @@ -243,6 +243,38 @@ def _db_V01_11(): session.commit() +def _db_V01_12(): + """ + Fügt der Tabelle 'tags' die drei neuen Spalten für die + Telefon-Akquise-Workflow-Logik hinzu: + - is_erledigend (BOOLEAN NOT NULL DEFAULT 0) + - loest_wiedervorlage_aus (BOOLEAN NOT NULL DEFAULT 0) + - wiedervorlage_abstand_tage (INTEGER) + + Idempotent: Bei Neuinstallation hat create_all die Spalten bereits + angelegt — in diesem Fall wird das jeweilige ALTER TABLE übersprungen. + """ + from sqlalchemy import text + + result = session.execute(text("PRAGMA table_info(tags)")) + spalten = {row[1] for row in result} + + if 'is_erledigend' not in spalten: + session.execute(text( + "ALTER TABLE tags ADD COLUMN is_erledigend BOOLEAN NOT NULL DEFAULT 0" + )) + if 'loest_wiedervorlage_aus' not in spalten: + session.execute(text( + "ALTER TABLE tags ADD COLUMN loest_wiedervorlage_aus BOOLEAN NOT NULL DEFAULT 0" + )) + if 'wiedervorlage_abstand_tage' not in spalten: + session.execute(text( + "ALTER TABLE tags ADD COLUMN wiedervorlage_abstand_tage INTEGER" + )) + _updatesysteminfo('01.12') + 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 index 55c8ef1..1c8458a 100644 --- a/App_Server/root/crm/model/tag.py +++ b/App_Server/root/crm/model/tag.py @@ -20,6 +20,48 @@ from auth.model.errors import ApplicationError, HtmlStatusCode session = dbapp.session +def _apply_workflow_felder(tag: Tag, data: dict) -> None: + """ + Übernimmt die Wiedervorlage- und Erledigt-Felder aus dem data-Dict in + den Tag. Für Parent-Tags (is_parent=True) werden die Felder auf False + bzw. None gesetzt, da sie dort keine Wirkung haben (Parent-Tags sind + nicht zuordenbar). + + Validierung: Ist loest_wiedervorlage_aus True, muss wiedervorlage_abstand_tage + einen gültigen positiven Integer-Wert haben; sonst wird ein + ApplicationError (Bad_Request) geworfen. + """ + is_parent = bool(data.get("is_parent", tag.is_parent)) + if is_parent: + # Parent-Tags: Workflow-Felder sind nicht relevant. + tag.is_erledigend = False + tag.loest_wiedervorlage_aus = False + tag.wiedervorlage_abstand_tage = None + return + + tag.is_erledigend = bool(data.get("is_erledigend", False)) + loest = bool(data.get("loest_wiedervorlage_aus", False)) + tag.loest_wiedervorlage_aus = loest + + abstand_raw = data.get("wiedervorlage_abstand_tage") + abstand = None + if abstand_raw is not None and str(abstand_raw).strip() != "": + try: + abstand = int(abstand_raw) + except (TypeError, ValueError): + abstand = None + if loest: + if abstand is None or abstand < 0: + raise ApplicationError( + "Wiedervorlage-Abstand in Tagen ist erforderlich, wenn " + "Wiedervorlage ausgelöst wird.", + htmlstatus=HtmlStatusCode.Bad_Request, + ) + tag.wiedervorlage_abstand_tage = abstand + else: + tag.wiedervorlage_abstand_tage = None + + def get_tags(parent_id: int | None = None) -> list[Tag]: """ Liefert Tags, sortiert nach name (alphabetisch). @@ -76,6 +118,7 @@ def create_tag(data: dict) -> Tag: tag.color = (data.get("color") or "").strip() or None tag.parent_id = parent_id tag.is_parent = bool(data.get("is_parent", False)) + _apply_workflow_felder(tag, data) session.add(tag) session.commit() return tag @@ -124,6 +167,7 @@ def update_tag(data: dict) -> Tag: tag.color = (data.get("color") or "").strip() or None tag.parent_id = parent_id tag.is_parent = bool(data.get("is_parent", False)) + _apply_workflow_felder(tag, data) session.commit() return tag diff --git a/Client/Components/tags.ts b/Client/Components/tags.ts index 1a40cc9..cea80b9 100644 --- a/Client/Components/tags.ts +++ b/Client/Components/tags.ts @@ -77,6 +77,9 @@ export const Tag: m.ClosureComponent = () => { color: "", parent_id: null, is_parent: false, + is_erledigend: false, + loest_wiedervorlage_aus: false, + wiedervorlage_abstand_tage: null, } // Liste aller Parent-Tags fuer die Eltern-Auswahl (nur Parent-Tags @@ -109,6 +112,9 @@ export const Tag: m.ClosureComponent = () => { color: routeParams.color || "", parent_id: routeParams.parent_id ? parseInt(routeParams.parent_id) : null, is_parent: routeParams.is_parent ? routeParams.is_parent === "true" : false, + is_erledigend: routeParams.is_erledigend ? routeParams.is_erledigend === "true" : false, + loest_wiedervorlage_aus: routeParams.loest_wiedervorlage_aus ? routeParams.loest_wiedervorlage_aus === "true" : false, + wiedervorlage_abstand_tage: routeParams.wiedervorlage_abstand_tage ? parseInt(routeParams.wiedervorlage_abstand_tage) : null, } } // Parent-Tag-Liste vom Server laden (nur is_parent-Tags sind @@ -129,8 +135,11 @@ export const Tag: m.ClosureComponent = () => { 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, + parent_id: currentTag.parent_id, is_parent: currentTag.is_parent, + is_erledigend: currentTag.is_erledigend, + loest_wiedervorlage_aus: currentTag.loest_wiedervorlage_aus, + wiedervorlage_abstand_tage: currentTag.wiedervorlage_abstand_tage, } if (currentTag.id === -1) { createTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) @@ -168,7 +177,11 @@ export const Tag: m.ClosureComponent = () => { m("select.w3-select", { name: "parent_id", value: currentTag.parent_id || "", - onchange: () => { guard.markDirty() } + onchange: (e: Event) => { + const val = (e.target as HTMLSelectElement).value + currentTag.parent_id = val ? parseInt(val) : null + guard.markDirty() + } }, m("option", {value: ""}, "-- Kein übergeordneter Tag --"), allParentTags.map(tag => @@ -176,6 +189,62 @@ export const Tag: m.ClosureComponent = () => { ) ), + // Workflow-Felder (nur für echte, zuordenbare Tags sichtbar). + // Parent-Tags sind Gruppierungsknoten und nicht zuordenbar, + // daher sind diese Felder für sie irrelevant. + currentTag.is_parent ? null : [ + m("div", {style: {'margin-top': '16px', 'margin-bottom': '5px'}}, + m("label", + m("input.w3-check", { + type: "checkbox", + name: "is_erledigend", + checked: currentTag.is_erledigend, + style: {'margin-right': '8px'}, + onclick: (e: Event) => { + currentTag.is_erledigend = (e.target as HTMLInputElement).checked + guard.markDirty() + } + }), + "Kontakt als erledigt markieren" + ) + ), + m("div", {style: {'margin-bottom': '5px'}}, + m("label", + m("input.w3-check", { + type: "checkbox", + name: "loest_wiedervorlage_aus", + checked: currentTag.loest_wiedervorlage_aus, + style: {'margin-right': '8px'}, + onclick: (e: Event) => { + currentTag.loest_wiedervorlage_aus = (e.target as HTMLInputElement).checked + if (!currentTag.loest_wiedervorlage_aus) { + currentTag.wiedervorlage_abstand_tage = null + } + guard.markDirty() + } + }), + "Löst Wiedervorlage aus" + ) + ), + m("label", "Wiedervorlage-Abstand (Tage)"), + m("input.w3-input", { + type: "number", + name: "wiedervorlage_abstand_tage", + value: currentTag.wiedervorlage_abstand_tage ?? "", + min: 0, + disabled: !currentTag.loest_wiedervorlage_aus, + style: { + 'margin-bottom': '12px', + opacity: currentTag.loest_wiedervorlage_aus ? '1' : '0.5' + }, + oninput: (e: Event) => { + const val = (e.target as HTMLInputElement).value + currentTag.wiedervorlage_abstand_tage = val === "" ? null : parseInt(val) + guard.markDirty() + } + }) + ], + m("label", "Farbe"), m("div", {style: {display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px'}}, colorPalette.map(color => @@ -279,6 +348,47 @@ export const TagList: m.ClosureComponent = () => { const tagIcon = tag.is_parent ? "folder" : "label" const tagTitle = tag.is_parent ? "Parent-Tag (nicht zuordenbar)" : "" + // Workflow-Badges für echte Tags (nicht für Parent-Tags) + let workflowBadges: any[] = [] + if (!tag.is_parent) { + workflowBadges = [] + if (tag.is_erledigend) { + workflowBadges.push( + m("i.material-icons", { + title: "Erledigend: Kontakt gilt als erledigt", + style: { + verticalAlign: 'middle', + marginRight: '4px', + fontSize: '16px', + color: '#2e7d32' + } + }, "check_circle") + ) + } + if (tag.loest_wiedervorlage_aus) { + workflowBadges.push( + m("span", { + title: "Löst Wiedervorlage aus: " + (tag.wiedervorlage_abstand_tage ?? 0) + " Tage", + style: { + verticalAlign: 'middle', + marginRight: '4px', + fontSize: '12px', + color: '#1565c0' + } + }, + m("i.material-icons", { + style: { + verticalAlign: 'middle', + marginRight: '2px', + fontSize: '16px' + } + }, "schedule"), + tag.wiedervorlage_abstand_tage != null ? tag.wiedervorlage_abstand_tage + "d" : "" + ) + ) + } + } + return [ m("span", m("i.material-icons", { @@ -291,7 +401,7 @@ export const TagList: m.ClosureComponent = () => { }, tagIcon), m("span", { style: { - backgroundColor: tag.color, + backgroundColor: tag.color || '#9e9e9e', color: 'white', padding: '2px 6px', borderRadius: '3px', @@ -300,7 +410,8 @@ export const TagList: m.ClosureComponent = () => { marginRight: '8px' }, title: tagTitle - }, tag.name) + }, tag.name), + workflowBadges ), m("span", tag.description), m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}}, diff --git a/Client/models/tag.ts b/Client/models/tag.ts index a8ebab0..c3cbbcb 100644 --- a/Client/models/tag.ts +++ b/Client/models/tag.ts @@ -12,6 +12,12 @@ export interface iTag { color: string parent_id: number | null is_parent: boolean + /** True = Kontakt gilt als erledigt, wenn dieser Tag zugewiesen wird. */ + is_erledigend: boolean + /** True = Tag löst eine Wiedervorlage aus. */ + loest_wiedervorlage_aus: boolean + /** Tage bis zur Wiedervorlage (nur relevant, wenn loest_wiedervorlage_aus True). */ + wiedervorlage_abstand_tage: number | null } /**