import m from "mithril" import {Form, iForm} from "./widgets/form" import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs" import {Select, iSelect, MultiSelect, iMultiSelect, iSelectOption} from "./widgets/select" import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb" 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. 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 = "" // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). let breadcrumbs: iBreadcrumb[] = [] const handleRowMarked = (node: iTreenode) => { selectedNode = node } // Eigener Listen-Crumb für die Breadcrumb-Kette. const ownCrumb = (): iBreadcrumb => ({ route: "/calllogliste", params: filterProjektId !== null ? {projekt_id: filterProjektId} : {}, label: filterProjektId !== null ? "Notizen: " + projektName : "Gesprächsnotizen" }) 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, from_liste: "1" } if (filterProjektId !== null) { params.projekt_id = filterProjektId params.projekt_name = projektName params.kunden_name = kundenName params.return_projekt_id = filterProjektId params.return_projekt_name = projektName } // Breadcrumb-Kette um eigene Position erweitern. params.bc = serializeBc([...breadcrumbs, ownCrumb()]) m.route.set("/call_log", params) } const editCallLog = (log: iCallLog, e: Event) => { e.stopPropagation() const params: any = {id: log.id, from_liste: "1"} if (filterProjektId !== null) { params.return_projekt_id = filterProjektId params.return_projekt_name = projektName } // Breadcrumb-Kette um eigene Position erweitern. params.bc = serializeBc([...breadcrumbs, ownCrumb()]) m.route.set("/call_log", params) } 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, ...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")) ] } 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 = "" } // Breadcrumb-Kette aus Route-Param parsen. breadcrumbs = parseBc(routeParams && routeParams.bc) // 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, [])) }).catch((e) => showServerError(e)) // 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 }).catch((e) => showServerError(e)) } }, 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(BreadcrumbBar, { breadcrumbs: breadcrumbs, current: filterProjektId !== null ? "Notizen: " + projektName : "Gesprächsnotizen" } as iBreadcrumbBar), 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[] = [] // Asynchron geladene Tag-Liste (nur zuordenbare, nicht is_parent). let tagListe: iTag[] = [] // Aktuell ausgewählte Tag-IDs im Formular. let selectedTagIds: number[] = [] // Wurde die Maske aus der Gesprächsnotizenliste geöffnet? Wenn ja, // erfolgt die Rückkehr dorthin mit dem ursprünglichen Listenfilter. let fromListe: boolean = false // Filter-Kontext der Liste, aus der die Maske geöffnet wurde. // null bedeutet ungefiltert; ein Wert bedeutet projektgefiltert. let returnProjektId: number | null = null let returnProjektName: string = "" // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). let breadcrumbs: iBreadcrumb[] = [] /** * 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 // Rückkehr-Kontext: wurde die Maske aus der Liste geöffnet und // mit welchem Filter soll zurückgekehrt werden? fromListe = !!(routeParams && routeParams.from_liste) if (routeParams && routeParams.return_projekt_id) { returnProjektId = parseInt(routeParams.return_projekt_id) returnProjektName = routeParams.return_projekt_name || "" } // Breadcrumb-Kette aus Route-Param parsen. breadcrumbs = parseBc(routeParams && routeParams.bc) if (id !== -1) { // Gesprächsnotiz frisch vom Server holen. fetchCallLogById(id).then((result) => { 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 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 }).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) } }, 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 + ")" : "")} }) : [] // 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 = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string])) const goBack = () => { const params: any = {} if (fromListe) { // Rückkehr in den Listenkontext, aus dem die Maske // geöffnet wurde. Ohne return_projekt_id ungefiltert. if (returnProjektId !== null) { params.projekt_id = returnProjektId params.projekt_name = returnProjektName } } else if (currentLog.projekt_id !== -1) { // Fallback: Maske wurde nicht aus der Liste geöffnet // (z.B. addCallLog aus projekt.ts/kontaktadresse.ts). params.projekt_id = currentLog.projekt_id params.projekt_name = currentLog.projekt_name } // Breadcrumb-Kette ohne das letzte Glied (die Notizliste // selbst) an die Liste durchreichen, damit diese die // Vorgänger-Ansichten wieder als Leiste anzeigen kann. const listBc = serializeBc(breadcrumbs.slice(0, -1)) if (listBc) params.bc = listBc m.route.set("/calllogliste", params) } 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", tag_ids: formData.getAll("tag_ids").map((v) => parseInt(v as string)).filter((n) => !isNaN(n)) } if (currentLog.id === -1) { createCallLogREST(gespeichertesLog).then(() => goBack()).catch((e) => showServerError(e)) } else { updateCallLogREST(gespeichertesLog).then(() => goBack()).catch((e) => showServerError(e)) } return true } const cancel = (e: Event) => { goBack() } return m("div.w3-container.w3-auto.form-view-container", m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"), m(BreadcrumbBar, { breadcrumbs: breadcrumbs, current: currentLog.id === -1 ? "Neue Gesprächsnotiz" : "Gesprächsnotiz" } as iBreadcrumbBar), 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", "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") ]) ) ) } } }