feat(workflow): Tages-Workflow-UI für Telefon-Akquise (Liste + Maske)
Server: neuer Endpunkt /xhr/workflow/list aggregiert pro Kontaktadresse die letzte Gesprächsnotiz, das effektiv gültige Wiedervorlage-Datum (jüngste Notiz mit Wert), den Erledigt-Status und eine priorisierte Sortierung (überfällig → ohne Notizen → Rest). Filter: kunden_id, status (offen/erledigt), wiedervorlage (heute/3tage/ueberfaellig/woche). Sichtbarkeit wie kontaktadressen über projekt_users. CallLog erfasst den Ersteller serverseitig (user_id, Migration _db_V01_14, kein FK wegen separater Auth-DB); to_dict ergänzt mitarbeiter_name. calllog_list unterstützt kontaktadresse_id als Filter. Client: TagesWorkflowListe (Route /tagesworkflow, Filterleiste) und TagesWorkflowMaske (Route /tagesworkflowkontakt, lesende Historie, Notiz-Formular mit "Speichern und weiter"), Routes + Menüpunkt erster Eintrag. berechneWiedervorlageDatum aus der Call_Log-Maske nach models/tag.ts ausgelagert (gemeinsame Nutzung). Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
1 parent
981b28df03
commit
c96ed6e97d
13 files changed
+1247
-44
No files matched your search
@@ -8,7 +8,7 @@ import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCall
|
||||
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 {iTag, fetchTags, berechneWiedervorlageDatum} from "../models/tag"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
|
||||
@@ -277,27 +277,6 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
return min.toString().padStart(2, '0') + ':' + sek.toString().padStart(2, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet das Wiedervorlage-Datum automatisch aus den ausgewählten
|
||||
* Tags. Basis ist das Datum der Notiz (currentLog.datum) oder heute,
|
||||
* falls dieses leer ist. Aus allen ausgewählten Tags mit
|
||||
* loest_wiedervorlage_aus=True wird das Minimum von
|
||||
* wiedervorlage_abstand_tage ermittelt und zum Basisdatum addiert.
|
||||
* Liefert einen leeren String, wenn kein Tag mit
|
||||
* loest_wiedervorlage_aus=True ausgewählt ist.
|
||||
*/
|
||||
const berechneWiedervorlageDatum = (tagIds: number[]): string => {
|
||||
const basis = currentLog.datum || new Date().toISOString().split('T')[0]
|
||||
const wvTags = tagListe.filter((t: iTag) =>
|
||||
tagIds.includes(t.id) && t.loest_wiedervorlage_aus && t.wiedervorlage_abstand_tage != null
|
||||
)
|
||||
if (wvTags.length === 0) return ""
|
||||
const minAbstand = Math.min(...wvTags.map(t => t.wiedervorlage_abstand_tage as number))
|
||||
const basisDate = new Date(basis)
|
||||
basisDate.setDate(basisDate.getDate() + minAbstand)
|
||||
return basisDate.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
const routeParams = m.route.param()
|
||||
@@ -397,6 +376,11 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
kontaktadresse_id: ansprechpartnerVoreingestellt
|
||||
? currentLog.kontaktadresse_id
|
||||
: parseInt((formData.get("kontaktadresse_id") as string) || "-1"),
|
||||
// Ersteller wird serverseitig aus current_user.id
|
||||
// gesetzt — hier nur Platzhalter, mitarbeiter_name
|
||||
// wird ausschließlich vom Server geliefert.
|
||||
user_id: null,
|
||||
mitarbeiter_name: null,
|
||||
projekt_name: "",
|
||||
kunden_name: "",
|
||||
datum: currentLog.datum || "",
|
||||
@@ -537,7 +521,11 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
onchanged: (newSelection: number[]) => {
|
||||
selectedTagIds.splice(0, selectedTagIds.length)
|
||||
Array.prototype.push.apply(selectedTagIds, newSelection)
|
||||
const vorschlag = berechneWiedervorlageDatum(newSelection)
|
||||
const vorschlag = berechneWiedervorlageDatum(
|
||||
newSelection,
|
||||
tagListe,
|
||||
currentLog.datum || new Date().toISOString().split('T')[0]
|
||||
)
|
||||
if (vorschlag) {
|
||||
currentLog.wiedervorlage_datum = vorschlag
|
||||
}
|
||||
|
||||
@@ -238,6 +238,9 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
||||
id: currentKontaktadresse.id,
|
||||
projekt_id: currentKontaktadresse.projekt_id,
|
||||
projekt_name: "",
|
||||
// Server-seitig aus den Relationships abgeleitet;
|
||||
// Platzhalter beim Senden.
|
||||
kunden_name: "",
|
||||
anrede: (formData.get("anrede") as string) || "",
|
||||
name: (formData.get("name") as string) || "",
|
||||
firma: (formData.get("firma") as string) || "",
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
import m from "mithril"
|
||||
import {Form, iForm} from "./widgets/form"
|
||||
import {Input, iInput} from "./widgets/inputs"
|
||||
import {Select, iSelect, MultiSelect, iMultiSelect, iSelectOption} from "./widgets/select"
|
||||
import {Treenode, iTreenode, Treetable, iTreetable} from "./widgets/Treetable_V2"
|
||||
import {ModalType, ModalView, iModalView} from "./widgets/Modal"
|
||||
import {gridMountpoints} from "./mainlayout"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
import {serializeBc, iBreadcrumb} from "./widgets/breadcrumb"
|
||||
import {iKunde, fetchKunden} from "../models/kunde"
|
||||
import {iKontaktadresse, fetchKontaktadresseById} from "../models/kontaktadresse"
|
||||
import {iCallLog, fetchCallLogsByKontaktadresse, createCallLogREST} from "../models/call_log"
|
||||
import {iTag, fetchTags, berechneWiedervorlageDatum} from "../models/tag"
|
||||
import {iTagesWorkflowEntry, iTagesWorkflowFilter, fetchTagesWorkflowList} from "../models/tages_workflow"
|
||||
|
||||
// ============================================================================
|
||||
// MODUL-LEVEL WORKFLOW-KONTEXT
|
||||
// ============================================================================
|
||||
|
||||
// Die aktuellen Filter-Einstellungen werden von der Liste gesetzt und von
|
||||
// der Maske gelesen: Für "Nächster Kontakt" bzw. "Speichern und weiter"
|
||||
// wird die Workflow-Liste mit denselben Filtern frisch vom Server geladen,
|
||||
// um Filter-Prüfung und Reihenfolge serverseitig neu zu berechnen. Im
|
||||
// Kontext liegen nur einfache Werte (IDs/Strings) — die Maske lädt ihre
|
||||
// Daten frisch vom Server (AGENTS.md: nur IDs in Route-Params).
|
||||
let workflowFilter: iTagesWorkflowFilter = {status: "offen"}
|
||||
|
||||
// Hinweis, der beim Weiterschalten an die nächste Maske übergeben wird,
|
||||
// wenn der aktuelle Kontakt die Filterkriterien nicht mehr erfüllt
|
||||
// (Konzept 2.5). Wird von der Maske beim Laden konsumiert.
|
||||
let workflowHinweis: string | null = null
|
||||
|
||||
// In der aktuellen Workflow-Sitzung bereits bearbeitete Kontaktadressen.
|
||||
// Wird beim "Speichern und weiter"/"Speichern" gefüllt, damit der nächste
|
||||
// Kontakt nicht bereits Abgearbeitetes erneut anbietet. Zurücksetzen bei
|
||||
// jedem (erneuten) Laden der Liste — die Sitzung endet mit der Rückkehr
|
||||
// zur Liste.
|
||||
let bearbeiteteIds: Set<number> = new Set()
|
||||
|
||||
/**
|
||||
* Heutiges Datum als ISO-String (yyyy-MM-dd) für das Notiz-Datum.
|
||||
*/
|
||||
function heuteIso(): string {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuelle Uhrzeit als HH:MM für den Begin der neuen Notiz.
|
||||
*/
|
||||
function jetztUhrzeit(): string {
|
||||
const now = new Date()
|
||||
return now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TAGES-WORKFLOW-LISTE
|
||||
// ============================================================================
|
||||
|
||||
export const TagesWorkflowListe: m.ClosureComponent = () => {
|
||||
let eintraege: iTagesWorkflowEntry[] = []
|
||||
let treenodes: iTreenode[] = []
|
||||
let selectedNode: iTreenode | null = null
|
||||
// Filterleisten-Zustand (Closure). kunden_id: -1 = alle Kunden.
|
||||
let filterKundenId: number = -1
|
||||
let filterStatus: string = "offen"
|
||||
let filterWiedervorlage: string = "alle"
|
||||
// Kundenliste für das Kunden-Select, asynchron geladen.
|
||||
let kundenListe: iKunde[] = []
|
||||
|
||||
/**
|
||||
* Lädt die Workflow-Liste mit den aktuellen Filter-Einstellungen vom
|
||||
* Server und erzeugt die flachen Treenodes für die Treetable.
|
||||
*/
|
||||
const listeLaden = () => {
|
||||
// Filter-Zustand in den Modul-Kontext schreiben — die Maske lädt
|
||||
// mit denselben Filtern, wenn der nächste Kontakt bestimmt wird.
|
||||
workflowFilter = {
|
||||
kunden_id: filterKundenId >= 0 ? filterKundenId : undefined,
|
||||
status: filterStatus !== "alle" ? filterStatus : undefined,
|
||||
wiedervorlage: filterWiedervorlage !== "alle" ? filterWiedervorlage : undefined,
|
||||
}
|
||||
// Mit dem Laden der Liste beginnt eine neue Workflow-Sitzung —
|
||||
// die bisher als bearbeitet gemerkten Kontakte verfallen.
|
||||
bearbeiteteIds = new Set()
|
||||
fetchTagesWorkflowList(workflowFilter).then((result) => {
|
||||
eintraege = result
|
||||
treenodes = result.map((eintrag: iTagesWorkflowEntry) => new Treenode(eintrag, true, []))
|
||||
selectedNode = null
|
||||
}).catch((e) => showServerError(e))
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt einen geänderten Filterwert in den Zustand und lädt die
|
||||
* Liste sofort neu — jeder Filterwechsel löst genau einen GET-Request
|
||||
* aus, ein separates "Filter anwenden" ist daher nicht nötig.
|
||||
*/
|
||||
const filterGeaendert = (setter: () => void) => {
|
||||
setter()
|
||||
listeLaden()
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt alle Filter auf die Defaults zurück und lädt die Liste neu.
|
||||
*/
|
||||
const filterZuruecksetzen = () => {
|
||||
filterGeaendert(() => {
|
||||
filterKundenId = -1
|
||||
filterStatus = "offen"
|
||||
filterWiedervorlage = "alle"
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet die Bearbeitungsmaske für einen Workflow-Eintrag.
|
||||
*/
|
||||
const openMaske = (eintrag: iTagesWorkflowEntry) => {
|
||||
m.route.set("/tagesworkflowkontakt", {kontaktadresse_id: eintrag.kontaktadresse_id})
|
||||
}
|
||||
|
||||
/**
|
||||
* "Workflow beginnen": öffnet den ersten Eintrag der gefilterten Liste.
|
||||
*/
|
||||
const workflowBeginnen = () => {
|
||||
if (eintraege.length > 0) {
|
||||
openMaske(eintraege[0])
|
||||
}
|
||||
}
|
||||
|
||||
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
||||
const eintrag: iTagesWorkflowEntry = node.nodevalue
|
||||
// Badges der Tags des letzten Gesprächs (gleicher Stil wie in der
|
||||
// Gesprächsnotizen-Liste).
|
||||
const tagBadges = (eintrag.letzte_notiz_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", eintrag.kunden_name || ""),
|
||||
m("div", {style: {fontSize: '12px', color: '#666'}}, eintrag.projekt_name || "")),
|
||||
// Kontakt: Name (+Firma), darunter die Telefonnummer in fett —
|
||||
// so bleibt die Nummer ohne eigene Spalte ohne Umbruch lesbar.
|
||||
m("div",
|
||||
m("div", eintrag.name + (eintrag.firma ? " (" + eintrag.firma + ")" : "")),
|
||||
eintrag.telefon
|
||||
? m("div", m("strong", eintrag.telefon))
|
||||
: null),
|
||||
// Letztes Gespräch: Datum (+Uhrzeit) und darunter die Tags der
|
||||
// letzten Notiz (eigene Zeile — mehr Platz für die Badges).
|
||||
// Der Notiztext steht als Tooltip auf der Spalte.
|
||||
m("div",
|
||||
{title: eintrag.letzte_notiz || undefined},
|
||||
m("div", {style: {'white-space': 'nowrap'}},
|
||||
eintrag.letzte_notiz_datum
|
||||
? eintrag.letzte_notiz_datum +
|
||||
(eintrag.letzte_notiz_uhrzeit ? ", " + eintrag.letzte_notiz_uhrzeit : "")
|
||||
: "—"),
|
||||
m("div", tagBadges)),
|
||||
// Wiedervorlage: Liegt das Datum in der Vergangenheit, wird es
|
||||
// rot und fett hervorgehoben (Überfällig). ISO-Strings
|
||||
// (yyyy-MM-dd) vergleichen lexikalisch korrekt.
|
||||
m("span", {
|
||||
class: eintrag.wiedervorlage_datum && eintrag.wiedervorlage_datum < heuteIso()
|
||||
? "w3-text-red"
|
||||
: "",
|
||||
style: {
|
||||
'white-space': 'nowrap',
|
||||
fontWeight: eintrag.wiedervorlage_datum && eintrag.wiedervorlage_datum < heuteIso()
|
||||
? 'bold'
|
||||
: 'normal'
|
||||
},
|
||||
title: eintrag.wiedervorlage_datum && eintrag.wiedervorlage_datum < heuteIso()
|
||||
? "Überfällig"
|
||||
: undefined
|
||||
}, eintrag.wiedervorlage_datum || ""),
|
||||
m("span.w3-tag" + (eintrag.is_erledigt ? ".w3-green" : ".w3-grey"),
|
||||
eintrag.is_erledigt ? "Erledigt" : "Offen"),
|
||||
// Expliziter Button statt Zeilen-Klick: Die Select-Optionen der
|
||||
// Filterleiste reagieren auf mousedown und schließen das
|
||||
// Dropdown vor dem mouseup — der mouseup landet dann auf der
|
||||
// Zeile darunter und würde eine Navigation auslösen. Die
|
||||
// Bearbeitung startet daher ausschließlich über bewusste
|
||||
// Klicks (Button je Zeile oder "Workflow beginnen").
|
||||
m("button.w3-button.w3-small",
|
||||
{
|
||||
title: "Kontakt im Workflow bearbeiten",
|
||||
style: {padding: '0 2px'},
|
||||
onclick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
openMaske(eintrag)
|
||||
}
|
||||
},
|
||||
m("i.material-icons", "call"))
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
// Filter-Zustand aus dem Modul-Kontext übernehmen, damit nach
|
||||
// Rückkehr aus der Maske die zuletzt gewählten Filter aktiv sind.
|
||||
filterKundenId = workflowFilter.kunden_id !== undefined ? workflowFilter.kunden_id : -1
|
||||
filterStatus = workflowFilter.status || "offen"
|
||||
filterWiedervorlage = workflowFilter.wiedervorlage || "alle"
|
||||
fetchKunden().then((result) => { kundenListe = result }).catch((e) => showServerError(e))
|
||||
listeLaden()
|
||||
},
|
||||
view: (vnode) => {
|
||||
// Optionen für die Filter-Selects. "Alle"-Optionen erhalten
|
||||
// den Wert -1 (Kunde) bzw. "alle" (Status/Wiedervorlage).
|
||||
const kundenOptionen: iSelectOption[] = [
|
||||
{value: -1, label: "Alle Kunden"},
|
||||
...kundenListe.map((k: iKunde) => {
|
||||
return {value: k.id, label: k.name}
|
||||
})
|
||||
]
|
||||
const statusOptionen: iSelectOption[] = [
|
||||
{value: "alle", label: "Alle"},
|
||||
{value: "offen", label: "Offen"},
|
||||
{value: "erledigt", label: "Erledigt"}
|
||||
]
|
||||
const wiedervorlageOptionen: iSelectOption[] = [
|
||||
{value: "alle", label: "Alle"},
|
||||
{value: "ueberfaellig", label: "Überfällig"},
|
||||
{value: "heute", label: "Heute"},
|
||||
{value: "3tage", label: "Nächste 3 Tage"},
|
||||
{value: "woche", label: "Aktuelle Woche"}
|
||||
]
|
||||
return m("div.w3-container.w3-auto", {
|
||||
style: {
|
||||
display: 'flex',
|
||||
'flex-direction': 'column',
|
||||
height: 'inherit',
|
||||
'overflow-y': 'inherit'}},
|
||||
m("h1", "Tages-Workflow"),
|
||||
// Filterleiste: drei Selects nebeneinander, auf kleinen
|
||||
// Bildschirmen untereinander (w3-third ist responsive).
|
||||
m("div.w3-row-padding", {style: {'margin-bottom': '8px'}},
|
||||
m("div.w3-third",
|
||||
m("label", "Kunde",
|
||||
m(Select, {
|
||||
name: "kunden_id",
|
||||
value: filterKundenId,
|
||||
options: kundenOptionen,
|
||||
onchanged: (kundenId: number) => {
|
||||
filterGeaendert(() => { filterKundenId = kundenId })
|
||||
}
|
||||
} as iSelect))),
|
||||
m("div.w3-third",
|
||||
m("label", "Kontakt-Status",
|
||||
m(Select, {
|
||||
name: "status",
|
||||
value: filterStatus,
|
||||
options: statusOptionen,
|
||||
onchanged: (status: string) => {
|
||||
filterGeaendert(() => { filterStatus = status })
|
||||
}
|
||||
} as iSelect))),
|
||||
m("div.w3-third",
|
||||
m("label", "Wiedervorlage",
|
||||
m(Select, {
|
||||
name: "wiedervorlage",
|
||||
value: filterWiedervorlage,
|
||||
options: wiedervorlageOptionen,
|
||||
onchanged: (zeitraum: string) => {
|
||||
filterGeaendert(() => { filterWiedervorlage = zeitraum })
|
||||
}
|
||||
} as iSelect)))
|
||||
),
|
||||
// Aktionsbuttons: Filter wirken sofort, daher kein
|
||||
// "Filter anwenden" mehr — nur Zurücksetzen und Start.
|
||||
m("div.w3-bar",
|
||||
m("button.w3-button.w3-theme-l2",
|
||||
{onclick: filterZuruecksetzen},
|
||||
m("i.material-icons", {style: {verticalAlign: 'middle', marginRight: '4px'}}, "restart_alt"),
|
||||
"Zurücksetzen"),
|
||||
m("button.w3-button.w3-theme",
|
||||
{
|
||||
onclick: workflowBeginnen,
|
||||
style: {'margin-left': '4px'},
|
||||
disabled: eintraege.length === 0
|
||||
},
|
||||
m("i.material-icons", {style: {verticalAlign: 'middle', marginRight: '4px'}}, "play_arrow"),
|
||||
"Workflow beginnen"),
|
||||
// Info-Button: erklärt in einem Modal die priorisierte
|
||||
// Sortierung und die aktuell aktiven Filter. Die Label
|
||||
// werden aus denselben Optionen abgeleitet, die auch die
|
||||
// Filter-Selects anzeigen.
|
||||
m("button.w3-button.w3-theme-l4.w3-right",
|
||||
{
|
||||
type: "button",
|
||||
title: "Sortierung und Filter erklärt",
|
||||
onclick: () => {
|
||||
const label = (optionen: iSelectOption[], value: any): string => {
|
||||
const o = optionen.find((op: iSelectOption) => op.value === value)
|
||||
return o ? o.label : String(value)
|
||||
}
|
||||
m.mount(gridMountpoints.modalhook, {
|
||||
view: () => {
|
||||
return m(ModalView,
|
||||
{
|
||||
title: "Sortierung & Filter",
|
||||
type: ModalType.MESSAGE,
|
||||
width: '50%'
|
||||
} as iModalView,
|
||||
m("div.w3-panel",
|
||||
m("p", m("strong", "Die Liste ist nach Priorität sortiert:")),
|
||||
m("ol",
|
||||
m("li", "Überfällige Wiedervorlagen (rot und fett dargestellt), älteste zuerst"),
|
||||
m("li", "Kontakte ohne Gesprächsnotizen"),
|
||||
m("li", "Übrige Kontakte, aufsteigend nach Wiedervorlage-Datum")),
|
||||
m("p", m("strong", "Aktuell aktive Filter:")),
|
||||
m("ul",
|
||||
m("li", "Kunde: " + label(kundenOptionen, filterKundenId)),
|
||||
m("li", "Kontakt-Status: " + label(statusOptionen, filterStatus)),
|
||||
m("li", "Wiedervorlage: " + label(wiedervorlageOptionen, filterWiedervorlage)),
|
||||
// Sichtbarkeits-Hinweis: die Projekt-Zuordnung
|
||||
// greift serverseitig immer, unabhängig von den
|
||||
// gewählten Filtern.
|
||||
m("li", "Sichtbarkeit: Es werden nur Kontaktadressen von Projekten angezeigt, die Ihnen zugeordnet sind (AppAdmin/SysAdmin sehen alle).")),
|
||||
m("p", {style: {color: '#666', 'font-size': '13px'}},
|
||||
"Filter wirken sofort bei Änderung. Beim Weiterschalten in der Bearbeitungsmaske werden bereits bearbeitete Kontakte und solche, die nicht mehr den Filterkriterien entsprechen, automatisch übersprungen.")
|
||||
),
|
||||
m("div.w3-bar.w3-padding-16",
|
||||
m("button.w3-button.w3-theme.w3-right",
|
||||
{
|
||||
onclick: () => {
|
||||
m.mount(gridMountpoints.modalhook, null)
|
||||
}
|
||||
},
|
||||
"Schließen")
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
m("i.material-icons", "info"))
|
||||
),
|
||||
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||
m(Treetable,
|
||||
{
|
||||
treenodes: treenodes,
|
||||
columnmapper: columnmapper,
|
||||
// Spaltenüberschriften; letzte Spalte ist der
|
||||
// Bearbeiten-Button (ohne Titel).
|
||||
header: ["Kunde", "Kontakt", "Letztes Gespräch", "Wiedervorlage", "Status", ""],
|
||||
// Ohne filter-Attribut rendert das Treetable
|
||||
// keine Suchleiste — die Server-Filter der
|
||||
// Leiste oben sind das einzige Eingrenzungskriterium.
|
||||
// Die Liste ist flach (keine Hierarchie), daher
|
||||
// auch keine Ein-/Ausklappen-Buttons.
|
||||
showExpandButtons: false,
|
||||
// Zeilen-Klick markiert nur (wie in den übrigen
|
||||
// Listen). Die Bearbeitungsmaske wird über den
|
||||
// Telefon-Button je Zeile oder "Workflow
|
||||
// beginnen" geöffnet — nicht über die
|
||||
// Zeilen-Markierung, da der mouseup nach dem
|
||||
// Auswählen einer Filter-Option fälschlich auf
|
||||
// der Zeile darunter landen würde.
|
||||
onRowMarked: (node: iTreenode) => {
|
||||
selectedNode = node
|
||||
},
|
||||
isSelected: (node: iTreenode) => node === selectedNode
|
||||
} as iTreetable
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TAGES-WORKFLOW-MASKE
|
||||
// ============================================================================
|
||||
|
||||
export const TagesWorkflowMaske: m.ClosureComponent = () => {
|
||||
// Kontaktadresse, Historie und Formularzustand werden beim Laden frisch
|
||||
// vom Server geholt bzw. zurückgesetzt.
|
||||
let kontaktadresse: iKontaktadresse | null = null
|
||||
let historie: iCallLog[] = []
|
||||
let geladeneId: number = -1
|
||||
// Formularzustand der neuen Gesprächsnotiz (live gebunden, damit ein
|
||||
// Redraw die Eingaben nicht verwirft).
|
||||
let notizText: string = ""
|
||||
let wiedervorlageDatum: string = ""
|
||||
let selectedTagIds: number[] = []
|
||||
// Asynchron geladene Tag-Liste (nur zuordenbare, nicht is_parent).
|
||||
let tagListe: iTag[] = []
|
||||
// Hinweis aus der Navigation (Kontakt übersprungen), siehe workflowHinweis.
|
||||
let hinweis: string | null = null
|
||||
// Formular-Referenz für das programmgesteuerte Submit ("Speichern und
|
||||
// weiter" löst dasselbe Submit aus wie der Standard-Speichern-Button).
|
||||
let formRef: HTMLFormElement | null = null
|
||||
// Nach erfolgreichem Speichern automatisch zum nächsten Kontakt
|
||||
// weitergeschaltet werden?
|
||||
let weiterNachSpeichern: boolean = false
|
||||
// Eigener Dirty-Merkstand: "Nächster Kontakt" soll ungespeicherte
|
||||
// Eingaben nicht kommentarlos verwerfen.
|
||||
let formDirty: boolean = false
|
||||
// DirtyMaskHelper: Standard-Ziel nach Speichern/Abbrechen ist die
|
||||
// Workflow-Liste (entspricht dem Konzept-Button "Zurück zur Liste").
|
||||
const guard = new DirtyMaskHelper("/tagesworkflow")
|
||||
|
||||
/**
|
||||
* Lädt Kontaktadresse und Historie frisch vom Server und setzt den
|
||||
* Formularzustand zurück. Wird im oninit und beim Wechsel des
|
||||
* Route-Params (Nächster Kontakt) aufgerufen.
|
||||
*/
|
||||
const laden = (kontaktadresseId: number) => {
|
||||
geladeneId = kontaktadresseId
|
||||
kontaktadresse = null
|
||||
historie = []
|
||||
notizText = ""
|
||||
wiedervorlageDatum = ""
|
||||
selectedTagIds = []
|
||||
formDirty = false
|
||||
// Hinweis der vorherigen Navigation übernehmen und konsumieren.
|
||||
hinweis = workflowHinweis
|
||||
workflowHinweis = null
|
||||
fetchKontaktadresseById(kontaktadresseId).then((result) => {
|
||||
kontaktadresse = result
|
||||
}).catch((e) => showServerError(e))
|
||||
fetchCallLogsByKontaktadresse(kontaktadresseId).then((result) => {
|
||||
historie = result
|
||||
}).catch((e) => showServerError(e))
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt den nächsten Kontakt der Workflow-Sitzung: Die Liste wird
|
||||
* mit den aktiven Filtern frisch vom Server geladen — der Server
|
||||
* berechnet Status und Wiedervorlage neu, sodass inzwischen erledigte
|
||||
* oder herausgefallene Kontakte automatisch übersprungen werden
|
||||
* (Konzept 2.5/3.4). Bereits bearbeitete Kontakte dieser Sitzung
|
||||
* werden ebenfalls übersprungen.
|
||||
*/
|
||||
const naechsterKontakt = () => {
|
||||
const aktuelleId = geladeneId
|
||||
const aktuellerName = kontaktadresse ? kontaktadresse.name : ""
|
||||
fetchTagesWorkflowList(workflowFilter).then((liste) => {
|
||||
// Position des aktuellen Kontakts in der frischen Liste suchen.
|
||||
const idx = liste.findIndex((e: iTagesWorkflowEntry) =>
|
||||
e.kontaktadresse_id === aktuelleId
|
||||
)
|
||||
// Kandidaten: ab dem Nachfolger des aktuellen Kontakts; ist
|
||||
// der aktuelle selbst herausgefallen (z.B. gerade als erledigt
|
||||
// markiert), die gesamte Liste.
|
||||
const kandidaten = idx === -1 ? liste : liste.slice(idx + 1)
|
||||
// Erster Kandidat, der in dieser Sitzung noch nicht bearbeitet
|
||||
// wurde. Ist der aktuelle Kontakt herausgefallen, wird der
|
||||
// Grund im Hinweis erläutert.
|
||||
const ziel = kandidaten.find((e: iTagesWorkflowEntry) =>
|
||||
!bearbeiteteIds.has(e.kontaktadresse_id)
|
||||
)
|
||||
if (ziel === undefined) {
|
||||
// Kein Kontakt mehr, der den Filterkriterien entspricht.
|
||||
m.mount(gridMountpoints.modalhook, {
|
||||
view: () => {
|
||||
return m(ModalView,
|
||||
{
|
||||
title: "Tages-Workflow",
|
||||
type: ModalType.MESSAGE,
|
||||
width: '40%'
|
||||
} as iModalView,
|
||||
m("div.w3-panel",
|
||||
m("p", "Keine weiteren Kontakte entsprechen den Filterkriterien.")),
|
||||
m("div.w3-bar.w3-padding-16",
|
||||
m("button.w3-button.w3-theme.w3-right",
|
||||
{
|
||||
onclick: () => {
|
||||
m.mount(gridMountpoints.modalhook, null)
|
||||
m.route.set("/tagesworkflow")
|
||||
}
|
||||
},
|
||||
"Zurück zur Liste")
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (idx === -1) {
|
||||
// Grund für das Herausfallen erläutern (beste Schätzung
|
||||
// anhand des geladenen Erledigt-Status).
|
||||
const grund = kontaktadresse && kontaktadresse.is_erledigt
|
||||
? "er wurde als erledigt markiert"
|
||||
: "sein Wiedervorlage-Datum liegt außerhalb des gefilterten Zeitraums"
|
||||
workflowHinweis = "Kontakt " + aktuellerName + " erfüllt die Filterkriterien nicht mehr (" + grund + ") — es wird der nächste passende Kontakt angezeigt."
|
||||
}
|
||||
m.route.set("/tagesworkflowkontakt", {kontaktadresse_id: ziel.kontaktadresse_id})
|
||||
}).catch((e) => showServerError(e))
|
||||
}
|
||||
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
const routeParams = m.route.param()
|
||||
const kontaktadresseId = routeParams && routeParams.kontaktadresse_id
|
||||
? parseInt(routeParams.kontaktadresse_id) : -1
|
||||
guard.registerGuard()
|
||||
laden(kontaktadresseId)
|
||||
// Tags laden — nur zuordenbare (is_parent=false) Tags.
|
||||
fetchTags().then((result) => {
|
||||
tagListe = result.filter((t: iTag) => !t.is_parent)
|
||||
}).catch((e) => showServerError(e))
|
||||
},
|
||||
onbeforeupdate: (vnode) => {
|
||||
// "Nächster Kontakt" navigiert auf derselben Route mit neuem
|
||||
// kontaktadresse_id-Param — Mithril mountet die Komponente
|
||||
// dabei nicht neu, daher hier auf Parameterwechsel reagieren.
|
||||
const routeParams = m.route.param()
|
||||
const neueId = routeParams && routeParams.kontaktadresse_id
|
||||
? parseInt(routeParams.kontaktadresse_id) : -1
|
||||
if (neueId !== geladeneId) {
|
||||
laden(neueId)
|
||||
}
|
||||
return true
|
||||
},
|
||||
onremove: (vnode) => {
|
||||
guard.cleanup()
|
||||
},
|
||||
view: (vnode) => {
|
||||
const submit = (form: HTMLFormElement): boolean => {
|
||||
if (!kontaktadresse) return false
|
||||
const formData = new FormData(form)
|
||||
const log: iCallLog = {
|
||||
id: -1,
|
||||
projekt_id: kontaktadresse.projekt_id,
|
||||
kontaktadresse_id: kontaktadresse.id,
|
||||
// Wird serverseitig aus current_user.id gesetzt.
|
||||
user_id: null,
|
||||
projekt_name: "",
|
||||
kunden_name: "",
|
||||
mitarbeiter_name: null,
|
||||
datum: heuteIso(),
|
||||
uhrzeit_beginn: jetztUhrzeit(),
|
||||
dauer: "",
|
||||
betreff: "",
|
||||
name_ansprechpartner: kontaktadresse.name,
|
||||
telefon: kontaktadresse.telefon || "",
|
||||
notiz: ((formData.get("notiz") as string) || "").trim(),
|
||||
wiedervorlage_datum: wiedervorlageDatum || "",
|
||||
erledigt: false,
|
||||
tag_ids: selectedTagIds.slice()
|
||||
}
|
||||
createCallLogREST(log).then(() => {
|
||||
// Kontakt als bearbeitet merken, damit "Nächster
|
||||
// Kontakt" ihn in dieser Sitzung nicht erneut anbietet.
|
||||
bearbeiteteIds.add(geladeneId)
|
||||
if (weiterNachSpeichern) {
|
||||
weiterNachSpeichern = false
|
||||
naechsterKontakt()
|
||||
} else {
|
||||
// Beim Kontakt bleiben: Historie und Erledigt-
|
||||
// Status frisch laden (durch die neue Notiz können
|
||||
// sich beide geändert haben), Formular zurücksetzen.
|
||||
laden(geladeneId)
|
||||
}
|
||||
}).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
return true
|
||||
}
|
||||
|
||||
// Form-Attribute des DirtyMaskHelper um die eigene Form-
|
||||
// Referenz erweitern ("Speichern und weiter" triggert das
|
||||
// Submit programmgesteuert) und den Dirty-Zustand mitverfolgen.
|
||||
const formAttrs: any = guard.formAttrs(submit) as iForm
|
||||
const origOnformcreate = formAttrs.onformcreate
|
||||
formAttrs.onformcreate = (f: HTMLFormElement) => {
|
||||
formRef = f
|
||||
origOnformcreate(f)
|
||||
}
|
||||
const origOnDirtyChange = formAttrs.onDirtyChange
|
||||
formAttrs.onDirtyChange = (dirty: boolean) => {
|
||||
formDirty = dirty
|
||||
origOnDirtyChange(dirty)
|
||||
}
|
||||
|
||||
// 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]))
|
||||
|
||||
// Historie client-seitig stabil nach Datum/Uhrzeit absteigend
|
||||
// sortieren (neueste zuerst; Server sortiert nur nach Datum).
|
||||
const historieSortiert = historie.slice().sort((a: iCallLog, b: iCallLog) => {
|
||||
const da = a.datum || ""
|
||||
const db = b.datum || ""
|
||||
if (db !== da) return db.localeCompare(da)
|
||||
return (b.uhrzeit_beginn || "").localeCompare(a.uhrzeit_beginn || "")
|
||||
})
|
||||
|
||||
const kontaktBearbeiten = () => {
|
||||
// Breadcrumb-Kette mit der Workflow-Liste mitgeben, damit
|
||||
// die Kontaktadresse-Maske per Breadcrumb zurückführt.
|
||||
const bc = serializeBc([{
|
||||
route: "/tagesworkflow",
|
||||
params: {},
|
||||
label: "Tages-Workflow"
|
||||
} as iBreadcrumb])
|
||||
m.route.set("/kontaktadresse", {id: geladeneId, bc: bc})
|
||||
}
|
||||
|
||||
return m("div.w3-container.w3-auto.form-view-container",
|
||||
m("h1", "Tages-Workflow" + (kontaktadresse ? ": " + kontaktadresse.name : "")),
|
||||
// Hinweis, falls beim Weiterschalten ein Kontakt übersprungen wurde.
|
||||
hinweis
|
||||
? m("div.w3-panel.w3-pale-yellow.w3-leftbar",
|
||||
{style: {'border-left': '6px solid #ffc107'}},
|
||||
m("p", hinweis))
|
||||
: null,
|
||||
m("div", {style: {flex: '1', 'min-height': '0'}},
|
||||
m(Form, formAttrs, [
|
||||
// ---- Kopfzeile: Projekt- und Kontaktinformationen ----
|
||||
m("div.w3-panel.w3-theme-l4.w3-round-large", {style: {'margin-bottom': '12px'}},
|
||||
m("div.w3-row",
|
||||
m("div.w3-half",
|
||||
m("p", {style: {margin: '4px 0'}},
|
||||
m("strong", "Kunde: "), kontaktadresse ? kontaktadresse.kunden_name : ""),
|
||||
m("p", {style: {margin: '4px 0'}},
|
||||
m("strong", "Projekt: "), kontaktadresse ? kontaktadresse.projekt_name : "")),
|
||||
m("div.w3-half",
|
||||
m("p", {style: {margin: '4px 0'}},
|
||||
m("strong", "Kontakt: "),
|
||||
kontaktadresse ? kontaktadresse.name : "",
|
||||
kontaktadresse && kontaktadresse.telefon ? " | " + kontaktadresse.telefon : "",
|
||||
kontaktadresse && kontaktadresse.email ? " | " + kontaktadresse.email : ""),
|
||||
m("p", {style: {margin: '4px 0'}},
|
||||
m("strong", "Status: "),
|
||||
m("span.w3-tag" + (kontaktadresse && kontaktadresse.is_erledigt ? ".w3-green" : ".w3-grey"),
|
||||
kontaktadresse && kontaktadresse.is_erledigt ? "Erledigt" : "Offen"),
|
||||
m("button.w3-button.w3-theme.w3-right",
|
||||
{
|
||||
type: "button",
|
||||
style: {'margin-top': '-4px'},
|
||||
onclick: kontaktBearbeiten
|
||||
},
|
||||
m("i.material-icons", {style: {verticalAlign: 'middle', marginRight: '4px'}}, "edit"),
|
||||
"Kontakt bearbeiten")))
|
||||
)
|
||||
),
|
||||
// ---- Historie der bisherigen Gesprächsnotizen ----
|
||||
m("h2", {style: {fontSize: '20px'}}, "Bisherige Gesprächsnotizen"),
|
||||
m("div", {style: {'overflow-x': 'auto', 'margin-bottom': '16px'}},
|
||||
m("table.w3-table.w3-bordered.w3-striped",
|
||||
m("thead",
|
||||
m("tr.w3-theme",
|
||||
m("th", "Datum"),
|
||||
m("th", "Mitarbeiter"),
|
||||
m("th", "Notiz"),
|
||||
m("th", "Tags"),
|
||||
m("th", "Wiedervorlage"))),
|
||||
historieSortiert.map((log: iCallLog) =>
|
||||
m("tr",
|
||||
m("td", {style: {'white-space': 'nowrap'}},
|
||||
log.datum + (log.uhrzeit_beginn ? ", " + log.uhrzeit_beginn : "")),
|
||||
m("td", {style: {'white-space': 'nowrap'}},
|
||||
log.mitarbeiter_name || "unbekannt"),
|
||||
m("td", log.notiz || ""),
|
||||
m("td", (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))),
|
||||
m("td", {style: {'white-space': 'nowrap'}}, log.wiedervorlage_datum || ""))
|
||||
)
|
||||
)
|
||||
),
|
||||
// ---- Formular für die neue Gesprächsnotiz ----
|
||||
m("h2", {style: {fontSize: '20px'}}, "Neue Gesprächsnotiz"),
|
||||
m("label", "Notiz",
|
||||
m("textarea.w3-input.w3-border", {
|
||||
name: "notiz",
|
||||
value: notizText,
|
||||
rows: 4,
|
||||
required: true,
|
||||
style: {'margin-bottom': '8px'},
|
||||
oninput: (e: Event) => {
|
||||
notizText = (e.target as HTMLTextAreaElement).value
|
||||
}
|
||||
})),
|
||||
m("label", "Tags", m(MultiSelect, {
|
||||
name: "tag_ids",
|
||||
selected: selectedTagIds,
|
||||
options: tagOptionen,
|
||||
colors: tagColors,
|
||||
placeholder: "kein Tag",
|
||||
modalTitle: "Tags auswählen",
|
||||
editTooltip: "Tags bearbeiten",
|
||||
onchanged: (newSelection: number[]) => {
|
||||
selectedTagIds = newSelection.slice()
|
||||
// Wiedervorlage-Datum automatisch aus den Tags
|
||||
// berechnen (Basis heute), falls ein Tag mit
|
||||
// loest_wiedervorlage_aus gewählt wurde.
|
||||
const vorschlag = berechneWiedervorlageDatum(
|
||||
newSelection, tagListe, heuteIso())
|
||||
if (vorschlag) {
|
||||
wiedervorlageDatum = vorschlag
|
||||
}
|
||||
}
|
||||
} as iMultiSelect)),
|
||||
m("label", "Wiedervorlage Datum", m(Input, {
|
||||
name: "wiedervorlage_datum",
|
||||
value: wiedervorlageDatum,
|
||||
type: "date",
|
||||
oninput: (e: Event) => {
|
||||
wiedervorlageDatum = (e.target as HTMLInputElement).value
|
||||
}
|
||||
} as iInput)),
|
||||
// ---- Workflow-Navigation ----
|
||||
// "Speichern" und "Abbrechen" (= Zurück zur Liste)
|
||||
// liefert der Form-Standard-Button-Bereich.
|
||||
m("div.w3-bar.w3-padding-16",
|
||||
m("button.w3-button.w3-theme.w3-right", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
weiterNachSpeichern = true
|
||||
if (formRef && typeof formRef.requestSubmit === "function") {
|
||||
formRef.requestSubmit()
|
||||
}
|
||||
}
|
||||
},
|
||||
m("i.material-icons", {style: {verticalAlign: 'middle', marginRight: '4px'}}, "skip_next"),
|
||||
"Speichern und weiter"),
|
||||
m("button.w3-button.w3-theme-l2.w3-right", {
|
||||
type: "button",
|
||||
style: {'margin-right': '4px'},
|
||||
onclick: () => {
|
||||
if (formDirty &&
|
||||
!window.confirm("Es gibt ungespeicherte Eingaben. Ohne Speichern zum nächsten Kontakt wechseln?")) {
|
||||
return
|
||||
}
|
||||
naechsterKontakt()
|
||||
}
|
||||
},
|
||||
m("i.material-icons", {style: {verticalAlign: 'middle', marginRight: '4px'}}, "arrow_forward"),
|
||||
"Nächster Kontakt")
|
||||
)
|
||||
])
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user