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>
363 lines
17 KiB
TypeScript
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")
|
|
])
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|