Files
gg bc9b337696 feat(crm): zentrale TagBadge-Komponente und Ergänzungen zur Workflow-Maske
- Position des Ansprechpartners in der Kopfzeile der Tagesworkflow-Maske anzeigen
- Neue TagBadge-Komponente (Farbchip, Name, Workflow-Icons: erledigend/
  Wiedervorlage) ersetzt die duplizierte Badge-Darstellung in Notiz-Liste,
  Workflow-Liste, Historie und Tag-Liste
- MultiSelect: optionale content-Option rendert ein fertiges Badge des
  Aufrufers statt Farbpunkt + Label im Auswahl-Modal; Suchfeld wird beim
  Öffnen des Modals fokussiert
- TagBadge-Attribut heißt tagData statt tag, da Mithril attrs-Objekte mit
  tag-Property als Vnode-Kinder behandelt (führte zu 'color' undefined)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-27 18:37:49 +02:00

536 lines
28 KiB
TypeScript

import m from "mithril"
import {Form, iForm} from "./widgets/form"
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
import {Select, iSelect, MultiSelect, iMultiSelect, iSelectOption} from "./widgets/select"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
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, berechneWiedervorlageDatum} from "../models/tag"
import {TagBadge} from "./tagbadge"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
const DAUER_UPDATE_INTERVALL = 1000
export const call_log_liste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
let filterProjektId: number | null = null
// Projektname für die Überschrift, asynchron per fetchProjektById
// aufgelöst. kunden_name wird ebenfalls aus dem Projekt bezogen,
// da es nicht in den Route-Params enthalten ist.
let projektName: string = ""
let kundenName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
// Eigener Listen-Crumb für die Breadcrumb-Kette.
const ownCrumb = (): iBreadcrumb => ({
route: "/calllogliste",
params: filterProjektId !== null ? {projekt_id: filterProjektId} : {},
label: filterProjektId !== null ? "Notizen: " + projektName : "Gesprächsnotizen"
})
const addCallLogRoute = () => {
const now = new Date()
const datum = now.toISOString().split('T')[0]
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
const params: any = {
id: -1,
datum: datum,
uhrzeit_beginn: uhrzeit,
dauer: "",
betreff: "",
name_ansprechpartner: "",
telefon: "",
notiz: "",
wiedervorlage_datum: "",
from_liste: "1"
}
if (filterProjektId !== null) {
params.projekt_id = filterProjektId
params.projekt_name = projektName
params.kunden_name = kundenName
params.return_projekt_id = filterProjektId
params.return_projekt_name = projektName
}
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/call_log", params)
}
const editCallLog = (log: iCallLog, e: Event) => {
e.stopPropagation()
const params: any = {id: log.id, from_liste: "1"}
if (filterProjektId !== null) {
params.return_projekt_id = filterProjektId
params.return_projekt_name = projektName
}
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/call_log", params)
}
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(TagBadge, {tagData: t, compact: true})
)
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, ...tagBadges),
m("span", log.wiedervorlage_datum),
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"))
]
}
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let log: iCallLog = node.nodevalue
let searchLower = searchvalue.toLowerCase()
return (log.kunden_name || "").toLowerCase().includes(searchLower) ||
(log.projekt_name || "").toLowerCase().includes(searchLower) ||
(log.datum || "").includes(searchLower) ||
(log.uhrzeit_beginn || "").toLowerCase().includes(searchLower) ||
(log.betreff || "").toLowerCase().includes(searchLower) ||
(log.name_ansprechpartner || "").toLowerCase().includes(searchLower) ||
(log.notiz || "").toLowerCase().includes(searchLower) ||
(log.wiedervorlage_datum || "").includes(searchLower)
}
return {
oninit: (vnode) => {
const routeParams = m.route.param()
if (routeParams && routeParams.projekt_id) {
filterProjektId = parseInt(routeParams.projekt_id)
projektName = routeParams.projekt_name || ""
} else {
filterProjektId = null
projektName = ""
}
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
// Gesprächsnotizen asynchron vom Server laden. Die Filterung
// nach projekt_id erfolgt serverseitig, sofern ein Filter
// gesetzt ist.
fetchCallLogs(filterProjektId !== null ? filterProjektId : undefined).then((result) => {
treenodes = result.map((log: iCallLog) => new Treenode(log, true, []))
}).catch((e) => showServerError(e))
// Projektnamen und Kundennamen asynchron auflösen, falls
// nicht in den Route-Params enthalten.
if (filterProjektId !== null) {
fetchProjektById(filterProjektId).then((projekt: iProjekt) => {
if (projektName === "") {
projektName = projekt.projektname
}
kundenName = projekt.kunden_name
}).catch((e) => showServerError(e))
}
},
view: (vnode) => {
return m("div.w3-container.w3-auto", {
style: {
display: 'flex',
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", projektName ? "Gesprächsnotizen für " + projektName : "Gesprächsnotizen"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: filterProjektId !== null ? "Notizen: " + projektName : "Gesprächsnotizen"
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
treenodes: treenodes,
columnmapper: columnmapper,
filter: filterFunction,
onRowMarked: handleRowMarked,
isSelected: (node: iTreenode) => node === selectedNode
} as iTreetable,
m(TopbarButton, {onclick: addCallLogRoute, tooltip: "Neue Gesprächsnotiz anlegen"} as iTopbarButton,
m("i.material-icons", "add_call")
)
)
)
)
}
}
}
export const Call_Log: m.ClosureComponent = (vnode) => {
// currentLog hält den Formularzustand. projekt_id referenziert das
// Projekt, kunden_name und projekt_name dienen der Anzeige.
let currentLog: {
id: number
projekt_id: number
kontaktadresse_id: number
projekt_name: string
kunden_name: string
datum: string
uhrzeit_beginn: string
dauer: string
betreff: string
name_ansprechpartner: string
telefon: string
notiz: string
wiedervorlage_datum: string
} = {
id: -1,
projekt_id: -1,
kontaktadresse_id: -1,
projekt_name: "",
kunden_name: "",
datum: "",
uhrzeit_beginn: "",
dauer: "",
betreff: "",
name_ansprechpartner: "",
telefon: "",
notiz: "",
wiedervorlage_datum: ""
}
// Ist das Projekt voreingestellt (Liste wurde projektgefiltert)?
let projektVoreingestellt: boolean = false
// Ist der Ansprechpartner voreingestellt (aus Kontaktadressenliste)?
let ansprechpartnerVoreingestellt: boolean = false
// Startzeit für die Dauer-Messung (nur bei Neuanlage gesetzt).
let startZeit: Date | null = null
// Timer-Handle für die Dauer-Aktualisierung.
let timerId: number | null = null
// Direkte DOM-Referenz auf das Dauer-Feld.
let dauerInput: HTMLInputElement | null = null
// Asynchron geladene Listen für Select-Optionen.
let kundenListe: iKunde[] = []
let projektListe: iProjekt[] = []
let kontaktadressenListe: iKontaktadresse[] = []
// Im Auswahl-Modus (nicht vorbelegt): gewählte Kunden-ID für die
// abhängige Projekt-Filterung.
let selectedKundenId: number = -1
// Asynchron geladene Tag-Liste (nur zuordenbare, nicht is_parent).
let tagListe: iTag[] = []
// Aktuell ausgewählte Tag-IDs im Formular.
let selectedTagIds: number[] = []
// Wurde die Maske aus der Gesprächsnotizenliste geöffnet? Wenn ja,
// erfolgt die Rückkehr dorthin mit dem ursprünglichen Listenfilter.
let fromListe: boolean = false
// Filter-Kontext der Liste, aus der die Maske geöffnet wurde.
// null bedeutet ungefiltert; ein Wert bedeutet projektgefiltert.
let returnProjektId: number | null = null
let returnProjektName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
// Route-Params für die Rückkehr zur Notizliste; als Closure an den
// DirtyMaskHelper übergeben (greift auf fromListe, returnProjektId,
// currentLog, breadcrumbs zu).
const backParams = (): any => {
const params: any = {}
if (fromListe) {
if (returnProjektId !== null) {
params.projekt_id = returnProjektId
params.projekt_name = returnProjektName
}
} else if (currentLog.projekt_id !== -1) {
params.projekt_id = currentLog.projekt_id
params.projekt_name = currentLog.projekt_name
}
const listBc = serializeBc(breadcrumbs.slice(0, -1))
if (listBc) params.bc = listBc
return params
}
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/calllogliste", backParams)
/**
* Formatiert verstrichene Sekunden als mm:ss.
*/
const formatDauer = (sekunden: number): string => {
const min = Math.floor(sekunden / 60)
const sek = sekunden % 60
return min.toString().padStart(2, '0') + ':' + sek.toString().padStart(2, '0')
}
return {
oninit: (vnode) => {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Rückkehr-Kontext: wurde die Maske aus der Liste geöffnet und
// mit welchem Filter soll zurückgekehrt werden?
fromListe = !!(routeParams && routeParams.from_liste)
if (routeParams && routeParams.return_projekt_id) {
returnProjektId = parseInt(routeParams.return_projekt_id)
returnProjektName = routeParams.return_projekt_name || ""
}
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) {
// Gesprächsnotiz frisch vom Server holen.
fetchCallLogById(id).then((result) => {
currentLog = result
projektVoreingestellt = true
ansprechpartnerVoreingestellt = currentLog.kontaktadresse_id > 0
selectedTagIds = (result.tags || []).map((t: iTag) => t.id)
}).catch((e) => showServerError(e))
} else {
projektVoreingestellt = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) !== -1 : false
currentLog.projekt_id = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) : -1
currentLog.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
currentLog.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
currentLog.datum = routeParams && routeParams.datum ? routeParams.datum : ""
currentLog.uhrzeit_beginn = routeParams && routeParams.uhrzeit_beginn ? routeParams.uhrzeit_beginn : ""
currentLog.dauer = routeParams && routeParams.dauer ? routeParams.dauer : ""
currentLog.betreff = routeParams && routeParams.betreff ? routeParams.betreff : ""
currentLog.name_ansprechpartner = routeParams && routeParams.name_ansprechpartner ? routeParams.name_ansprechpartner : ""
currentLog.telefon = routeParams && routeParams.telefon ? routeParams.telefon : ""
currentLog.notiz = routeParams && routeParams.notiz ? routeParams.notiz : ""
currentLog.wiedervorlage_datum = routeParams && routeParams.wiedervorlage_datum ? routeParams.wiedervorlage_datum : ""
// Kontaktadresse-ID aus den Route-Params übernehmen, wenn die
// Maske aus der Kontaktadressenliste geöffnet wurde.
currentLog.kontaktadresse_id = routeParams && routeParams.kontaktadresse_id ? parseInt(routeParams.kontaktadresse_id) : -1
ansprechpartnerVoreingestellt = currentLog.kontaktadresse_id > 0
// Bei Neuanlage Startzeit merken und Dauer-Timer starten.
startZeit = new Date()
timerId = window.setInterval(() => {
if (startZeit) {
const sekunden = Math.floor((Date.now() - startZeit.getTime()) / 1000)
const dauer = formatDauer(sekunden)
currentLog.dauer = dauer
if (dauerInput) { dauerInput.value = dauer }
}
}, DAUER_UPDATE_INTERVALL)
}
// Alle Listen asynchron laden, für Select-Optionen.
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) }
guard.cleanup()
},
view: (vnode) => {
const kundenOptionen: iSelectOption[] = kundenListe.map((k: iKunde) => {
return {value: k.id, label: k.name}
})
// Projekt-Optionen sind vom gewählten Kunden abhängig.
const projektOptionen: iSelectOption[] = projektListe
.filter((p: iProjekt) => selectedKundenId === -1 || p.kunden_id === selectedKundenId)
.map((p: iProjekt) => {
return {value: p.id, label: p.projektname}
})
// Kontaktadressen-Optionen sind vom gewählten Projekt abhängig.
const kontaktadressenOptionen: iSelectOption[] = !ansprechpartnerVoreingestellt
? kontaktadressenListe
.filter((ka: iKontaktadresse) => ka.projekt_id === currentLog.projekt_id)
.map((ka: iKontaktadresse) => {
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
})
: []
// Tag-Optionen und Farb-Map für die Mehrfachauswahl. Als content
// wird das fertige TagBadge übergeben — das Auswahl-Modal zeigt
// damit den Farbchip mit Namen und die Workflow-Icons.
const tagOptionen: iSelectOption[] = tagListe.map((t: iTag) => {
return {value: t.id, label: t.name, content: m(TagBadge, {tagData: t})}
})
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)
const gespeichertesLog: iCallLog = {
id: currentLog.id,
projekt_id: projektVoreingestellt
? currentLog.projekt_id
: parseInt((formData.get("projekt_id_select") as string) || "-1"),
// Pflichtfeld: voreingestellt aus dem currentLog, sonst
// aus dem Select-Widget (kontaktadresse_id ist im Server
// ein Pflichtfeld, ohne wird Bad Request geliefert).
kontaktadresse_id: ansprechpartnerVoreingestellt
? currentLog.kontaktadresse_id
: parseInt((formData.get("kontaktadresse_id") as string) || "-1"),
// Ersteller wird serverseitig aus current_user.id
// gesetzt — hier nur Platzhalter, mitarbeiter_name
// wird ausschließlich vom Server geliefert.
user_id: null,
mitarbeiter_name: null,
projekt_name: "",
kunden_name: "",
datum: currentLog.datum || "",
uhrzeit_beginn: (formData.get("uhrzeit_beginn") as string) || "",
dauer: (formData.get("dauer") as string) || "",
betreff: (formData.get("betreff") as string) || "",
name_ansprechpartner: (formData.get("name_ansprechpartner") as string) || "",
telefon: (formData.get("telefon") as string) || "",
notiz: (formData.get("notiz") as string) || "",
wiedervorlage_datum: currentLog.wiedervorlage_datum || "",
tag_ids: formData.getAll("tag_ids").map((v) => parseInt(v as string)).filter((n) => !isNaN(n))
}
if (currentLog.id === -1) {
createCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: currentLog.id === -1 ? "Neue Gesprächsnotiz" : "Gesprächsnotiz",
onnavigate: guardedNavigate
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, guard.formAttrs(submit) as iForm, [
projektVoreingestellt
? m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id})
: null,
// Kunde und Projekt: vorbelegt als Read-Only-Text,
// nicht vorbelegt als auswählbare Select-Widgets.
projektVoreingestellt
? [
m("label", "Kunde",
m("input.w3-input.w3-border", {
type: "text",
value: currentLog.kunden_name,
readonly: true,
style: {'margin-bottom': '5px'}
})),
m("label", "Projekt",
m("input.w3-input.w3-border", {
type: "text",
value: currentLog.projekt_name,
readonly: true,
style: {'margin-bottom': '5px'}
}))
]
: [
m("label", "Kunde", m(Select, {
name: "kunden_id",
value: selectedKundenId,
options: kundenOptionen,
required: true,
onchanged: (kundenId: number) => {
selectedKundenId = kundenId
// Projekt zurücksetzen, da es zum
// alten Kunden gehörte.
currentLog.projekt_id = -1
currentLog.projekt_name = ""
currentLog.kunden_name = kundenListe.find(k => k.id === kundenId)?.name || ""
guard.markDirty()
}
} as iSelect)),
m("label", "Projekt", m(Select, {
name: "projekt_id_select",
value: currentLog.projekt_id,
options: projektOptionen,
required: true,
onchanged: (projektId: number) => {
const p = projektListe.find(pr => pr.id === projektId)
currentLog.projekt_id = projektId
currentLog.projekt_name = p?.projektname || ""
currentLog.kunden_name = p?.kunden_name || ""
guard.markDirty()
}
} as iSelect))
],
// Datum, Startzeit und Dauer in einer Zeile.
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
m("label", {style: {flex: '1'}}, "Datum",
m(Input, {
name: "datum",
value: currentLog.datum,
type: "date",
oninput: (e: Event) => {
currentLog.datum = (e.target as HTMLInputElement).value
guard.markDirty()
}
} as iInput)),
m("label", {style: {flex: '1'}}, "Uhrzeit Beginn",
m(Input, {name: "uhrzeit_beginn", value: currentLog.uhrzeit_beginn, type: "time"} as iInput)),
m("label", {style: {flex: '1'}}, "Dauer (mm:ss)",
m("input.w3-input.w3-border", {
name: "dauer",
type: "text",
value: currentLog.dauer,
oncreate: (vnode: m.VnodeDOM) => {
dauerInput = vnode.dom as HTMLInputElement
}
}))
),
m("label", "Betreff", m(Input, {name: "betreff", value: currentLog.betreff, type: "text"} as iInput)),
// Ansprechpartner und Telefon in einer Zeile.
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
m("label", {style: {flex: '1'}}, "Name Ansprechpartner",
ansprechpartnerVoreingestellt
? m(Input, {name: "name_ansprechpartner", value: currentLog.name_ansprechpartner, type: "text", readonly: true} as iInput)
: m(Select, {
name: "kontaktadresse_id",
value: -1,
options: kontaktadressenOptionen,
required: true,
onchanged: (kontaktadresseId: number) => {
const ka = kontaktadressenListe.find((k: iKontaktadresse) => k.id === kontaktadresseId)
if (ka) {
currentLog.name_ansprechpartner = ka.name
currentLog.telefon = ka.telefon
guard.markDirty()
}
}
} as iSelect)),
m("label", {style: {flex: '1'}}, "Telefon",
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",
onchanged: (newSelection: number[]) => {
selectedTagIds.splice(0, selectedTagIds.length)
Array.prototype.push.apply(selectedTagIds, newSelection)
const vorschlag = berechneWiedervorlageDatum(
newSelection,
tagListe,
currentLog.datum || new Date().toISOString().split('T')[0]
)
if (vorschlag) {
currentLog.wiedervorlage_datum = vorschlag
}
guard.markDirty()
}
} as iMultiSelect)),
m("label", "Wiedervorlage Datum", m(Input, {
name: "wiedervorlage_datum",
value: currentLog.wiedervorlage_datum,
type: "date",
oninput: (e: Event) => {
currentLog.wiedervorlage_datum = (e.target as HTMLInputElement).value
guard.markDirty()
}
} as iInput))
])
)
)
}
}
}