feat(tag): Workflow-Felder für Telefon-Akquise (is_erledigend, Wiedervorlage)
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 <vibe@mistral.ai>
This commit is contained in:
1 parent
1d8201f46a
commit
6f86bc9808
5 files changed
+206
-4
No files matched your search
@@ -325,6 +325,15 @@ class Tag(dbapp.Model):
|
|||||||
color = Column(String(20), nullable=True)
|
color = Column(String(20), nullable=True)
|
||||||
parent_id = Column(Integer, ForeignKey('tags.id'), nullable=True)
|
parent_id = Column(Integer, ForeignKey('tags.id'), nullable=True)
|
||||||
is_parent = Column(Boolean, nullable=False, default=False)
|
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
|
# Selbst-Referenzierende Relationship: parent verweist auf den
|
||||||
# uebergeordneten Tag, children auf alle direkten Kinder. Die
|
# uebergeordneten Tag, children auf alle direkten Kinder. Die
|
||||||
|
|||||||
@@ -243,6 +243,38 @@ def _db_V01_11():
|
|||||||
session.commit()
|
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():
|
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
|
||||||
|
|||||||
@@ -20,6 +20,48 @@ from auth.model.errors import ApplicationError, HtmlStatusCode
|
|||||||
session = dbapp.session
|
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]:
|
def get_tags(parent_id: int | None = None) -> list[Tag]:
|
||||||
"""
|
"""
|
||||||
Liefert Tags, sortiert nach name (alphabetisch).
|
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.color = (data.get("color") or "").strip() or None
|
||||||
tag.parent_id = parent_id
|
tag.parent_id = parent_id
|
||||||
tag.is_parent = bool(data.get("is_parent", False))
|
tag.is_parent = bool(data.get("is_parent", False))
|
||||||
|
_apply_workflow_felder(tag, data)
|
||||||
session.add(tag)
|
session.add(tag)
|
||||||
session.commit()
|
session.commit()
|
||||||
return tag
|
return tag
|
||||||
@@ -124,6 +167,7 @@ def update_tag(data: dict) -> Tag:
|
|||||||
tag.color = (data.get("color") or "").strip() or None
|
tag.color = (data.get("color") or "").strip() or None
|
||||||
tag.parent_id = parent_id
|
tag.parent_id = parent_id
|
||||||
tag.is_parent = bool(data.get("is_parent", False))
|
tag.is_parent = bool(data.get("is_parent", False))
|
||||||
|
_apply_workflow_felder(tag, data)
|
||||||
session.commit()
|
session.commit()
|
||||||
return tag
|
return tag
|
||||||
|
|
||||||
|
|||||||
+115
-4
@@ -77,6 +77,9 @@ export const Tag: m.ClosureComponent = () => {
|
|||||||
color: "",
|
color: "",
|
||||||
parent_id: null,
|
parent_id: null,
|
||||||
is_parent: false,
|
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
|
// Liste aller Parent-Tags fuer die Eltern-Auswahl (nur Parent-Tags
|
||||||
@@ -109,6 +112,9 @@ export const Tag: m.ClosureComponent = () => {
|
|||||||
color: routeParams.color || "",
|
color: routeParams.color || "",
|
||||||
parent_id: routeParams.parent_id ? parseInt(routeParams.parent_id) : 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,
|
||||||
|
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
|
// 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) || "",
|
name: (formData.get("name") as string) || "",
|
||||||
description: (formData.get("description") as string) || "",
|
description: (formData.get("description") as string) || "",
|
||||||
color: currentTag.color || "",
|
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_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) {
|
if (currentTag.id === -1) {
|
||||||
createTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
createTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||||
@@ -168,7 +177,11 @@ export const Tag: m.ClosureComponent = () => {
|
|||||||
m("select.w3-select", {
|
m("select.w3-select", {
|
||||||
name: "parent_id",
|
name: "parent_id",
|
||||||
value: currentTag.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 --"),
|
m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
|
||||||
allParentTags.map(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("label", "Farbe"),
|
||||||
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 =>
|
||||||
@@ -279,6 +348,47 @@ export const TagList: m.ClosureComponent = () => {
|
|||||||
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)" : ""
|
||||||
|
|
||||||
|
// 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 [
|
return [
|
||||||
m("span",
|
m("span",
|
||||||
m("i.material-icons", {
|
m("i.material-icons", {
|
||||||
@@ -291,7 +401,7 @@ export const TagList: m.ClosureComponent = () => {
|
|||||||
}, tagIcon),
|
}, tagIcon),
|
||||||
m("span", {
|
m("span", {
|
||||||
style: {
|
style: {
|
||||||
backgroundColor: tag.color,
|
backgroundColor: tag.color || '#9e9e9e',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
padding: '2px 6px',
|
padding: '2px 6px',
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
@@ -300,7 +410,8 @@ export const TagList: m.ClosureComponent = () => {
|
|||||||
marginRight: '8px'
|
marginRight: '8px'
|
||||||
},
|
},
|
||||||
title: tagTitle
|
title: tagTitle
|
||||||
}, tag.name)
|
}, tag.name),
|
||||||
|
workflowBadges
|
||||||
),
|
),
|
||||||
m("span", tag.description),
|
m("span", tag.description),
|
||||||
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}},
|
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}},
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export interface iTag {
|
|||||||
color: string
|
color: string
|
||||||
parent_id: number | null
|
parent_id: number | null
|
||||||
is_parent: boolean
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user