diff --git a/Client/Components/call_log.ts b/Client/Components/call_log.ts index 668bea4..36b528b 100644 --- a/Client/Components/call_log.ts +++ b/Client/Components/call_log.ts @@ -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 = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string])) diff --git a/Client/Components/tagbadge.ts b/Client/Components/tagbadge.ts new file mode 100644 index 0000000..28f2064 --- /dev/null +++ b/Client/Components/tagbadge.ts @@ -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 = () => { + 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) + } + } +} diff --git a/Client/Components/tages_workflow.ts b/Client/Components/tages_workflow.ts index 5ae6084..3b9d1ad 100644 --- a/Client/Components/tages_workflow.ts +++ b/Client/Components/tages_workflow.ts @@ -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 = 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 || "")) ) ) diff --git a/Client/Components/tags.ts b/Client/Components/tags.ts index 0609f0f..c7b6e6c 100644 --- a/Client/Components/tags.ts +++ b/Client/Components/tags.ts @@ -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)}, diff --git a/Client/Components/widgets/select.ts b/Client/Components/widgets/select.ts index ae064ea..76c5d19 100644 --- a/Client/Components/widgets/select.ts +++ b/Client/Components/widgets/select.ts @@ -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 = () => { 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,19 +537,33 @@ export const MultiSelect: m.ClosureComponent = () => { toggleDraft(option) } }), - color + // 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-block', - width: '10px', - height: '10px', - borderRadius: '50%', - backgroundColor: color, - flex: '0 0 auto' + display: 'inline-flex', + alignItems: 'center', + flex: '1 1 auto' } - }) - : null, - m("span", option.label) + }, 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) + ] ) }) ),