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

+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")
])