Files
kbcrm/Client/Components/call_log.ts
T
gg a8838a1a38 Call_Log: Ansprechpartner als Select aus Kontaktadressen des Projekts
Im Call-Log-Formular wird "Name Ansprechpartner" durch ein Select ersetzt,
das die Kontaktadressen des gewählten Projekts anbietet, sofern der Name nicht
bereits voreingestellt ist (z. B. aus der Kontaktadressenliste). Bei Auswahl
einer Kontaktadresse werden Name und Telefon automatisch übernommen. Bei
Projektwechsel werden Ansprechpartner und Telefon zurückgesetzt.

Kontaktadressenliste: add_call-Button pro Zeile, der eine Gesprächsnotiz mit
den ForeignKeys aus der Kontaktadresse (projektId, kundenName, name, telefon)
anlegt.

AGENTS.md: Vorgabe ergänzt — Farben priorisiert aus app-theme.css.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-01 18:21:28 +02:00

363 lines
17 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, getCallLogsByProjektId, getNewCallLogId, addCallLogData, updateCallLogData} from "../models/call_log"
import {iKunde, getKunden} from "../models/kunde"
import {getProjekteByKundenId, getProjektById} from "../models/projekt"
import {getKontaktadressenByProjektId, getKontaktadresseById} from "../models/kontaktadresse"
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
// 1000 = jede Sekunde, 60000 = jede Minute.
const DAUER_UPDATE_INTERVALL = 1000
function createCallLogTreenodes(projektId: number | null = null): iTreenode[] {
let filteredLogs = getCallLogsByProjektId(projektId)
return filteredLogs.map((log: iCallLog) => {
return new Treenode(log, true, [])
})
}
export const call_log_liste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = createCallLogTreenodes()
let selectedNode: iTreenode | null = null
let filterProjektId: number | null = null
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,
uhrzeitBeginn: uhrzeit,
dauer: "",
betreff: "",
nameAnsprechpartner: "",
telefon: "",
notiz: "",
wiedervorlageDatum: "",
erledigt: false
}
// Bei projektgefilterter Liste Kunde und Projekt vorbelegen.
if (filterProjektId !== null) {
const projekt = getProjektById(filterProjektId)
if (projekt) {
params.kundenName = projekt.kundenName
params.projektId = projekt.id
params.projektName = projekt.projektname
}
}
m.route.set("/call_log", params)
}
const editCallLog = (log: iCallLog, e: Event) => {
e.stopPropagation()
m.route.set("/call_log", {
id: log.id,
kundenName: log.kundenName,
projektId: log.projektId,
projektName: log.projektName,
datum: log.datum,
uhrzeitBeginn: log.uhrzeitBeginn,
dauer: log.dauer,
betreff: log.betreff,
nameAnsprechpartner: log.nameAnsprechpartner,
telefon: log.telefon,
notiz: log.notiz,
wiedervorlageDatum: log.wiedervorlageDatum,
erledigt: log.erledigt
})
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let log: iCallLog = node.nodevalue
return [
m("div", m("strong", log.kundenName + ": " + log.projektName)),
m("span", log.datum),
m("span", log.uhrzeitBeginn),
m("span", log.dauer + " min"),
m("span", log.betreff),
m("span", log.wiedervorlageDatum),
m("span", log.erledigt ? "Erledigt" : "Offen"),
m("button.w3-button.w3-small", {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.kundenName.toLowerCase().includes(searchLower) ||
log.projektName.toLowerCase().includes(searchLower) ||
log.datum.includes(searchLower) ||
log.uhrzeitBeginn.toLowerCase().includes(searchLower) ||
log.betreff.toLowerCase().includes(searchLower) ||
log.nameAnsprechpartner.toLowerCase().includes(searchLower) ||
log.notiz.toLowerCase().includes(searchLower) ||
log.wiedervorlageDatum.includes(searchLower)
}
return {
oninit: (vnode) => {
const routeParams = m.route.param()
if (routeParams && routeParams.projektId) {
filterProjektId = parseInt(routeParams.projektId)
} else {
filterProjektId = null
}
treenodes = createCallLogTreenodes(filterProjektId)
},
view: (vnode) => {
return m("div.w3-container.w3-auto", {
style: {
display: 'flex',
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", "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. kundenId ist intern und wird über
// das Projekt aufgelöst; kundenName/projektName dienen der Anzeige.
let currentLog: {
id: number
kundenId: number
kundenName: string
projektId: number
projektName: string
datum: string
uhrzeitBeginn: string
dauer: string
betreff: string
nameAnsprechpartner: string
telefon: string
notiz: string
wiedervorlageDatum: string
erledigt: boolean
} = {
id: -1,
kundenId: -1,
kundenName: "",
projektId: -1,
projektName: "",
datum: "",
uhrzeitBeginn: "",
dauer: "",
betreff: "",
nameAnsprechpartner: "",
telefon: "",
notiz: "",
wiedervorlageDatum: "",
erledigt: false
}
// Sind Kunde und Projekt voreingestellt (Liste wurde projektgefiltert
// oder aus der Projektliste aufgerufen)?
let kundeVoreingestellt: boolean = false
// Ist der Ansprechpartner voreingestellt (aus Kontaktadressenliste)?
// Wenn ja, wird ein readonly Input gezeigt; sonst ein Select, das die
// Kontaktadressen des gewählten Projekts anbietet.
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, damit der Timer den Wert
// ohne Redraw aktualisieren kann (ein Redraw würde die übrigen Felder
// mit den veralteten currentLog-Werten überschreiben).
let dauerInput: HTMLInputElement | null = null
/**
* Formatiert verstrichene Sekunden als mm:ss (Minuten:Sekunden).
*/
const formatDauer = (sekunden: number): string => {
const m = Math.floor(sekunden / 60)
const s = sekunden % 60
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
}
return {
oninit: (vnode) => {
const routeParams = m.route.param()
if (routeParams) {
const projektId = routeParams.projektId ? parseInt(routeParams.projektId) : -1
kundeVoreingestellt = projektId !== -1
// kundenId aus dem Projekt ableiten, falls nicht direkt gesetzt.
let kundenId = -1
if (kundeVoreingestellt) {
const projekt = getProjektById(projektId)
if (projekt) { kundenId = projekt.kundenId }
}
currentLog = {
id: routeParams.id ? parseInt(routeParams.id) : -1,
kundenId: kundenId,
kundenName: routeParams.kundenName || "",
projektId: projektId,
projektName: routeParams.projektName || "",
datum: routeParams.datum || "",
uhrzeitBeginn: routeParams.uhrzeitBeginn || "",
dauer: routeParams.dauer || "",
betreff: routeParams.betreff || "",
nameAnsprechpartner: routeParams.nameAnsprechpartner || "",
telefon: routeParams.telefon || "",
notiz: routeParams.notiz || "",
wiedervorlageDatum: routeParams.wiedervorlageDatum || "",
erledigt: routeParams.erledigt === true || routeParams.erledigt === "true"
}
// Bei Neuanlage Startzeit merken und Dauer-Timer starten.
ansprechpartnerVoreingestellt = currentLog.nameAnsprechpartner !== ""
if (currentLog.id === -1) {
startZeit = new Date()
timerId = window.setInterval(() => {
if (startZeit) {
// Verstrichene Sekunden berechnen; das Intervall
// steuert nur die Update-Frequenz.
const sekunden = Math.floor((Date.now() - startZeit.getTime()) / 1000)
const dauer = formatDauer(sekunden)
currentLog.dauer = dauer
// Nur das Dauer-Feld direkt im DOM aktualisieren,
// ohne redraw (sonst werden andere Felder zurückgesetzt).
if (dauerInput) { dauerInput.value = dauer }
}
}, DAUER_UPDATE_INTERVALL)
}
}
},
onremove: (vnode) => {
// Timer stoppen, damit er nach Verlassen des Views nicht weiterläuft.
if (timerId != null) { window.clearInterval(timerId) }
},
view: (vnode) => {
// Select-Optionen aus Kunden- und Projektliste aufbauen.
const kundenOptionen: iSelectOption[] = getKunden().map((k: iKunde) => {
return {value: k.id, label: k.name}
})
// Projekt-Optionen sind vom gewählten Kunden abhängig.
const projektOptionen: iSelectOption[] = getProjekteByKundenId(currentLog.kundenId).map((p) => {
return {value: p.id, label: p.projektname}
})
// Kontaktadressen-Optionen sind vom gewählten Projekt abhängig.
// Werden nur benötigt, wenn der Ansprechpartner nicht voreingestellt ist.
const kontaktadressenOptionen: iSelectOption[] = !ansprechpartnerVoreingestellt
? getKontaktadressenByProjektId(currentLog.projektId).map((ka) => {
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
})
: []
// Einheit der Dauer-Anzeige im Format mm:ss.
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"),
// Form füllt den verbleibenden Platz; das Form-Widget selbst
// sorgt (mit stickyButtons, Default) dafür, dass nur der
// Inhalt scrollt und die Buttons fixiert bleiben.
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [
m("label", "Kunde",
m(Select, {
name: "kundenId",
value: currentLog.kundenId,
options: kundenOptionen,
readonly: kundeVoreingestellt,
required: true,
onchanged: (kundenId: number) => {
// Bei Kundenwechsel die kundenId übernehmen
// und projektId zurücksetzen, da die Projekte
// kundenabhängig sind.
currentLog.kundenId = kundenId
currentLog.projektId = -1
}
} as iSelect)),
m("label", "Projekt",
m(Select, {
name: "projektId",
value: currentLog.projektId,
options: projektOptionen,
readonly: kundeVoreingestellt,
required: true,
onchanged: (projektId: number) => {
currentLog.projektId = projektId
// Bei Projektwechsel Ansprechpartner und Telefon
// zurücksetzen, falls nicht voreingestellt.
if (!ansprechpartnerVoreingestellt) {
currentLog.nameAnsprechpartner = ""
currentLog.telefon = ""
}
}
} as iSelect)),
// 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: "uhrzeitBeginn", value: currentLog.uhrzeitBeginn, type: "time"} as iInput)),
m("label", {style: {flex: '1'}}, "Dauer (mm:ss)",
// Raw input statt Input-Widget, damit oncreate die
// DOM-Referenz für den Timer greifen kann. Text-Typ,
// da das Format mm:ss keine Zahl ist.
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.
// Wenn der Ansprechpartner voreingestellt ist (aus Kontaktadressenliste),
// wird ein readonly Input gezeigt; sonst ein Select, das die
// Kontaktadressen des gewählten Projekts anbietet. Bei Auswahl
// werden Name und Telefon automatisch übernommen.
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
m("label", {style: {flex: '1'}}, "Name Ansprechpartner",
ansprechpartnerVoreingestellt
? m(Input, {name: "nameAnsprechpartner", value: currentLog.nameAnsprechpartner, type: "text", readonly: true} as iInput)
: m(Select, {
name: "kontaktadresseId",
value: -1,
options: kontaktadressenOptionen,
onchanged: (kontaktadresseId: number) => {
const ka = getKontaktadresseById(kontaktadresseId)
if (ka) {
currentLog.nameAnsprechpartner = 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: "wiedervorlageDatum", value: currentLog.wiedervorlageDatum, type: "date"} as iInput)),
m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt")
])
)
)
}
}
}