feat(workflow): Erledigt-Feld entfernen und Tages-Workflow-Maske überarbeiten

Das redundante 'erledigt'-Feld wird aus CallLog entfernt (Modell, API,
Client, Migration _db_V01_15 DROP COLUMN). Der Erledigt-Status ergibt
sich dynamisch aus den zugeordneten Tags (is_erledigend).

Die Tages-Workflow-Maske zeigt Datum, Uhrzeit Beginn und Dauer der neuen
Notiz als Read-Only-Panel; die Dauer läuft als Timer (mm:ss) mit. Ein
Betreff-Feld ergänzt die neue Notiz. Mit flexContent wird der
Formular-Inhalt als Flex-Spalte gerendert, das Historie-Panel füllt den
verbleibenden Platz und scrollt in sich.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
gg committed 2026-09-27 10:18:09 +02:00
1 parent 929664453e
commit 6e3cc7375d
9 files changed
+287 -122

No files matched your search

-1
View File
@@ -257,7 +257,6 @@ def _seed_muster_calllogs():
log.notiz = rng.choice(notizvorlagen)
wiedervorlage_monat = monat + 1 if monat < 12 else 1
log.wiedervorlage_datum = "2024-0{m}-{t}".format(m=wiedervorlage_monat, t=tag) if wiedervorlage_monat < 10 else "2024-{m}-{t}".format(m=wiedervorlage_monat, t=tag)
log.erledigt = rng.choice([True, False])
session.add(log)
session.commit()
-1
View File
@@ -314,7 +314,6 @@ class CallLog(dbapp.Model):
telefon = Column(String(100), nullable=True)
notiz = Column(String(2000), nullable=True)
wiedervorlage_datum = Column(Date, nullable=True)
erledigt = Column(Boolean, nullable=False, default=False)
projekt = relationship('Projekt')
kontaktadresse = relationship('Kontaktadresse', back_populates='call_logs')
+1 -3
View File
@@ -161,7 +161,7 @@ def create_call_log(data: dict, user_id: int | None = None) -> CallLog:
- data: dict mit den Feldern projekt_id, kontaktadresse_id, datum,
uhrzeit_beginn, dauer, betreff, name_ansprechpartner, telefon,
notiz, wiedervorlage_datum, erledigt, tag_ids
notiz, wiedervorlage_datum, tag_ids
- user_id: ID des angemeldeten Mitarbeiters, der die Notiz erfasst
(Ersteller). Wird von der Route übergeben (current_user.id),
damit diese Funktion frei vom Flask-Request-Kontext bleibt.
@@ -198,7 +198,6 @@ def create_call_log(data: dict, user_id: int | None = None) -> CallLog:
call_log.telefon = (data.get("telefon") or "").strip() or None
call_log.notiz = (data.get("notiz") or "").strip() or None
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
call_log.erledigt = bool(data.get("erledigt", False))
_set_call_log_tags(call_log, data.get("tag_ids"))
session.add(call_log)
session.commit()
@@ -234,7 +233,6 @@ def update_call_log(data: dict) -> CallLog:
call_log.telefon = (data.get("telefon") or "").strip() or None
call_log.notiz = (data.get("notiz") or "").strip() or None
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
call_log.erledigt = bool(data.get("erledigt", False))
_set_call_log_tags(call_log, data.get("tag_ids"))
session.commit()
return call_log
+34
View File
@@ -366,6 +366,40 @@ def _db_V01_14():
session.commit()
def _db_V01_15():
"""
Entfernt die Spalte 'erledigt' aus der Tabelle 'call_logs'. Der
Erledigt-Status einer Gesprächsnotiz ist redundante Information:
Er ergibt sich dynamisch daraus, ob einer der zugeordneten Tags
is_erledigend=True hat (Kontaktadresse.is_erledigt()). Das Feld
wurde aus Modell und Client entfernt.
SQLite unterstützt DROP COLUMN ab Version 3.35; die Laufzeitumgebung
liefert 3.40.1.
Idempotent: Bei Neuinstallation existiert die Spalte nicht mehr
(create_all legt sie nicht mehr an) — der PRAGMA-Check überspringt
das DROP COLUMN in diesem Fall.
"""
from sqlalchemy import text
result = session.execute(text("PRAGMA table_info(call_logs)"))
spalten = {row[1] for row in result}
if 'erledigt' in spalten:
session.execute(text(
"ALTER TABLE call_logs DROP COLUMN erledigt"
))
# Roh-SQL-Änderungen committen, BEVOR die Systeminfo aktualisiert wird
# (dasselbe Muster wie _db_V01_08 bis _db_V01_14): Die DDL-Anweisung
# läuft über die Default-Bind-Verbindung — SQLALCHEMY_DATABASE_URI
# zeigt auf dieselbe Datei wie der appdb-Bind. Das ORM-Update der
# Systeminfo läuft über die appdb-Bind-Verbindung; ohne dieses Commit
# blockiert die offene Transaktion das UPDATE ('database is locked').
session.commit()
_updatesysteminfo('01.15')
session.commit()
def checkupdates():
"""
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
+2 -9
View File
@@ -52,7 +52,6 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
telefon: "",
notiz: "",
wiedervorlage_datum: "",
erledigt: false,
from_liste: "1"
}
if (filterProjektId !== null) {
@@ -105,7 +104,6 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
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"))
]
}
@@ -200,7 +198,6 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
telefon: string
notiz: string
wiedervorlage_datum: string
erledigt: boolean
} = {
id: -1,
projekt_id: -1,
@@ -214,8 +211,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
name_ansprechpartner: "",
telefon: "",
notiz: "",
wiedervorlage_datum: "",
erledigt: false
wiedervorlage_datum: ""
}
// Ist das Projekt voreingestellt (Liste wurde projektgefiltert)?
let projektVoreingestellt: boolean = false
@@ -312,7 +308,6 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
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")
// Kontaktadresse-ID aus den Route-Params übernehmen, wenn die
// Maske aus der Kontaktadressenliste geöffnet wurde.
currentLog.kontaktadresse_id = routeParams && routeParams.kontaktadresse_id ? parseInt(routeParams.kontaktadresse_id) : -1
@@ -391,7 +386,6 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
telefon: (formData.get("telefon") as string) || "",
notiz: (formData.get("notiz") as string) || "",
wiedervorlage_datum: currentLog.wiedervorlage_datum || "",
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) {
@@ -540,8 +534,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
currentLog.wiedervorlage_datum = (e.target as HTMLInputElement).value
guard.markDirty()
}
} as iInput)),
m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt")
} as iInput))
])
)
)
-1
View File
@@ -73,7 +73,6 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
telefon: kontaktadresse.telefon,
notiz: "",
wiedervorlage_datum: "",
erledigt: false,
bc: serializeBc(chain)
})
}
-1
View File
@@ -85,7 +85,6 @@ export const projektliste: m.ClosureComponent = (vnode) => {
telefon: "",
notiz: "",
wiedervorlage_datum: "",
erledigt: false,
bc: serializeBc(chain)
})
}
+247 -105
View File
@@ -53,6 +53,21 @@ function jetztUhrzeit(): string {
return now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
}
/**
* Aktualisierungsintervall für die Dauer-Anzeige in der Workflow-Maske
* (Millisekunden), analog zur CallLog-Maske.
*/
const DAUER_UPDATE_INTERVALL = 1000
/**
* Formatiert verstrichene Sekunden als mm:ss (wie die CallLog-Maske).
*/
function 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')
}
// ============================================================================
// TAGES-WORKFLOW-LISTE
// ============================================================================
@@ -395,6 +410,26 @@ export const TagesWorkflowMaske: m.ClosureComponent = () => {
let notizText: string = ""
let wiedervorlageDatum: string = ""
let selectedTagIds: number[] = []
// Datum, Uhrzeit Beginn, Dauer und Betreff der neuen Notiz
// (analog zur CallLog-Maske). Datum, Uhrzeit und Dauer werden beim
// Öffnen der Maske gesetzt und als Read-Only-Anzeige dargestellt —
// die Dauer läuft als Timer mit (mm:ss). Ein Erledigt-Feld gibt es
// nicht: Der Status ergibt sich aus einem ggf. zugeordneten
// erledigend-Tag.
let notizDatum: string = ""
let notizUhrzeit: string = ""
let notizDauer: string = ""
let notizBetreff: string = ""
// Startzeit für die Dauer-Messung: wird bei jedem Laden (Maskenaufruf
// und Kontaktwechsel) neu gesetzt; der Timer läuft durch und aktualisiert
// die Dauer-Anzeige laufend.
let startZeit: Date | null = null
// Timer-Handle für die Dauer-Aktualisierung.
let timerId: number | null = null
// Direkte DOM-Referenz auf die Dauer-Anzeige: Der Timer ist kein
// Mithril-Event und löst keinen Redraw aus — die Anzeige wird daher
// direkt im DOM aktualisiert (wie in der CallLog-Maske).
let dauerAnzeige: HTMLElement | null = null
// Asynchron geladene Tag-Liste (nur zuordenbare, nicht is_parent).
let tagListe: iTag[] = []
// Hinweis aus der Navigation (Kontakt übersprungen), siehe workflowHinweis.
@@ -424,7 +459,13 @@ export const TagesWorkflowMaske: m.ClosureComponent = () => {
notizText = ""
wiedervorlageDatum = ""
selectedTagIds = []
notizDatum = heuteIso()
notizUhrzeit = jetztUhrzeit()
notizDauer = "00:00"
notizBetreff = ""
formDirty = false
// Dauer-Messung für die neue Notiz starten.
startZeit = new Date()
// Hinweis der vorherigen Navigation übernehmen und konsumieren.
hinweis = workflowHinweis
workflowHinweis = null
@@ -508,6 +549,17 @@ export const TagesWorkflowMaske: m.ClosureComponent = () => {
? parseInt(routeParams.kontaktadresse_id) : -1
guard.registerGuard()
laden(kontaktadresseId)
// Dauer-Timer starten (läuft über Kontaktwechsel hinweg; die
// Startzeit setzt laden() bei jedem Kontakt neu).
if (timerId == null) {
timerId = window.setInterval(() => {
if (startZeit) {
const sekunden = Math.floor((Date.now() - startZeit.getTime()) / 1000)
notizDauer = formatDauer(sekunden)
if (dauerAnzeige) { dauerAnzeige.textContent = notizDauer }
}
}, DAUER_UPDATE_INTERVALL)
}
// Tags laden — nur zuordenbare (is_parent=false) Tags.
fetchTags().then((result) => {
tagListe = result.filter((t: iTag) => !t.is_parent)
@@ -526,6 +578,7 @@ export const TagesWorkflowMaske: m.ClosureComponent = () => {
return true
},
onremove: (vnode) => {
if (timerId != null) { window.clearInterval(timerId) }
guard.cleanup()
},
view: (vnode) => {
@@ -541,15 +594,16 @@ export const TagesWorkflowMaske: m.ClosureComponent = () => {
projekt_name: "",
kunden_name: "",
mitarbeiter_name: null,
datum: heuteIso(),
uhrzeit_beginn: jetztUhrzeit(),
dauer: "",
betreff: "",
// Datum, Uhrzeit und Dauer kommen aus den Read-Only-
// Anzeigen (Status), nicht aus dem Formular.
datum: notizDatum || heuteIso(),
uhrzeit_beginn: notizUhrzeit || jetztUhrzeit(),
dauer: notizDauer,
betreff: ((formData.get("betreff") as string) || "").trim(),
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(() => {
@@ -614,6 +668,11 @@ export const TagesWorkflowMaske: m.ClosureComponent = () => {
m("i.material-icons", {style: {verticalAlign: 'middle', marginRight: '4px'}}, "arrow_forward"),
"Nächster Kontakt")
]
// Inhaltsbereich der Form als Flex-Spalte rendern (flexContent):
// Das Historie-Panel unten füllt damit den verbleibenden Platz
// und scrollt in sich — lange Historien machen die Maske nicht
// selbst scrollbar.
formAttrs.flexContent = true
// Tag-Optionen und Farb-Map für die Mehrfachauswahl.
const tagOptionen: iSelectOption[] = tagListe.map((t: iTag) => {
@@ -651,110 +710,193 @@ export const TagesWorkflowMaske: m.ClosureComponent = () => {
: 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")))
)
// Fester Teil der Maske (Kopfzeile, Neue Gesprächsnotiz):
// flex '0 0 auto' verhindert, dass der Flex-Inhaltsbereich
// der Form (flexContent) diese Blöcke staucht — bei zu
// wenig Platz scrollt die Maske stattdessen als Ganzes.
m("div", {style: {flex: '0 0 auto'}},
// ---- 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")))
)
),
// ---- Formular für die neue Gesprächsnotiz ----
m("h2", {style: {fontSize: '20px'}}, "Neue Gesprächsnotiz"),
// Datum, Uhrzeit Beginn und Dauer als Read-Only-Info im
// Stil der Kopfzeile (gleiches Panel-Layout): Die Werte
// werden beim Öffnen der Maske gesetzt, die Dauer läuft
// als Timer mit (mm:ss) und wird direkt im DOM
// aktualisiert. w3-third bricht die Zeile auf kleinen
// Bildschirmen um.
m("div.w3-panel.w3-theme-l4.w3-round-large", {style: {'margin-bottom': '12px'}},
m("div.w3-row",
m("div.w3-third",
m("p", {style: {margin: '4px 0'}},
m("strong", "Datum: "), notizDatum || "—")),
m("div.w3-third",
m("p", {style: {margin: '4px 0'}},
m("strong", "Uhrzeit Beginn: "), notizUhrzeit || "—")),
m("div.w3-third",
m("p", {style: {margin: '4px 0'}},
m("strong", "Dauer: "),
m("span", {
oncreate: (vnode: m.VnodeDOM) => {
dauerAnzeige = vnode.dom as HTMLElement
}
}, notizDauer))))
),
m("label", "Betreff", m(Input, {
name: "betreff",
value: notizBetreff,
type: "text",
oninput: (e: Event) => {
notizBetreff = (e.target as HTMLInputElement).value
}
} as iInput)),
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
}
})),
// Tags (links) und Wiedervorlage-Datum (rechts) in
// einer Zeile, um Platz zu sparen. w3-twothird/w3-third
// brechen die Zeile auf kleinen Bildschirmen um.
m("div.w3-row-padding",
m("div.w3-twothird",
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("div.w3-third",
m("label", "Wiedervorlage Datum", m(Input, {
name: "wiedervorlage_datum",
value: wiedervorlageDatum,
type: "date",
oninput: (e: Event) => {
wiedervorlageDatum = (e.target as HTMLInputElement).value
}
} as iInput)))
),
),
// ---- 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 || ""))
// Steht bewusst unterhalb des Formulars: Der Nutzer
// sieht beim Öffnen der Maske direkt die Eingabefelder
// für die neue Notiz, die Historie dient als Nachschlag.
// Das Panel füllt den verbleibenden Platz des Inhalts-
// bereichs (flex '1 0 200px': füllt auf, schrumpft aber
// nie unter 200px — reicht der Platz nicht, scrollt die
// Maske als Ganzes). Die Tabelle scrollt darin in sich.
// Abgerundeter grauer Panel-Hintergrund (analog der
// Kopfzeile); die Schriften im Block sind eine Stufe
// kleiner gehalten (Überschrift 18px, Tabelle 13px),
// damit die Historie optisch hinter dem Formular
// zurücktritt.
m("div.w3-panel.w3-light-grey.w3-round-large", {
style: {
flex: '1 0 200px',
'min-height': '0',
display: 'flex',
'flex-direction': 'column',
'margin-bottom': '0'
}
},
m("h2", {style: {fontSize: '18px', flex: '0 0 auto'}}, "Bisherige Gesprächsnotizen"),
// Eigener Scroll-Container: Bei langer Historie
// scrollt nur die Tabelle, nicht die gesamte Maske.
// Wichtig: Der div muss selbst der Scroll-Container
// sein (nicht die Tabelle), damit sich position:sticky
// der th-Zellen auf ihn bezieht (Muster wie im
// Treetable-Widget).
m("div", {style: {flex: '1', 'min-height': '0', 'overflow-x': 'auto', 'overflow-y': 'auto'}},
m("table.w3-table.w3-bordered.w3-striped", {style: {fontSize: '13px'}},
m("thead",
m("tr.w3-theme",
["Datum", "Mitarbeiter", "Dauer", "Betreff", "Notiz", "Tags", "Wiedervorlage"].map((titel: string) =>
m("th.w3-theme", {
// Sticky-Header mit eigenem Hintergrund:
// Der tr-Hintergrund wandert beim
// Scrollen mit den Daten weg, ohne
// eigene Fläche scheinen Zeilen
// durch (Muster wie Treetable).
style: {
position: 'sticky',
top: '0',
'z-index': '1',
'text-align': 'left'
}
}, titel)))),
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", {style: {'white-space': 'nowrap'}}, log.dauer || ""),
m("td", log.betreff || ""),
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))
)
])
)
)
+3 -1
View File
@@ -13,6 +13,9 @@ import {iTag} from "./tag"
* Alt-Notizen ohne erfassten Ersteller haben null.
* mitarbeiter_name ist keine Spalte, sondern wird serverseitig aus der
* user_id gegen die Auth-Datenbank aufgelöst (null ohne user_id).
* Ein Erledigt-Status ist bewusst kein Feld der Notiz: Er ergibt sich
* daraus, ob einer der zugeordneten Tags is_erledigend=True hat (siehe
* Kontaktadresse.is_erledigt).
* tags (vom Server beim Lesen) und tag_ids (vom Client beim Schreiben)
* steuern die n:m-Beziehung zu den Tags.
*/
@@ -34,7 +37,6 @@ export interface iCallLog {
telefon: string
notiz: string
wiedervorlage_datum: string
erledigt: boolean
/** Vom Server gelieferte zugeordnete Tags (nur beim Lesen). */
tags?: iTag[]
/** Vom Client zu übermittelnde Tag-IDs (nur beim Schreiben). */