Files
kbcrm/Client/Components/call_log.ts
T
gg 93827dde0f Serverseitige Filterung für Listen-Endpunkte und Einzelabrufe in Listen-Komponenten
Server: Die list-Endpunkte für Kontakt, Projekt, Kontaktadresse und CallLog
akzeptieren nun einen optionalen Query-Parameter (kunden_id bzw. projekt_id)
und filtern im ORM-Query. Neue Migration V01.05 legt Indizes auf den
FK-Spalten an.

Client: Die fetch-Funktionen erhalten einen optionalen Parent-ID-Parameter.
Die Listen-Komponenten übergeben die Parent-ID aus den Route-Params an den
Server und laden nur noch benötigte Daten. Statt kompletter Eltern-Listen
wird per fetchXxxById ein einzelner Datensatz für die Namensauflösung geladen.
Die CallLog-Bearbeitungsmaske behält ihre ungesfilterten Dropdown-Abrufe.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-04 15:43:43 +02:00

363 lines
18 KiB
TypeScript

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 {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"
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
const DAUER_UPDATE_INTERVALL = 1000
export const call_log_liste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
let filterProjektId: number | null = null
// Projektname für die Überschrift, asynchron per fetchProjektById
// aufgelöst. kunden_name wird ebenfalls aus dem Projekt bezogen,
// da es nicht in den Route-Params enthalten ist.
let projektName: string = ""
let kundenName: string = ""
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
const addCallLogRoute = () => {
const now = new Date()
const datum = now.toISOString().split('T')[0]
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
const params: any = {
id: -1,
datum: datum,
uhrzeit_beginn: uhrzeit,
dauer: "",
betreff: "",
name_ansprechpartner: "",
telefon: "",
notiz: "",
wiedervorlage_datum: "",
erledigt: false
}
if (filterProjektId !== null) {
params.projekt_id = filterProjektId
params.projekt_name = projektName
params.kunden_name = kundenName
}
m.route.set("/call_log", params)
}
const editCallLog = (log: iCallLog, e: Event) => {
e.stopPropagation()
m.route.set("/call_log", {id: log.id})
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let log: iCallLog = node.nodevalue
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.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"))
]
}
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let log: iCallLog = node.nodevalue
let searchLower = searchvalue.toLowerCase()
return (log.kunden_name || "").toLowerCase().includes(searchLower) ||
(log.projekt_name || "").toLowerCase().includes(searchLower) ||
(log.datum || "").includes(searchLower) ||
(log.uhrzeit_beginn || "").toLowerCase().includes(searchLower) ||
(log.betreff || "").toLowerCase().includes(searchLower) ||
(log.name_ansprechpartner || "").toLowerCase().includes(searchLower) ||
(log.notiz || "").toLowerCase().includes(searchLower) ||
(log.wiedervorlage_datum || "").includes(searchLower)
}
return {
oninit: (vnode) => {
const routeParams = m.route.param()
if (routeParams && routeParams.projekt_id) {
filterProjektId = parseInt(routeParams.projekt_id)
projektName = routeParams.projekt_name || ""
} else {
filterProjektId = null
projektName = ""
}
// Gesprächsnotizen asynchron vom Server laden. Die Filterung
// nach projekt_id erfolgt serverseitig, sofern ein Filter
// gesetzt ist.
fetchCallLogs(filterProjektId !== null ? filterProjektId : undefined).then((result) => {
treenodes = result.map((log: iCallLog) => new Treenode(log, true, []))
})
// Projektnamen und Kundennamen asynchron auflösen, falls
// nicht in den Route-Params enthalten.
if (filterProjektId !== null) {
fetchProjektById(filterProjektId).then((projekt: iProjekt) => {
if (projektName === "") {
projektName = projekt.projektname
}
kundenName = projekt.kunden_name
})
}
},
view: (vnode) => {
return m("div.w3-container.w3-auto", {
style: {
display: 'flex',
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", projektName ? "Gesprächsnotizen für " + projektName : "Gesprächsnotizen"),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
treenodes: treenodes,
columnmapper: columnmapper,
filter: filterFunction,
onRowMarked: handleRowMarked,
isSelected: (node: iTreenode) => node === selectedNode
} as iTreetable,
m(TopbarButton, {onclick: addCallLogRoute, tooltip: "Neue Gesprächsnotiz anlegen"} as iTopbarButton,
m("i.material-icons", "add_call")
)
)
)
)
}
}
}
export const Call_Log: m.ClosureComponent = (vnode) => {
// currentLog hält den Formularzustand. projekt_id referenziert das
// Projekt, kunden_name und projekt_name dienen der Anzeige.
let currentLog: {
id: number
projekt_id: number
projekt_name: string
kunden_name: string
datum: string
uhrzeit_beginn: string
dauer: string
betreff: string
name_ansprechpartner: string
telefon: string
notiz: string
wiedervorlage_datum: string
erledigt: boolean
} = {
id: -1,
projekt_id: -1,
projekt_name: "",
kunden_name: "",
datum: "",
uhrzeit_beginn: "",
dauer: "",
betreff: "",
name_ansprechpartner: "",
telefon: "",
notiz: "",
wiedervorlage_datum: "",
erledigt: false
}
// Ist das Projekt voreingestellt (Liste wurde projektgefiltert)?
let projektVoreingestellt: boolean = false
// Ist der Ansprechpartner voreingestellt (aus Kontaktadressenliste)?
let ansprechpartnerVoreingestellt: boolean = false
// Startzeit für die Dauer-Messung (nur bei Neuanlage gesetzt).
let startZeit: Date | null = null
// Timer-Handle für die Dauer-Aktualisierung.
let timerId: number | null = null
// Direkte DOM-Referenz auf das Dauer-Feld.
let dauerInput: HTMLInputElement | null = null
// Asynchron geladene Listen für Select-Optionen.
let kundenListe: iKunde[] = []
let projektListe: iProjekt[] = []
let kontaktadressenListe: iKontaktadresse[] = []
/**
* Formatiert verstrichene Sekunden als mm:ss.
*/
const formatDauer = (sekunden: number): string => {
const min = Math.floor(sekunden / 60)
const sek = sekunden % 60
return min.toString().padStart(2, '0') + ':' + sek.toString().padStart(2, '0')
}
return {
oninit: (vnode) => {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
if (id !== -1) {
// Gesprächsnotiz frisch vom Server holen.
fetchCallLogById(id).then((result) => {
currentLog = result
projektVoreingestellt = true
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
})
} else {
projektVoreingestellt = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) !== -1 : false
currentLog.projekt_id = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) : -1
currentLog.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
currentLog.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
currentLog.datum = routeParams && routeParams.datum ? routeParams.datum : ""
currentLog.uhrzeit_beginn = routeParams && routeParams.uhrzeit_beginn ? routeParams.uhrzeit_beginn : ""
currentLog.dauer = routeParams && routeParams.dauer ? routeParams.dauer : ""
currentLog.betreff = routeParams && routeParams.betreff ? routeParams.betreff : ""
currentLog.name_ansprechpartner = routeParams && routeParams.name_ansprechpartner ? routeParams.name_ansprechpartner : ""
currentLog.telefon = routeParams && routeParams.telefon ? routeParams.telefon : ""
currentLog.notiz = routeParams && routeParams.notiz ? routeParams.notiz : ""
currentLog.wiedervorlage_datum = routeParams && routeParams.wiedervorlage_datum ? routeParams.wiedervorlage_datum : ""
currentLog.erledigt = routeParams && (routeParams.erledigt === true || routeParams.erledigt === "true")
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
// Bei Neuanlage Startzeit merken und Dauer-Timer starten.
startZeit = new Date()
timerId = window.setInterval(() => {
if (startZeit) {
const sekunden = Math.floor((Date.now() - startZeit.getTime()) / 1000)
const dauer = formatDauer(sekunden)
currentLog.dauer = dauer
if (dauerInput) { dauerInput.value = dauer }
}
}, DAUER_UPDATE_INTERVALL)
}
// Alle Listen asynchron laden, für Select-Optionen.
fetchKunden().then((result) => { kundenListe = result })
fetchProjekte().then((result) => { projektListe = result })
fetchKontaktadressen().then((result) => { kontaktadressenListe = result })
},
onremove: (vnode) => {
if (timerId != null) { window.clearInterval(timerId) }
},
view: (vnode) => {
// kunden_id aus dem geladenen Projekt ableiten (fuer abhaengiges
// Projekt-Select). Wird nur bei nicht voreingestelltem Projekt
// benoetigt.
const internKundenId = (() => {
if (currentLog.projekt_id !== -1) {
const p = projektListe.find((p: iProjekt) => p.id === currentLog.projekt_id)
return p ? p.kunden_id : -1
}
return -1
})()
const kundenOptionen: iSelectOption[] = kundenListe.map((k: iKunde) => {
return {value: k.id, label: k.name}
})
// Projekt-Optionen sind vom gewählten Kunden abhängig.
const projektOptionen: iSelectOption[] = projektListe
.filter((p: iProjekt) => internKundenId === -1 || p.kunden_id === internKundenId)
.map((p: iProjekt) => {
return {value: p.id, label: p.projektname}
})
// Kontaktadressen-Optionen sind vom gewählten Projekt abhängig.
const kontaktadressenOptionen: iSelectOption[] = !ansprechpartnerVoreingestellt
? kontaktadressenListe
.filter((ka: iKontaktadresse) => ka.projekt_id === currentLog.projekt_id)
.map((ka: iKontaktadresse) => {
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
})
: []
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
const gespeichertesLog: iCallLog = {
id: currentLog.id,
projekt_id: currentLog.projekt_id,
projekt_name: "",
kunden_name: "",
datum: (formData.get("datum") as string) || "",
uhrzeit_beginn: (formData.get("uhrzeit_beginn") as string) || "",
dauer: (formData.get("dauer") as string) || "",
betreff: (formData.get("betreff") as string) || "",
name_ansprechpartner: (formData.get("name_ansprechpartner") as string) || "",
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"
}
if (currentLog.id === -1) {
createCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name}))
} else {
updateCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name}))
}
return true
}
const cancel = (e: Event) => {
m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})
}
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}),
// Kunde und Projekt als Read-Only-Text, da voreingestellt.
m("label", "Kunde",
m("input.w3-input.w3-border", {
type: "text",
value: currentLog.kunden_name,
readonly: true,
style: {'margin-bottom': '5px'}
})),
m("label", "Projekt",
m("input.w3-input.w3-border", {
type: "text",
value: currentLog.projekt_name,
readonly: true,
style: {'margin-bottom': '5px'}
})),
// Datum, Startzeit und Dauer in einer Zeile.
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
m("label", {style: {flex: '1'}}, "Datum",
m(Input, {name: "datum", value: currentLog.datum, type: "date"} as iInput)),
m("label", {style: {flex: '1'}}, "Uhrzeit Beginn",
m(Input, {name: "uhrzeit_beginn", value: currentLog.uhrzeit_beginn, type: "time"} as iInput)),
m("label", {style: {flex: '1'}}, "Dauer (mm:ss)",
m("input.w3-input.w3-border", {
name: "dauer",
type: "text",
value: currentLog.dauer,
oncreate: (vnode: m.VnodeDOM) => {
dauerInput = vnode.dom as HTMLInputElement
}
}))
),
m("label", "Betreff", m(Input, {name: "betreff", value: currentLog.betreff, type: "text"} as iInput)),
// Ansprechpartner und Telefon in einer Zeile.
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
m("label", {style: {flex: '1'}}, "Name Ansprechpartner",
ansprechpartnerVoreingestellt
? m(Input, {name: "name_ansprechpartner", value: currentLog.name_ansprechpartner, type: "text", readonly: true} as iInput)
: m(Select, {
name: "kontaktadresse_id",
value: -1,
options: kontaktadressenOptionen,
onchanged: (kontaktadresseId: number) => {
const ka = kontaktadressenListe.find((k: iKontaktadresse) => k.id === kontaktadresseId)
if (ka) {
currentLog.name_ansprechpartner = ka.name
currentLog.telefon = ka.telefon
}
}
} as iSelect)),
m("label", {style: {flex: '1'}}, "Telefon",
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", "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")
])
)
)
}
}
}