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
@@ -259,12 +259,19 @@ class CallLog(dbapp.Model):
|
|||||||
erledigt = Column(Boolean, nullable=False, default=False)
|
erledigt = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
projekt = relationship('Projekt')
|
projekt = relationship('Projekt')
|
||||||
|
# n:m-Beziehung zu Tags über die Verknüpfungstabelle call_logs_tags.
|
||||||
|
# cascade='all, delete-orphan' stellt sicher, dass die Zuordnungen
|
||||||
|
# automatisch gelöscht werden, wenn die Gesprächsnotiz gelöscht wird.
|
||||||
|
tags_rel = relationship(
|
||||||
|
'CallLogTag', cascade='all, delete-orphan', back_populates='call_log'
|
||||||
|
)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""
|
"""
|
||||||
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
|
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
|
||||||
über columns2dict serialisiert; projekt_name und kunden_name werden
|
über columns2dict serialisiert; projekt_name und kunden_name werden
|
||||||
zusätzlich aus der Projekt-Relationship eingefügt.
|
zusätzlich aus der Projekt-Relationship eingefügt. Die zugeordneten
|
||||||
|
Tags werden als Liste von Tag-Dictionaries unter 'tags' ergänzt.
|
||||||
"""
|
"""
|
||||||
result = columns2dict(self)
|
result = columns2dict(self)
|
||||||
if self.projekt:
|
if self.projekt:
|
||||||
@@ -273,9 +280,33 @@ class CallLog(dbapp.Model):
|
|||||||
else:
|
else:
|
||||||
result['projekt_name'] = None
|
result['projekt_name'] = None
|
||||||
result['kunden_name'] = None
|
result['kunden_name'] = None
|
||||||
|
# Zugeordnete Tags serialisieren (nur is_parent=False sind zuordenbar).
|
||||||
|
result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class CallLogTag(dbapp.Model):
|
||||||
|
"""
|
||||||
|
Verknüpfungstabelle für die n:m-Beziehung zwischen Gesprächsnotizen
|
||||||
|
(CallLog) und Tags. Jede Zeile verknüpft eine Gesprächsnotiz mit einem
|
||||||
|
Tag. Der UniqueConstraint stellt sicher, dass jeder Tag nur einmal pro
|
||||||
|
Gesprächsnotiz zugeordnet werden kann.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'call_logs_tags'
|
||||||
|
__bind_key__ = BINDKEY
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('call_log_id', 'tag_id', name='uq_calllog_tag'),
|
||||||
|
Index('ix_calllogtag_call_log_id', 'call_log_id'),
|
||||||
|
Index('ix_calllogtag_tag_id', 'tag_id'),
|
||||||
|
)
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
call_log_id = Column(Integer, ForeignKey('call_logs.id'), nullable=False)
|
||||||
|
tag_id = Column(Integer, ForeignKey('tags.id'), nullable=False)
|
||||||
|
|
||||||
|
call_log = relationship('CallLog', back_populates='tags_rel')
|
||||||
|
tag = relationship('Tag')
|
||||||
|
|
||||||
|
|
||||||
class Tag(dbapp.Model):
|
class Tag(dbapp.Model):
|
||||||
"""
|
"""
|
||||||
Hierarchischer Tag (Taxonomie). Tags koennen beliebig verschachtelt
|
Hierarchischer Tag (Taxonomie). Tags koennen beliebig verschachtelt
|
||||||
|
|||||||
@@ -9,14 +9,68 @@ dort über den Decorator authorisation_required.
|
|||||||
:date: 2026-09-04
|
:date: 2026-09-04
|
||||||
:version: 1.0
|
:version: 1.0
|
||||||
"""
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from db import dbapp
|
from db import dbapp
|
||||||
from crm.model import CallLog, Projekt, ProjektUser
|
from crm.model import CallLog, CallLogTag, Projekt, ProjektUser, Tag
|
||||||
|
|
||||||
from auth.model.errors import ApplicationError, HtmlStatusCode
|
from auth.model.errors import ApplicationError, HtmlStatusCode
|
||||||
|
|
||||||
session = dbapp.session
|
session = dbapp.session
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: str | None) -> date | None:
|
||||||
|
"""
|
||||||
|
Wandelt einen ISO-Datum-String (yyyy-MM-dd) in ein date-Objekt um.
|
||||||
|
Leere Strings und None werden zu None konvertiert (Spalte ist nullable).
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _set_call_log_tags(call_log: CallLog, tag_ids: list) -> None:
|
||||||
|
"""
|
||||||
|
Ersetzt die Tag-Zuordnungen einer Gesprächsnotiz durch die übergebene
|
||||||
|
Liste von Tag-IDs. Bestehende Zuordnungen werden gelöscht, danach werden
|
||||||
|
die neuen Zuordnungen angelegt.
|
||||||
|
|
||||||
|
Nur Tags, die existieren und nicht reine Gruppierungsknoten
|
||||||
|
(is_parent=True) sind, werden zugeordnet. Duplikate in der Liste
|
||||||
|
werden durch ein Set eliminiert.
|
||||||
|
"""
|
||||||
|
# Bestehende Zuordnungen entfernen.
|
||||||
|
for clt in list(call_log.tags_rel):
|
||||||
|
session.delete(clt)
|
||||||
|
call_log.tags_rel = []
|
||||||
|
# Neue Zuordnungen anlegen.
|
||||||
|
seen = set()
|
||||||
|
for tag_id in tag_ids or []:
|
||||||
|
try:
|
||||||
|
tag_id = int(tag_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if tag_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(tag_id)
|
||||||
|
tag = Tag.query.get(tag_id)
|
||||||
|
if tag is None or tag.is_parent:
|
||||||
|
# Nicht zuordenbar — überspringen.
|
||||||
|
continue
|
||||||
|
clt = CallLogTag()
|
||||||
|
clt.call_log = call_log
|
||||||
|
clt.tag = tag
|
||||||
|
session.add(clt)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
|
def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
|
||||||
"""
|
"""
|
||||||
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
|
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
|
||||||
@@ -94,15 +148,16 @@ def create_call_log(data: dict) -> CallLog:
|
|||||||
)
|
)
|
||||||
call_log = CallLog()
|
call_log = CallLog()
|
||||||
call_log.projekt_id = int(projekt_id)
|
call_log.projekt_id = int(projekt_id)
|
||||||
call_log.datum = (data.get("datum") or "").strip() or None
|
call_log.datum = _parse_date(data.get("datum"))
|
||||||
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
||||||
call_log.dauer = (data.get("dauer") or "").strip() or None
|
call_log.dauer = (data.get("dauer") or "").strip() or None
|
||||||
call_log.betreff = (data.get("betreff") or "").strip() or None
|
call_log.betreff = (data.get("betreff") or "").strip() or None
|
||||||
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
|
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
|
||||||
call_log.telefon = (data.get("telefon") or "").strip() or None
|
call_log.telefon = (data.get("telefon") or "").strip() or None
|
||||||
call_log.notiz = (data.get("notiz") or "").strip() or None
|
call_log.notiz = (data.get("notiz") or "").strip() or None
|
||||||
call_log.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
|
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
|
||||||
call_log.erledigt = bool(data.get("erledigt", False))
|
call_log.erledigt = bool(data.get("erledigt", False))
|
||||||
|
_set_call_log_tags(call_log, data.get("tag_ids"))
|
||||||
session.add(call_log)
|
session.add(call_log)
|
||||||
session.commit()
|
session.commit()
|
||||||
return call_log
|
return call_log
|
||||||
@@ -127,15 +182,16 @@ def update_call_log(data: dict) -> CallLog:
|
|||||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||||
)
|
)
|
||||||
call_log.projekt_id = int(projekt_id)
|
call_log.projekt_id = int(projekt_id)
|
||||||
call_log.datum = (data.get("datum") or "").strip() or None
|
call_log.datum = _parse_date(data.get("datum"))
|
||||||
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
||||||
call_log.dauer = (data.get("dauer") or "").strip() or None
|
call_log.dauer = (data.get("dauer") or "").strip() or None
|
||||||
call_log.betreff = (data.get("betreff") or "").strip() or None
|
call_log.betreff = (data.get("betreff") or "").strip() or None
|
||||||
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
|
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
|
||||||
call_log.telefon = (data.get("telefon") or "").strip() or None
|
call_log.telefon = (data.get("telefon") or "").strip() or None
|
||||||
call_log.notiz = (data.get("notiz") or "").strip() or None
|
call_log.notiz = (data.get("notiz") or "").strip() or None
|
||||||
call_log.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
|
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
|
||||||
call_log.erledigt = bool(data.get("erledigt", False))
|
call_log.erledigt = bool(data.get("erledigt", False))
|
||||||
|
_set_call_log_tags(call_log, data.get("tag_ids"))
|
||||||
session.commit()
|
session.commit()
|
||||||
return call_log
|
return call_log
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,17 @@ def _db_V01_10():
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _db_V01_11():
|
||||||
|
"""
|
||||||
|
Legt die Verknüpfungstabelle 'call_logs_tags' für die n:m-Beziehung
|
||||||
|
zwischen Gesprächsnotizen und Tags an. create_all erzeugt nur fehlende
|
||||||
|
Tabellen, bestehende bleiben unangetastet (idempotent).
|
||||||
|
"""
|
||||||
|
dbapp.create_all(bind_key=BINDKEY)
|
||||||
|
_updatesysteminfo('01.11')
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
def checkupdates():
|
def checkupdates():
|
||||||
"""
|
"""
|
||||||
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
|
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import m from "mithril"
|
import m from "mithril"
|
||||||
import {Form, iForm} from "./widgets/form"
|
import {Form, iForm} from "./widgets/form"
|
||||||
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
|
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 {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||||
import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCallLogREST} from "../models/call_log"
|
import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCallLogREST} from "../models/call_log"
|
||||||
import {iKunde, fetchKunden} from "../models/kunde"
|
import {iKunde, fetchKunden} from "../models/kunde"
|
||||||
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
|
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
|
||||||
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
|
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
|
||||||
|
import {iTag, fetchTags} from "../models/tag"
|
||||||
import {showServerError} from "./auth/authmessages"
|
import {showServerError} from "./auth/authmessages"
|
||||||
|
|
||||||
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
|
// 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) => {
|
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
||||||
let log: iCallLog = node.nodevalue
|
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 [
|
return [
|
||||||
m("div", m("strong", log.kunden_name + ": " + log.projekt_name)),
|
m("div", m("strong", log.kunden_name + ": " + log.projekt_name)),
|
||||||
m("span", log.datum),
|
m("span", log.datum),
|
||||||
m("span", log.uhrzeit_beginn),
|
m("span", log.uhrzeit_beginn),
|
||||||
m("span", log.dauer + " min"),
|
m("span", log.dauer + " min"),
|
||||||
m("span", log.betreff),
|
m("span", log.betreff, ...tagBadges),
|
||||||
m("span", log.wiedervorlage_datum),
|
m("span", log.wiedervorlage_datum),
|
||||||
m("span", log.erledigt ? "Erledigt" : "Offen"),
|
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"))
|
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 kundenListe: iKunde[] = []
|
||||||
let projektListe: iProjekt[] = []
|
let projektListe: iProjekt[] = []
|
||||||
let kontaktadressenListe: iKontaktadresse[] = []
|
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.
|
* Formatiert verstrichene Sekunden als mm:ss.
|
||||||
@@ -203,6 +224,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
currentLog = result
|
currentLog = result
|
||||||
projektVoreingestellt = true
|
projektVoreingestellt = true
|
||||||
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
|
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
|
||||||
|
selectedTagIds = (result.tags || []).map((t: iTag) => t.id)
|
||||||
}).catch((e) => showServerError(e))
|
}).catch((e) => showServerError(e))
|
||||||
} else {
|
} else {
|
||||||
projektVoreingestellt = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) !== -1 : false
|
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))
|
fetchKunden().then((result) => { kundenListe = result }).catch((e) => showServerError(e))
|
||||||
fetchProjekte().then((result) => { projektListe = result }).catch((e) => showServerError(e))
|
fetchProjekte().then((result) => { projektListe = result }).catch((e) => showServerError(e))
|
||||||
fetchKontaktadressen().then((result) => { kontaktadressenListe = 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) => {
|
onremove: (vnode) => {
|
||||||
if (timerId != null) { window.clearInterval(timerId) }
|
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 + ")" : "")}
|
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 submit = (form: HTMLFormElement): boolean => {
|
||||||
const formData = new FormData(form)
|
const formData = new FormData(form)
|
||||||
@@ -283,7 +312,8 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
telefon: (formData.get("telefon") as string) || "",
|
telefon: (formData.get("telefon") as string) || "",
|
||||||
notiz: (formData.get("notiz") as string) || "",
|
notiz: (formData.get("notiz") as string) || "",
|
||||||
wiedervorlage_datum: (formData.get("wiedervorlage_datum") 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) {
|
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))
|
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(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", "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", "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")
|
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 m from "mithril"
|
||||||
|
import {iTag} from "./tag"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Datenmodell für eine Gesprächsnotiz (Call-Log).
|
* Datenmodell für eine Gesprächsnotiz (Call-Log).
|
||||||
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
||||||
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
|
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
|
||||||
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
|
* 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 {
|
export interface iCallLog {
|
||||||
id: number
|
id: number
|
||||||
@@ -20,6 +23,10 @@ export interface iCallLog {
|
|||||||
notiz: string
|
notiz: string
|
||||||
wiedervorlage_datum: string
|
wiedervorlage_datum: string
|
||||||
erledigt: boolean
|
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