feat(calllog): Tags einer Gesprächsnotiz zuordnen (n:m)

Backend:
- Neues CallLogTag-Model (Tabelle call_logs_tags) mit Relationship
  auf CallLog (cascade delete-orphan); to_dict liefert 'tags'.
- Migration _db_V01_11 (idempotent via create_all).
- _set_call_log_tags in create/update_call_log: ersetzt
  Tag-Zuordnungen, filtert is_parent-Tags, eliminiert Duplikate.
- _parse_date wandelt ISO-Strings in date-Objekte (fixt autoflush-
  Crash bei Date-Spalten).

Frontend:
- iCallLog um tags (Lesen) und tag_ids (Schreiben) ergänzt.
- Neues generisches MultiSelect-Widget (widgets/select.ts):
  Badges + Edit-Button, inline Modal mit Suchfeld, Checkbox-Liste,
  OK-Button; konfigurierbare Texte (placeholder, modalTitle,
  editTooltip).
- Call_Log-Formular: Tag-Auswahl via MultiSelect, Submit via
  FormData.getAll; Tag-Badges in der Listenansicht hinter dem Betreff.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
gg committed 2026-09-19 15:12:36 +02:00
1 parent 5abcb2d96c
commit d30adf1579
6 files changed
+421 -9

No files matched your search

+32 -1
View File
@@ -259,12 +259,19 @@ class CallLog(dbapp.Model):
erledigt = Column(Boolean, nullable=False, default=False)
projekt = relationship('Projekt')
# n:m-Beziehung zu Tags über die Verknüpfungstabelle call_logs_tags.
# cascade='all, delete-orphan' stellt sicher, dass die Zuordnungen
# automatisch gelöscht werden, wenn die Gesprächsnotiz gelöscht wird.
tags_rel = relationship(
'CallLogTag', cascade='all, delete-orphan', back_populates='call_log'
)
def to_dict(self):
"""
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; projekt_name und kunden_name werden
zusätzlich aus der Projekt-Relationship eingefügt.
zusätzlich aus der Projekt-Relationship eingefügt. Die zugeordneten
Tags werden als Liste von Tag-Dictionaries unter 'tags' ergänzt.
"""
result = columns2dict(self)
if self.projekt:
@@ -273,9 +280,33 @@ class CallLog(dbapp.Model):
else:
result['projekt_name'] = None
result['kunden_name'] = None
# Zugeordnete Tags serialisieren (nur is_parent=False sind zuordenbar).
result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel]
return result
class CallLogTag(dbapp.Model):
"""
Verknüpfungstabelle für die n:m-Beziehung zwischen Gesprächsnotizen
(CallLog) und Tags. Jede Zeile verknüpft eine Gesprächsnotiz mit einem
Tag. Der UniqueConstraint stellt sicher, dass jeder Tag nur einmal pro
Gesprächsnotiz zugeordnet werden kann.
"""
__tablename__ = 'call_logs_tags'
__bind_key__ = BINDKEY
__table_args__ = (
UniqueConstraint('call_log_id', 'tag_id', name='uq_calllog_tag'),
Index('ix_calllogtag_call_log_id', 'call_log_id'),
Index('ix_calllogtag_tag_id', 'tag_id'),
)
id = Column(Integer, primary_key=True)
call_log_id = Column(Integer, ForeignKey('call_logs.id'), nullable=False)
tag_id = Column(Integer, ForeignKey('tags.id'), nullable=False)
call_log = relationship('CallLog', back_populates='tags_rel')
tag = relationship('Tag')
class Tag(dbapp.Model):
"""
Hierarchischer Tag (Taxonomie). Tags koennen beliebig verschachtelt
+61 -5
View File
@@ -9,14 +9,68 @@ dort über den Decorator authorisation_required.
:date: 2026-09-04
:version: 1.0
"""
from datetime import date
from db import dbapp
from crm.model import CallLog, Projekt, ProjektUser
from crm.model import CallLog, CallLogTag, Projekt, ProjektUser, Tag
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def _parse_date(value: str | None) -> date | None:
"""
Wandelt einen ISO-Datum-String (yyyy-MM-dd) in ein date-Objekt um.
Leere Strings und None werden zu None konvertiert (Spalte ist nullable).
"""
if not value:
return None
value = value.strip()
if not value:
return None
try:
return date.fromisoformat(value)
except ValueError:
return None
def _set_call_log_tags(call_log: CallLog, tag_ids: list) -> None:
"""
Ersetzt die Tag-Zuordnungen einer Gesprächsnotiz durch die übergebene
Liste von Tag-IDs. Bestehende Zuordnungen werden gelöscht, danach werden
die neuen Zuordnungen angelegt.
Nur Tags, die existieren und nicht reine Gruppierungsknoten
(is_parent=True) sind, werden zugeordnet. Duplikate in der Liste
werden durch ein Set eliminiert.
"""
# Bestehende Zuordnungen entfernen.
for clt in list(call_log.tags_rel):
session.delete(clt)
call_log.tags_rel = []
# Neue Zuordnungen anlegen.
seen = set()
for tag_id in tag_ids or []:
try:
tag_id = int(tag_id)
except (TypeError, ValueError):
continue
if tag_id in seen:
continue
seen.add(tag_id)
tag = Tag.query.get(tag_id)
if tag is None or tag.is_parent:
# Nicht zuordenbar — überspringen.
continue
clt = CallLogTag()
clt.call_log = call_log
clt.tag = tag
session.add(clt)
def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
"""
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
@@ -94,15 +148,16 @@ def create_call_log(data: dict) -> CallLog:
)
call_log = CallLog()
call_log.projekt_id = int(projekt_id)
call_log.datum = (data.get("datum") or "").strip() or None
call_log.datum = _parse_date(data.get("datum"))
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
call_log.dauer = (data.get("dauer") or "").strip() or None
call_log.betreff = (data.get("betreff") or "").strip() or None
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
call_log.telefon = (data.get("telefon") or "").strip() or None
call_log.notiz = (data.get("notiz") or "").strip() or None
call_log.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
call_log.erledigt = bool(data.get("erledigt", False))
_set_call_log_tags(call_log, data.get("tag_ids"))
session.add(call_log)
session.commit()
return call_log
@@ -127,15 +182,16 @@ def update_call_log(data: dict) -> CallLog:
htmlstatus=HtmlStatusCode.Bad_Request,
)
call_log.projekt_id = int(projekt_id)
call_log.datum = (data.get("datum") or "").strip() or None
call_log.datum = _parse_date(data.get("datum"))
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
call_log.dauer = (data.get("dauer") or "").strip() or None
call_log.betreff = (data.get("betreff") or "").strip() or None
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
call_log.telefon = (data.get("telefon") or "").strip() or None
call_log.notiz = (data.get("notiz") or "").strip() or None
call_log.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
call_log.erledigt = bool(data.get("erledigt", False))
_set_call_log_tags(call_log, data.get("tag_ids"))
session.commit()
return call_log
+11
View File
@@ -232,6 +232,17 @@ def _db_V01_10():
session.commit()
def _db_V01_11():
"""
Legt die Verknüpfungstabelle 'call_logs_tags' für die n:m-Beziehung
zwischen Gesprächsnotizen und Tags an. create_all erzeugt nur fehlende
Tabellen, bestehende bleiben unangetastet (idempotent).
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.11')
session.commit()
def checkupdates():
"""
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
+42 -3
View File
@@ -1,12 +1,13 @@
import m from "mithril"
import {Form, iForm} from "./widgets/form"
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
import {Select, iSelect, iSelectOption} from "./widgets/select"
import {Select, iSelect, MultiSelect, iMultiSelect, iSelectOption} from "./widgets/select"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCallLogREST} from "../models/call_log"
import {iKunde, fetchKunden} from "../models/kunde"
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
import {iTag, fetchTags} from "../models/tag"
import {showServerError} from "./auth/authmessages"
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
@@ -58,12 +59,28 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let log: iCallLog = node.nodevalue
// Zugewiesene Tags als farbige Badges hinter dem Betreff darstellen.
const tagBadges = (log.tags || []).map((t: iTag) =>
m("span", {
style: {
backgroundColor: t.color || '#999',
color: 'white',
padding: '1px 5px',
'border-radius': '3px',
'font-size': '11px',
'margin-left': '4px',
display: 'inline-block'
},
title: t.name
}, t.name)
)
return [
m("div", m("strong", log.kunden_name + ": " + log.projekt_name)),
m("span", log.datum),
m("span", log.uhrzeit_beginn),
m("span", log.dauer + " min"),
m("span", log.betreff),
m("span", log.betreff, ...tagBadges),
m("span", log.wiedervorlage_datum),
m("span", log.erledigt ? "Erledigt" : "Offen"),
m("button.w3-button.w3-small", {title: "Gesprächsnotiz bearbeiten", style: {padding: '0 2px'}, onclick: (e: Event) => editCallLog(log, e)}, m("i.material-icons", "edit"))
@@ -183,6 +200,10 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
let kundenListe: iKunde[] = []
let projektListe: iProjekt[] = []
let kontaktadressenListe: iKontaktadresse[] = []
// Asynchron geladene Tag-Liste (nur zuordenbare, nicht is_parent).
let tagListe: iTag[] = []
// Aktuell ausgewählte Tag-IDs im Formular.
let selectedTagIds: number[] = []
/**
* Formatiert verstrichene Sekunden als mm:ss.
@@ -203,6 +224,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
currentLog = result
projektVoreingestellt = true
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
selectedTagIds = (result.tags || []).map((t: iTag) => t.id)
}).catch((e) => showServerError(e))
} else {
projektVoreingestellt = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) !== -1 : false
@@ -234,6 +256,8 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
fetchKunden().then((result) => { kundenListe = result }).catch((e) => showServerError(e))
fetchProjekte().then((result) => { projektListe = result }).catch((e) => showServerError(e))
fetchKontaktadressen().then((result) => { kontaktadressenListe = result }).catch((e) => showServerError(e))
// Tags laden — nur zuordenbare (is_parent=false) Tags.
fetchTags().then((result) => { tagListe = result.filter((t: iTag) => !t.is_parent) }).catch((e) => showServerError(e))
},
onremove: (vnode) => {
if (timerId != null) { window.clearInterval(timerId) }
@@ -267,6 +291,11 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
})
: []
// Tag-Optionen und Farb-Map für die Mehrfachauswahl.
const tagOptionen: iSelectOption[] = tagListe.map((t: iTag) => {
return {value: t.id, label: t.name}
})
const tagColors: Map<number, string> = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string]))
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
@@ -283,7 +312,8 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
telefon: (formData.get("telefon") as string) || "",
notiz: (formData.get("notiz") as string) || "",
wiedervorlage_datum: (formData.get("wiedervorlage_datum") as string) || "",
erledigt: formData.get("erledigt") === "on"
erledigt: formData.get("erledigt") === "on",
tag_ids: formData.getAll("tag_ids").map((v) => parseInt(v as string)).filter((n) => !isNaN(n))
}
if (currentLog.id === -1) {
createCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})).catch((e) => showServerError(e))
@@ -353,6 +383,15 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
m(Input, {name: "telefon", value: currentLog.telefon, type: "tel"} as iInput))
),
m("label", "Notiz", m(Textarea, {name: "notiz", value: currentLog.notiz, rows: 4} as iTextarea)),
m("label", "Tags", m(MultiSelect, {
name: "tag_ids",
selected: selectedTagIds,
options: tagOptionen,
colors: tagColors,
placeholder: "kein Tag",
modalTitle: "Tags auswählen",
editTooltip: "Tags bearbeiten"
} as iMultiSelect)),
m("label", "Wiedervorlage Datum", m(Input, {name: "wiedervorlage_datum", value: currentLog.wiedervorlage_datum, type: "date"} as iInput)),
m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt")
])
+268
View File
@@ -207,3 +207,271 @@ export const Select: m.ClosureComponent<iSelect> = () => {
}
}
}
/**
* Der Vnode der Komponente 'MultiSelect' akzeptiert die folgenden Parameter:
* @property {string} name - Der Name des Feldes (für FormData; pro gewähltem
* Wert wird ein hidden input dieses Namens erzeugt)
* @property {any[]} selected - Die aktuell gewählten Werte (values der Optionen)
* @property {iSelectOption[]} options - Die zur Auswahl stehenden Optionen
* @property {boolean} readonly (optional) - Auswahl gesperrt
* @property {Map<any, string>} colors (optional) - Wert -> Hexfarbe für
* Farbpunkte/Chips (z.B. Tag-Farben)
* @property {string} placeholder (optional) - Platzhalter-Badge-Text bei
* leerer Auswahl (Default: "kein Wert")
* @property {string} modalTitle (optional) - Titel des Auswahl-Modals
* (Default: "Auswahl")
* @property {string} editTooltip (optional) - Tooltip des Edit-Buttons
* (Default: "Bearbeiten")
* @property {Function} onchanged (optional) - Callback bei Änderung der Auswahl
*/
export interface iMultiSelect {
name: string
selected: any[]
options: iSelectOption[]
readonly?: boolean
colors?: Map<any, string>
placeholder?: string
modalTitle?: string
editTooltip?: string
onchanged?: Function
}
/**
* Mehrfachauswahl-Widget mit Modal-Dialog. Im Formular werden nur die
* ausgewählten Werte als farbige Badges (bzw. ein grauer Platzhalter bei
* leerer Auswahl) zusammen mit einem Edit-Button angezeigt. Beim Klick
* auf den Edit-Button öffnet sich ein Modal mit einem Suchfeld, der
* durchsuchbaren Checkbox-Liste aller Optionen und einem OK-Button zum
* Schließen.
*
* Das Widget ist generisch und nicht auf Tags beschränkt — es arbeitet
* mit beliebigen iSelectOption-Listen. Tag-spezifische Texte (Platzhalter,
* Modal-Titel, Tooltip) sind über die entsprechenden Attribute
* konfigurierbar.
*
* Für die Formularübergabe wird pro gewähltem Wert ein verstecktes
* input-Element erzeugt, sodass FormData.getAll(name) alle IDs liefert.
*/
export const MultiSelect: m.ClosureComponent<iMultiSelect> = () => {
// Suchtext und Modal-Sichtbarkeit.
let search: string = ""
let isModalOpen: boolean = false
// Lokale Arbeitskopie der Auswahl während das Modal geöffnet ist.
let draft: any[] = []
const getFilteredOptions = (options: iSelectOption[], searchText: string): iSelectOption[] => {
const searchLower = searchText.toLowerCase()
if (searchLower === "") {
return options
}
return options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower))
}
return {
oninit: (vnode) => {
search = ""
isModalOpen = false
draft = []
},
view: (vnode) => {
const isReadonly = vnode.attrs.readonly === true
const colors = vnode.attrs.colors
const placeholder = vnode.attrs.placeholder || "kein Wert"
const modalTitle = vnode.attrs.modalTitle || "Auswahl"
const editTooltip = vnode.attrs.editTooltip || "Bearbeiten"
const selected: any[] = Array.isArray(vnode.attrs.selected) ? vnode.attrs.selected : []
const filteredOptions = getFilteredOptions(vnode.attrs.options, search)
const isDraftSelected = (value: any) => draft.includes(value)
const toggleDraft = (option: iSelectOption) => {
if (isDraftSelected(option.value)) {
const idx = draft.indexOf(option.value)
if (idx >= 0) { draft.splice(idx, 1) }
} else {
draft.push(option.value)
}
}
const removeChip = (value: any, e: Event) => {
e.stopPropagation()
e.preventDefault()
const idx = vnode.attrs.selected.indexOf(value)
if (idx >= 0) { vnode.attrs.selected.splice(idx, 1) }
if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(vnode.attrs.selected.slice())
}
}
const openEditModal = (e: Event) => {
e.preventDefault()
// draft als Kopie der aktuellen Auswahl initialisieren.
draft = selected.slice()
search = ""
isModalOpen = true
}
const closeModal = () => {
// Auswahl aus draft in selected übernehmen.
vnode.attrs.selected.splice(0, vnode.attrs.selected.length)
Array.prototype.push.apply(vnode.attrs.selected, draft)
if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(vnode.attrs.selected.slice())
}
isModalOpen = false
search = ""
}
// Badges für ausgewählte Werte. Bei leerer Auswahl wird ein
// graues Platzhalter-Badge "kein Tag" angezeigt.
const badges = m("div", {
style: {
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
'margin-bottom': '5px',
'min-height': '20px',
alignItems: 'center'
}
},
selected.length > 0
? selected.map((value: any) => {
const opt = vnode.attrs.options.find((o: iSelectOption) => o.value === value)
const label = opt ? opt.label : String(value)
const color = colors ? colors.get(value) : undefined
return m("span.w3-tag.w3-small", {
style: {
backgroundColor: color || '#999',
color: 'white',
borderRadius: '3px',
padding: '2px 4px',
display: 'inline-flex',
alignItems: 'center',
gap: '2px',
cursor: isReadonly ? 'default' : 'pointer'
}
},
m("span", label),
!isReadonly
? m("button.w3-button", {
style: {
'min-width': '16px',
height: '16px',
padding: '0',
'line-height': '1',
color: 'white',
background: 'transparent'
},
onclick: (e: Event) => removeChip(value, e)
}, "×")
: null
)
})
: m("span.w3-tag.w3-small", {
style: {
backgroundColor: '#bbb',
color: 'white',
borderRadius: '3px',
padding: '2px 4px',
display: 'inline-flex',
alignItems: 'center'
}
}, placeholder)
)
return m("div", {style: {width: '100%', position: 'relative'}},
// Versteckte Felder für die Formularübergabe (ein pro gewähltem Wert).
selected.map((value: any) =>
m("input", {type: "hidden", name: vnode.attrs.name, value: value})
),
m("div", {style: {display: 'flex', alignItems: 'center', gap: '8px'}},
badges,
!isReadonly
? m("button.w3-button.w3-small", {
title: editTooltip,
style: {
'min-width': '32px',
height: '32px',
padding: '0 4px',
'line-height': '1'
},
onclick: openEditModal
}, m("i.material-icons", {style: {'font-size': '20px'}}, "edit"))
: null
),
// Inline gerendertes Modal für die Tag-Auswahl.
isModalOpen
? m("div.w3-modal", {style: {display: 'block'}},
m("div.w3-modal-content.w3-card-4", {style: {width: '500px', top: '10%'}},
m("header.w3-container.w3-theme",
m("h2", modalTitle)
),
m("div.w3-container.w3-theme-l1", {style: {padding: '16px'}},
// Suchfeld.
m("input.w3-input.w3-border", {
type: "text",
value: search,
placeholder: "Suchbegriff …",
style: {'margin-bottom': '10px', width: '100%'},
oninput: (e: Event) => {
search = (e.target as HTMLInputElement).value
}
}),
// Checkbox-Liste der (gefilterten) Tags.
m("div.w3-bar-block.w3-border", {
style: {
'max-height': '300px',
'overflow-y': 'auto',
'margin-bottom': '10px'
}
},
filteredOptions.length === 0
? m("div.w3-padding-small", "Keine Treffer")
: filteredOptions.map((option: iSelectOption) => {
const color = colors ? colors.get(option.value) : undefined
return m("label.w3-bar-item.w3-button.w3-text-left", {
style: {whiteSpace: 'normal', display: 'flex', alignItems: 'center', gap: '6px'}
},
m("input.w3-check", {
type: "checkbox",
checked: isDraftSelected(option.value),
style: {'margin-right': '4px'},
onclick: (e: Event) => {
if (isReadonly) { return }
e.preventDefault()
toggleDraft(option)
}
}),
color
? m("span", {
style: {
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: color,
flex: '0 0 auto'
}
})
: null,
m("span", option.label)
)
})
),
// OK-Button schließt das Modal.
m("div.w3-bar",
m("button.w3-button.w3-theme.w3-right",
{type: "button", onclick: closeModal},
"OK"
)
)
)
)
)
: null
)
}
}
}
+7
View File
@@ -1,10 +1,13 @@
import m from "mithril"
import {iTag} from "./tag"
/**
* Datenmodell für eine Gesprächsnotiz (Call-Log).
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
* tags (vom Server beim Lesen) und tag_ids (vom Client beim Schreiben)
* steuern die n:m-Beziehung zu den Tags.
*/
export interface iCallLog {
id: number
@@ -20,6 +23,10 @@ export interface iCallLog {
notiz: string
wiedervorlage_datum: string
erledigt: boolean
/** Vom Server gelieferte zugeordnete Tags (nur beim Lesen). */
tags?: iTag[]
/** Vom Client zu übermittelnde Tag-IDs (nur beim Schreiben). */
tag_ids?: number[]
}
/* ------------------------------------------------------------------ */