Files
kbcrm/Client/Components/widgets/select.ts
T
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

585 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import m from "mithril"
/**
* Option für das Select-Widget.
*/
export interface iSelectOption {
/** Wert, der im Formular übermittelt wird (z.B. kundenId). */
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
}
/**
* Der Vnode der Komponente 'Select' akzeptiert die folgenden Parameter:
* @property {string} name - Der Name des Feldes (für FormData)
* @property {any} value - Der aktuell gewählte Wert (value der Option)
* @property {iSelectOption[]} options - Die zur Auswahl stehenden Optionen
* @property {boolean} readonly (optional) - Auswahl gesperrt
* @property {boolean} required (optional) - Pflichtfeld
* @property {Function} onchanged (optional) - Callback bei Änderung
*/
export interface iSelect {
name: string
value: any
options: iSelectOption[]
readonly?: boolean
required?: boolean
onchanged?: Function
}
/**
* Suchbares Select-Widget (Combobox). Ein sichtbares Texteingabefeld dient
* gleichzeitig als Suchfeld: während der Eingabe wird die aufklappbare
* Optionliste gefiltert — nur Optionen, deren label die eingegebene
* Zeichenfolge (case-insensitiv, als Teilstring) enthält, bleiben sichtbar.
* Wählt der Benutzer einen Eintrag, wird der interne Wert auf dessen
* option.value gesetzt und das Eingabefeld zeigt option.label.
*
* Für die Formularübergabe wird zusätzlich ein verstecktes input-Element
* gerendert, das den echten value trägt und über FormData ausgelesen werden
* kann. So funktioniert die Suche unabhängig vom übergebenen Wert.
*/
export const Select: m.ClosureComponent<iSelect> = () => {
// Interner Zustand: Suchtext, Dropdown geöffnet?, markierter Eintrag.
let search: string = ""
let isOpen: boolean = false
let highlightIndex: number = -1
// Referenz auf das Eingabefeld für Fokus nach Clear.
let inputfield: HTMLInputElement | null = null
// Zuletzt im Suchtext dargestellter value. Dient der Synchronisation:
// Der Anzeigetext wird beim Initialisieren und bei der Optionsauswahl
// gesetzt; ändert sich der attrs.value von außen (z.B. Zurücksetzen
// eines Filters im Eltern-View), wird der Text im view nachgezogen.
let lastSyncedValue: any = undefined
// True, solange der attrs.value noch keinem label zugeordnet werden
// konnte — typischerweise weil die Optionen asynchron geladen werden
// und beim oninit noch leer waren. Der view wiederholt die Synchro-
// nisierung dann, bis eine passende Option existiert.
let syncPending: boolean = false
// Zeigt die Optionliste ungefiltert an, unabhängig vom Suchtext.
// Wird vom Öffnen-Button gesetzt (neben dem Clear-Button) und beim
// Tippen, bei der Auswahl und mit Escape zurückgesetzt.
let showAll: boolean = false
/**
* Liefert die gefilterten Optionen abhängig vom aktuellen Suchtext.
*/
const getFilteredOptions = (options: iSelectOption[]): iSelectOption[] => {
const searchLower = search.toLowerCase()
if (searchLower === "") {
return options
}
return options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower))
}
/**
* Setzt den Suchtext auf das label des übergebenen Werts und merkt
* sich diesen als synchronisiert. Wird beim Initialisieren, bei der
* Optionsauswahl und bei externen value-Änderungen aufgerufen.
*/
const syncSearchZumValue = (value: any, options: iSelectOption[]) => {
const option = options.find((o: iSelectOption) => o.value === value)
search = option ? option.label : ""
lastSyncedValue = value
// Konnte der value keinem label zugeordnet werden, bleibt die
// Synchronisierung offen und wird im view wiederholt, sobald
// die Optionen (asynchron) nachgeladen wurden.
syncPending = !option
}
/**
* Wählt eine Option aus: value setzen, Suchtext = label, Dropdown zu.
*/
const selectOption = (option: iSelectOption, vnode: m.Vnode<iSelect>) => {
vnode.attrs.value = option.value
syncSearchZumValue(option.value, vnode.attrs.options)
isOpen = false
showAll = false
highlightIndex = -1
if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(option.value)
}
}
/**
* Setzt die Markierung auf den ersten Eintrag, wenn das Dropdown
* geöffnet wird.
*/
const openDropdown = (options: iSelectOption[], isReadonly: boolean) => {
if (!isReadonly) {
isOpen = true
highlightIndex = getFilteredOptions(options).length > 0 ? 0 : -1
}
}
return {
oninit: (vnode) => {
// Aktuelles label zum value ermitteln (für die Anzeige im Feld).
syncSearchZumValue(vnode.attrs.value, vnode.attrs.options)
},
view: (vnode) => {
// Extern geänderte value nachziehen: Der Suchtext wird nur beim
// Initialisieren und bei der Optionsauswahl gesetzt. Ändert der
// Eltern-View den attrs.value (z.B. Zurücksetzen eines Filters),
// wird der angezeigte Text hier auf das neue label synchronisiert.
if (vnode.attrs.value !== lastSyncedValue || syncPending) {
syncSearchZumValue(vnode.attrs.value, vnode.attrs.options)
}
const isReadonly = vnode.attrs.readonly === true
const filteredOptions = getFilteredOptions(vnode.attrs.options)
// Angezeigt wird die ungefilterte Liste, wenn der Öffnen-Button
// genutzt wurde, sonst die vom Suchtext gefilterte.
const angezeigteOptionen = showAll ? vnode.attrs.options : filteredOptions
const inputAttrs: any = {
type: "text",
value: search,
style: {'margin-bottom': '5px', width: '100%', 'padding-right': '58px'},
oncreate: (vnode: m.VnodeDOM) => {
inputfield = vnode.dom as HTMLInputElement
},
oninput: (e: Event) => {
if (isReadonly) { return }
search = (e.target as HTMLInputElement).value
// Der Nutzer tippt selbst — eine offene Synchronisierung
// (asynchron nachgeladene Optionen) nicht mehr nachziehen.
syncPending = false
// Tippen sucht wieder im Suchtext-Basis.
showAll = false
isOpen = true
highlightIndex = filteredOptions.length > 0 ? 0 : -1
},
onfocus: (e: Event) => {
openDropdown(vnode.attrs.options, isReadonly)
},
onblur: (e: Event) => {
// Verzögert schließen, damit ein Klick auf einen Eintrag
// vorher registriert wird.
setTimeout(() => { isOpen = false }, 150)
},
onkeydown: (e: KeyboardEvent) => {
if (isReadonly) { return }
if (angezeigteOptionen.length === 0) { return }
if (e.key === "ArrowDown") {
e.preventDefault()
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
highlightIndex = (highlightIndex + 1) % angezeigteOptionen.length
} else if (e.key === "ArrowUp") {
e.preventDefault()
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
highlightIndex = (highlightIndex - 1 + angezeigteOptionen.length) % angezeigteOptionen.length
} else if (e.key === "Enter") {
if (isOpen && highlightIndex >= 0 && highlightIndex < angezeigteOptionen.length) {
e.preventDefault()
selectOption(angezeigteOptionen[highlightIndex], vnode)
}
} else if (e.key === "Escape") {
isOpen = false
showAll = false
highlightIndex = -1
}
}
}
if (isReadonly) {
inputAttrs.readonly = "true"
}
if (vnode.attrs.required === true) {
inputAttrs.required = true
}
return m("div", {style: {width: '100%', position: 'relative'}},
// Verstecktes Feld für die Formularübergabe (echter value).
m("input", {type: "hidden", name: vnode.attrs.name, value: vnode.attrs.value}),
// Sichtbares Such-/Anzeigefeld mit Öffnen- und Clear-Button darin.
m("div", {style: {position: 'relative', width: '100%'}},
m("input.w3-input.w3-border", inputAttrs),
!isReadonly
? [
// Öffnen-Button: blendet die Optionliste ungefiltert
// ein, ohne das Feld zu leeren — der aktuelle Wert
// bleibt stehen, bis eine neue Option gewählt wird.
m("button.w3-button", {
type: "button",
title: "Liste öffnen (alle Optionen anzeigen)",
style: {
position: 'absolute',
right: '28px',
top: '50%',
transform: 'translateY(-50%)',
'min-width': '24px',
height: '24px',
padding: '2px',
'line-height': '1'
},
// mousedown mit preventDefault: hält den Fokus
// auf dem Eingabefeld (kein Blur-Rennen mit dem
// verzögerten Schließen) und öffnet/schließt
// die ungefilterte Liste. Klick bei offener
// Gesamtliste schließt sie wieder (Toggle).
onmousedown: (e: Event) => {
e.preventDefault()
if (isOpen && showAll) {
isOpen = false
showAll = false
highlightIndex = -1
} else {
showAll = true
isOpen = true
highlightIndex = 0
}
}
}, m("i.material-icons", {style: {'font-size': '20px'}}, "arrow_drop_down")),
// Clear-Button: leert das Suchfeld.
// Absolut positioniert, sodass der Button optisch im
// Eingabefeld liegt und vertikal zentriert ist.
m("button.w3-button", {
type: "button",
style: {
position: 'absolute',
right: '4px',
top: '50%',
transform: 'translateY(-50%)',
'min-width': '24px',
height: '24px',
padding: '2px',
'line-height': '1'
},
onclick: () => {
search = ""
isOpen = true
highlightIndex = filteredOptions.length > 0 ? 0 : -1
if (inputfield) { inputfield.focus() }
}
}, m("i.material-icons", {style: {'font-size': '20px'}}, "clear"))
]
: null
),
// Gefilterte, aufklappbare Optionliste. Eigene Positionierung,
// da die w3-dropdown-content-Klasse das Element per CSS
// ausblendet (display:none) und nur bei :hover anzeigt.
isOpen && !isReadonly
? m("div.w3-bar-block.w3-border.w3-theme-l4", {
style: {
width: '95%',
'max-height': '220px',
'overflow-y': 'auto',
position: 'absolute',
'z-index': '1000',
'box-shadow': '2px 2px 4px darkgrey'
}
},
angezeigteOptionen.length === 0
? m("div.w3-padding-small", "Keine Treffer")
: angezeigteOptionen.map((option: iSelectOption, index: number) =>
m("a.w3-bar-item.w3-button.w3-text-left", {
style: {whiteSpace: 'normal'},
class: index === highlightIndex ? "w3-theme" : "w3-hover-light-grey",
// mousedown statt click: verhindert, dass das
// Eingabefeld den Fokus verliert, und schließt
// das Dropdown zuverlässig nach der Auswahl.
onmousedown: (e: Event) => {
e.preventDefault()
selectOption(option, vnode)
}
}, option.label)
)
)
: null
)
}
}
}
/**
* 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. 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
}
}),
// 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)
}
}),
// 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: {
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
)
}
}
}