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>
478 lines
22 KiB
TypeScript
478 lines
22 KiB
TypeScript
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
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
|
||
/**
|
||
* 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))
|
||
}
|
||
|
||
/**
|
||
* Wählt eine Option aus: value setzen, Suchtext = label, Dropdown zu.
|
||
*/
|
||
const selectOption = (option: iSelectOption, vnode: m.Vnode<iSelect>) => {
|
||
vnode.attrs.value = option.value
|
||
search = option.label
|
||
isOpen = 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).
|
||
const initialOption = vnode.attrs.options.find((o: iSelectOption) => o.value === vnode.attrs.value)
|
||
search = initialOption ? initialOption.label : ""
|
||
},
|
||
view: (vnode) => {
|
||
const isReadonly = vnode.attrs.readonly === true
|
||
const filteredOptions = getFilteredOptions(vnode.attrs.options)
|
||
const inputAttrs: any = {
|
||
type: "text",
|
||
value: search,
|
||
style: {'margin-bottom': '5px', width: '100%', 'padding-right': '32px'},
|
||
oncreate: (vnode: m.VnodeDOM) => {
|
||
inputfield = vnode.dom as HTMLInputElement
|
||
},
|
||
oninput: (e: Event) => {
|
||
if (isReadonly) { return }
|
||
search = (e.target as HTMLInputElement).value
|
||
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 (filteredOptions.length === 0) { return }
|
||
if (e.key === "ArrowDown") {
|
||
e.preventDefault()
|
||
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
|
||
highlightIndex = (highlightIndex + 1) % filteredOptions.length
|
||
} else if (e.key === "ArrowUp") {
|
||
e.preventDefault()
|
||
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
|
||
highlightIndex = (highlightIndex - 1 + filteredOptions.length) % filteredOptions.length
|
||
} else if (e.key === "Enter") {
|
||
if (isOpen && highlightIndex >= 0 && highlightIndex < filteredOptions.length) {
|
||
e.preventDefault()
|
||
selectOption(filteredOptions[highlightIndex], vnode)
|
||
}
|
||
} else if (e.key === "Escape") {
|
||
isOpen = 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 Clear-Button darin.
|
||
m("div", {style: {position: 'relative', width: '100%'}},
|
||
m("input.w3-input.w3-border", inputAttrs),
|
||
// Clear-Button: leert das Suchfeld (nur wenn nicht readonly).
|
||
// Absolut positioniert, sodass der Button optisch im
|
||
// Eingabefeld liegt und vertikal zentriert ist.
|
||
!isReadonly
|
||
? m("button.w3-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'
|
||
}
|
||
},
|
||
filteredOptions.length === 0
|
||
? m("div.w3-padding-small", "Keine Treffer")
|
||
: filteredOptions.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.
|
||
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
|
||
)
|
||
}
|
||
}
|
||
}
|