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>
This commit is contained in:
gg committed 2026-09-27 18:37:49 +02:00
1 parent 2ff4272ecd
commit bc9b337696
5 files changed
+146 -96

No files matched your search

+6 -14
View File
@@ -9,6 +9,7 @@ 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"
@@ -83,18 +84,7 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
// 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)
m(TagBadge, {tagData: t, compact: true})
)
return [
@@ -352,9 +342,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.
// 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}
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]))
+97
View File
@@ -0,0 +1,97 @@
import m from "mithril"
import {iTag} from "../models/tag"
/**
* Der Vnode der Komponente 'TagBadge' akzeptiert die folgenden Parameter:
* @property {iTag} tagData - Der darzustellende Tag. Achtung: Das Attribut
* darf nicht 'tag' heißen — Mithril behandelt ein attrs-
* Objekt mit einer 'tag'-Property als Vnode und verschiebt
* es in die children (siehe hyperscriptVnode.js).
* @property {boolean} compact (optional) - kompakte Darstellung für dichte
* Listen/Tabellen (kleinere Schrift, engeres Padding);
* Default ist die Standarddarstellung (fett)
* @property {string} title (optional) - Tooltip des Farbchips
* (Default: Tag-Name)
*/
export interface iTagBadge {
tagData: iTag
compact?: boolean
title?: string
}
/**
* Farbige Badge-Komponente für einen Tag: rendert den Tag-Namen als Chip in
* der Tag-Farbe und dahinter automatisch die Workflow-Zusatzicons —
* back_hand (rot) für erledigende Tags, schedule + Tagesabstand (blau) für
* Tags, die eine Wiedervorlage auslösen (jeweils mit Tooltip). Parent-Tags
* erhalten keine Zusatzicons.
*
* Die Komponente kapselt die Darstellung zentral, damit alle Stellen
* (Notiz-Listen, Workflow-Liste, Historie, Auswahl-Modals, Tag-Liste)
* einheitlich aussehen. Aufrufer geben nur den Tag an; Name, Farbe und
* Zusatzicons ergeben sich aus den Tag-Daten.
*/
export const TagBadge: m.ClosureComponent<iTagBadge> = () => {
return {
view: (vnode) => {
const tag = vnode.attrs.tagData
const compact = vnode.attrs.compact === true
// Farbiger Chip mit dem Tag-Namen. Bei fehlender Farbe grau.
const chip = m("span", {
style: {
backgroundColor: tag.color || '#999',
color: 'white',
padding: compact ? '1px 5px' : '2px 6px',
'border-radius': '3px',
'font-size': compact ? '11px' : undefined,
fontWeight: compact ? undefined : 'bold',
display: 'inline-block',
marginRight: '4px'
},
title: vnode.attrs.title || tag.name
}, tag.name)
// Workflow-Zusatzicons hinter dem Chip — fest programmiert aus
// den Tag-Flags, nicht per Attribut steuerbar. Parent-Tags sind
// nicht zuordenbar und erhalten daher keine Icons.
const icons: m.Child[] = []
if (!tag.is_parent) {
if (tag.is_erledigend) {
icons.push(m("i.material-icons", {
title: "Erledigend: Kontakt gilt als erledigt",
style: {
verticalAlign: 'middle',
fontSize: '16px',
color: '#d32f2f'
}
}, "back_hand"))
}
if (tag.loest_wiedervorlage_aus) {
icons.push(m("span", {
title: "Löst Wiedervorlage aus: " + (tag.wiedervorlage_abstand_tage ?? 0) + " Tage",
style: {
verticalAlign: 'middle',
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 m("span", {
style: {
display: 'inline-flex',
alignItems: 'center',
marginLeft: '4px',
whiteSpace: 'nowrap'
}
}, chip, icons)
}
}
}
+13 -26
View File
@@ -14,6 +14,7 @@ import {iProjekt, fetchProjektById} from "../models/projekt"
import {iCallLog, fetchCallLogsByKontaktadresse, createCallLogREST} from "../models/call_log"
import {iTag, fetchTags, berechneWiedervorlageDatum} from "../models/tag"
import {iTagesWorkflowEntry, iTagesWorkflowFilter, fetchTagesWorkflowList} from "../models/tages_workflow"
import {TagBadge} from "./tagbadge"
// ============================================================================
// MODUL-LEVEL WORKFLOW-KONTEXT
@@ -148,18 +149,7 @@ export const TagesWorkflowListe: m.ClosureComponent = () => {
// Badges der Tags des letzten Gesprächs (gleicher Stil wie in der
// Gesprächsnotizen-Liste).
const tagBadges = (eintrag.letzte_notiz_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)
m(TagBadge, {tagData: t, compact: true})
)
return [
m("div",
@@ -675,9 +665,13 @@ export const TagesWorkflowMaske: m.ClosureComponent = () => {
// selbst scrollbar.
formAttrs.flexContent = true
// Tag-Optionen und Farb-Map für die Mehrfachauswahl.
// 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 automatisch die Workflow-Icons
// (back_hand = erledigend, schedule + Tagesabstand = löst
// Wiedervorlage aus). label bleibt als Suchbasis gesetzt.
const tagOptionen: iSelectOption[] = tagListe.map((t: iTag) => {
return {value: t.id, label: t.name}
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]))
@@ -795,6 +789,10 @@ export const TagesWorkflowMaske: m.ClosureComponent = () => {
m("p", {style: {margin: '4px 0'}},
m("strong", "Kontakt: "),
kontaktadresse ? kontaktadresse.name : "",
// Position des Ansprechpartners in Klammern
// hinter dem Namen (nur wenn gesetzt).
kontaktadresse && kontaktadresse.position
? " (" + kontaktadresse.position + ")" : "",
kontaktadresse && kontaktadresse.telefon ? " | " + kontaktadresse.telefon : "",
kontaktadresse && kontaktadresse.email ? " | " + kontaktadresse.email : ""),
m("p", {style: {margin: '4px 0'}},
@@ -948,18 +946,7 @@ export const TagesWorkflowMaske: m.ClosureComponent = () => {
m("td", log.betreff || ""),
m("td", log.notiz || ""),
m("td", (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))),
m(TagBadge, {tagData: t, compact: true}))),
m("td", {style: {'white-space': 'nowrap'}}, log.wiedervorlage_datum || ""))
)
)
+4 -55
View File
@@ -7,6 +7,7 @@ import {gridMountpoints} from "./mainlayout"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
import {iTag, iTagWithChildren, fetchTags, fetchTagHierarchy, fetchTagById, createTagREST, updateTagREST, deleteTagREST} from "../models/tag"
import {TagBadge} from "./tagbadge"
const colorPalette: string[] = [
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
@@ -348,47 +349,6 @@ export const TagList: m.ClosureComponent = () => {
const tagIcon = tag.is_parent ? "folder" : "label"
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: '#d32f2f'
}
}, "back_hand")
)
}
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 [
m("span",
m("i.material-icons", {
@@ -399,20 +359,9 @@ export const TagList: m.ClosureComponent = () => {
},
title: tagTitle
}, tagIcon),
m("span", {
style: {
backgroundColor: tag.color || '#9e9e9e',
color: 'white',
padding: '2px 6px',
borderRadius: '3px',
fontWeight: 'bold',
display: 'inline-block',
marginRight: '8px'
},
title: tagTitle
}, tag.name),
workflowBadges
),
// Farbchip mit Name und Workflow-Icons (erledigend /
// Wiedervorlage) aus der zentralen TagBadge-Komponente.
m(TagBadge, {tagData: tag})),
m("span", tag.description),
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}},
m("button.w3-button.w3-small", {title: "Tag bearbeiten", style: {padding: '0 2px'}, onclick: (e: Event) => editTag(tag, e)},
+26 -1
View File
@@ -8,6 +8,11 @@ export interface iSelectOption {
value: any
/** Anzeige im Feld bzw. in der Dropdown-Liste (z.B. Kundenname). */
label: string
/** Optionaler fertiger Darstellungs-Content (z.B. eine Badge-Komponente),
* der im Auswahl-Modal des MultiSelect statt Farbpunkt und Label
* angezeigt wird. Das label bleibt davon unberührt — es ist weiterhin
* die Basis für die Suche im Modal. */
content?: m.Child
}
/**
@@ -491,12 +496,18 @@ export const MultiSelect: m.ClosureComponent<iMultiSelect> = () => {
m("h2", modalTitle)
),
m("div.w3-container.w3-theme-l1", {style: {padding: '16px'}},
// Suchfeld.
// Suchfeld. Der oncreate-Hook fokussiert das
// Feld direkt beim Öffnen des Modals — das
// Input wird ja erst mit dem Modal erzeugt,
// sodass der Nutzer sofort tippen kann.
m("input.w3-input.w3-border", {
type: "text",
value: search,
placeholder: "Suchbegriff …",
style: {'margin-bottom': '10px', width: '100%'},
oncreate: (vnode: m.VnodeDOM) => {
(vnode.dom as HTMLInputElement).focus()
},
oninput: (e: Event) => {
search = (e.target as HTMLInputElement).value
}
@@ -526,6 +537,19 @@ export const MultiSelect: m.ClosureComponent<iMultiSelect> = () => {
toggleDraft(option)
}
}),
// Ist ein fertiger content gesetzt (z.B. eine
// Badge-Komponente des Aufrufers), wird dieser
// statt Farbpunkt und Label angezeigt — das
// Widget selbst bleibt frei von Tag-Wissen.
option.content
? m("span", {
style: {
display: 'inline-flex',
alignItems: 'center',
flex: '1 1 auto'
}
}, option.content)
: [
color
? m("span", {
style: {
@@ -539,6 +563,7 @@ export const MultiSelect: m.ClosureComponent<iMultiSelect> = () => {
})
: null,
m("span", option.label)
]
)
})
),