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:
1 parent
5abcb2d96c
commit
d30adf1579
6 files changed
+421
-9
No files matched your search
@@ -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")
|
||||
])
|
||||
|
||||
@@ -207,3 +207,271 @@ export const Select: m.ClosureComponent<iSelect> = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import m from "mithril"
|
||||
import {iTag} from "./tag"
|
||||
|
||||
/**
|
||||
* Datenmodell für eine Gesprächsnotiz (Call-Log).
|
||||
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
||||
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
|
||||
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
|
||||
* tags (vom Server beim Lesen) und tag_ids (vom Client beim Schreiben)
|
||||
* steuern die n:m-Beziehung zu den Tags.
|
||||
*/
|
||||
export interface iCallLog {
|
||||
id: number
|
||||
@@ -20,6 +23,10 @@ export interface iCallLog {
|
||||
notiz: string
|
||||
wiedervorlage_datum: string
|
||||
erledigt: boolean
|
||||
/** Vom Server gelieferte zugeordnete Tags (nur beim Lesen). */
|
||||
tags?: iTag[]
|
||||
/** Vom Client zu übermittelnde Tag-IDs (nur beim Schreiben). */
|
||||
tag_ids?: number[]
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Reference in new issue
Block a user