diff --git a/App_Server/root/crm/model/__init__.py b/App_Server/root/crm/model/__init__.py index 6c956e0..b4a7a70 100644 --- a/App_Server/root/crm/model/__init__.py +++ b/App_Server/root/crm/model/__init__.py @@ -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 diff --git a/App_Server/root/crm/model/call_log.py b/App_Server/root/crm/model/call_log.py index a21037c..ceaa2f7 100644 --- a/App_Server/root/crm/model/call_log.py +++ b/App_Server/root/crm/model/call_log.py @@ -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 diff --git a/App_Server/root/crm/model/checkdb.py b/App_Server/root/crm/model/checkdb.py index ff70b05..6d4199f 100644 --- a/App_Server/root/crm/model/checkdb.py +++ b/App_Server/root/crm/model/checkdb.py @@ -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 diff --git a/Client/Components/call_log.ts b/Client/Components/call_log.ts index 4ad993f..5e696fa 100644 --- a/Client/Components/call_log.ts +++ b/Client/Components/call_log.ts @@ -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 = 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") ]) diff --git a/Client/Components/widgets/select.ts b/Client/Components/widgets/select.ts index f6c6aac..a63a3b0 100644 --- a/Client/Components/widgets/select.ts +++ b/Client/Components/widgets/select.ts @@ -207,3 +207,271 @@ export const Select: m.ClosureComponent = () => { } } } + + +/** + * 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} 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 + 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 = () => { + // 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 + ) + } + } +} diff --git a/Client/models/call_log.ts b/Client/models/call_log.ts index 91baa7c..42ecd35 100644 --- a/Client/models/call_log.ts +++ b/Client/models/call_log.ts @@ -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[] } /* ------------------------------------------------------------------ */