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
+1246
-43
No files matched your search
@@ -263,12 +263,14 @@ class Kontaktadresse(dbapp.Model):
|
|||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""
|
"""
|
||||||
Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden
|
Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden
|
||||||
über columns2dict serialisiert; projekt_name wird zusätzlich aus der
|
über columns2dict serialisiert; projekt_name und kunden_name werden
|
||||||
Projekt-Relationship eingefügt. is_erledigt wird dynamisch aus den
|
zusätzlich aus der Projekt- bzw. Kunde-Relationship eingefügt.
|
||||||
Gesprächsnotizen des Kontakts berechnet.
|
is_erledigt wird dynamisch aus den Gesprächsnotizen des Kontakts
|
||||||
|
berechnet.
|
||||||
"""
|
"""
|
||||||
result = columns2dict(self)
|
result = columns2dict(self)
|
||||||
result['projekt_name'] = self.projekt.projektname if self.projekt else None
|
result['projekt_name'] = self.projekt.projektname if self.projekt else None
|
||||||
|
result['kunden_name'] = self.projekt.kunde.name if self.projekt and self.projekt.kunde else None
|
||||||
result['is_erledigt'] = self.is_erledigt()
|
result['is_erledigt'] = self.is_erledigt()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -296,6 +298,14 @@ class CallLog(dbapp.Model):
|
|||||||
# NOT NULL) — die Pflicht wird über die Server-Validierung in
|
# NOT NULL) — die Pflicht wird über die Server-Validierung in
|
||||||
# crm.model.call_log durchgesetzt.
|
# crm.model.call_log durchgesetzt.
|
||||||
kontaktadresse_id = Column(Integer, ForeignKey('kontaktadressen.id'), nullable=False)
|
kontaktadresse_id = Column(Integer, ForeignKey('kontaktadressen.id'), nullable=False)
|
||||||
|
# Ersteller der Gesprächsnotiz (Mitarbeiter, der den Anruf getätigt
|
||||||
|
# hat). Referenziert logisch die 'id' in der Auth-Datenbanktabelle
|
||||||
|
# 'users'; ein physischer Fremdschlüssel ist aufgrund der zwei
|
||||||
|
# getrennten SQLite-Datenbanken nicht möglich (Muster wie
|
||||||
|
# projekt_users.user_id). nullable=True, da Alt-Notizen (angelegt
|
||||||
|
# vor der Spalte) keinen Ersteller erfassen konnten; die Auflösung
|
||||||
|
# auf den username erfolgt zur Laufzeit über eine separate Query.
|
||||||
|
user_id = Column(Integer, nullable=True)
|
||||||
datum = Column(Date, nullable=True)
|
datum = Column(Date, nullable=True)
|
||||||
uhrzeit_beginn = Column(String(10), nullable=True)
|
uhrzeit_beginn = Column(String(10), nullable=True)
|
||||||
dauer = Column(String(10), nullable=True)
|
dauer = Column(String(10), nullable=True)
|
||||||
@@ -319,8 +329,10 @@ class CallLog(dbapp.Model):
|
|||||||
"""
|
"""
|
||||||
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
|
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
|
||||||
über columns2dict serialisiert; projekt_name und kunden_name werden
|
über columns2dict serialisiert; projekt_name und kunden_name werden
|
||||||
zusätzlich aus der Projekt-Relationship eingefügt. Die zugeordneten
|
zusätzlich aus der Projekt-Relationship eingefügt. mitarbeiter_name
|
||||||
Tags werden als Liste von Tag-Dictionaries unter 'tags' ergänzt.
|
löst die user_id gegen die Auth-Datenbank auf (None bei Alt-Notizen
|
||||||
|
ohne erfassten Ersteller). Die zugeordneten Tags werden als Liste
|
||||||
|
von Tag-Dictionaries unter 'tags' ergänzt.
|
||||||
"""
|
"""
|
||||||
result = columns2dict(self)
|
result = columns2dict(self)
|
||||||
if self.projekt:
|
if self.projekt:
|
||||||
@@ -329,6 +341,14 @@ class CallLog(dbapp.Model):
|
|||||||
else:
|
else:
|
||||||
result['projekt_name'] = None
|
result['projekt_name'] = None
|
||||||
result['kunden_name'] = None
|
result['kunden_name'] = None
|
||||||
|
# Ersteller gegen die Auth-Datenbank auflösen (Muster wie
|
||||||
|
# crm.model.projekt_user). Alt-Notizen ohne user_id erhalten None.
|
||||||
|
result['mitarbeiter_name'] = None
|
||||||
|
if self.user_id is not None:
|
||||||
|
from auth.model import User
|
||||||
|
user = User.query.get(self.user_id)
|
||||||
|
if user is not None:
|
||||||
|
result['mitarbeiter_name'] = user.username
|
||||||
# Zugeordnete Tags serialisieren (nur is_parent=False sind zuordenbar).
|
# Zugeordnete Tags serialisieren (nur is_parent=False sind zuordenbar).
|
||||||
result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel]
|
result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel]
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -94,13 +94,17 @@ def _get_validierte_kontaktadresse_id(data: dict, projekt_id) -> int:
|
|||||||
return kontaktadresse.id
|
return kontaktadresse.id
|
||||||
|
|
||||||
|
|
||||||
def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
|
def get_call_logs(projekt_id: int | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
kontaktadresse_id: int | None = None):
|
||||||
"""
|
"""
|
||||||
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
|
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
|
||||||
|
|
||||||
Ist projekt_id gesetzt, werden nur die Notizen dieses Projekts
|
Ist projekt_id gesetzt, werden nur die Notizen dieses Projekts
|
||||||
zurückgegeben (serverseitige Filterung). Ohne projekt_id werden
|
zurückgegeben (serverseitige Filterung). Ist kontaktadresse_id
|
||||||
alle Notizen geliefert.
|
gesetzt, werden nur die Notizen dieses Kontakts zurückgegeben —
|
||||||
|
beide Filter können kombiniert werden. Ohne Filter werden alle
|
||||||
|
Notizen geliefert.
|
||||||
|
|
||||||
Ist user_id gesetzt, werden nur Notizen von Projekten geliefert, denen
|
Ist user_id gesetzt, werden nur Notizen von Projekten geliefert, denen
|
||||||
der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-Filterung).
|
der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-Filterung).
|
||||||
@@ -109,6 +113,8 @@ def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
|
|||||||
query = CallLog.query
|
query = CallLog.query
|
||||||
if projekt_id is not None:
|
if projekt_id is not None:
|
||||||
query = query.filter(CallLog.projekt_id == projekt_id)
|
query = query.filter(CallLog.projekt_id == projekt_id)
|
||||||
|
if kontaktadresse_id is not None:
|
||||||
|
query = query.filter(CallLog.kontaktadresse_id == kontaktadresse_id)
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
# Nur Notizen von Projekten, denen der Nutzer zugeordnet ist.
|
# Nur Notizen von Projekten, denen der Nutzer zugeordnet ist.
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
@@ -148,7 +154,7 @@ def get_call_log(call_log_id: int, user_id: int | None = None) -> CallLog:
|
|||||||
return call_log
|
return call_log
|
||||||
|
|
||||||
|
|
||||||
def create_call_log(data: dict) -> CallLog:
|
def create_call_log(data: dict, user_id: int | None = None) -> CallLog:
|
||||||
"""
|
"""
|
||||||
Legt eine neue Gesprächsnotiz an. Die vom Client übergebene ID wird
|
Legt eine neue Gesprächsnotiz an. Die vom Client übergebene ID wird
|
||||||
ignoriert; die ID wird serverseitig per Auto-Increment vergeben.
|
ignoriert; die ID wird serverseitig per Auto-Increment vergeben.
|
||||||
@@ -156,6 +162,11 @@ def create_call_log(data: dict) -> CallLog:
|
|||||||
- data: dict mit den Feldern projekt_id, kontaktadresse_id, datum,
|
- data: dict mit den Feldern projekt_id, kontaktadresse_id, datum,
|
||||||
uhrzeit_beginn, dauer, betreff, name_ansprechpartner, telefon,
|
uhrzeit_beginn, dauer, betreff, name_ansprechpartner, telefon,
|
||||||
notiz, wiedervorlage_datum, erledigt, tag_ids
|
notiz, wiedervorlage_datum, erledigt, 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.
|
||||||
|
Der Ersteller wird beim Anlegen gesetzt und beim späteren
|
||||||
|
Update nicht mehr verändert.
|
||||||
"""
|
"""
|
||||||
projekt_id = data.get("projekt_id")
|
projekt_id = data.get("projekt_id")
|
||||||
if projekt_id is None:
|
if projekt_id is None:
|
||||||
@@ -173,6 +184,12 @@ def create_call_log(data: dict) -> CallLog:
|
|||||||
call_log = CallLog()
|
call_log = CallLog()
|
||||||
call_log.projekt_id = int(projekt_id)
|
call_log.projekt_id = int(projekt_id)
|
||||||
call_log.kontaktadresse_id = kontaktadresse_id
|
call_log.kontaktadresse_id = kontaktadresse_id
|
||||||
|
# Ersteller erfassen: welcher Mitarbeiter den Anruf getätigt hat.
|
||||||
|
# Alt-Notizen (vor der user_id-Spalte angelegt) haben NULL; neue
|
||||||
|
# Notizen ohne Angabe erhalten ebenfalls NULL, bleiben aber die
|
||||||
|
# Ausnahme, da die Route immer current_user.id übergibt.
|
||||||
|
if user_id is not None:
|
||||||
|
call_log.user_id = int(user_id)
|
||||||
call_log.datum = _parse_date(data.get("datum"))
|
call_log.datum = _parse_date(data.get("datum"))
|
||||||
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
||||||
call_log.dauer = (data.get("dauer") or "").strip() or None
|
call_log.dauer = (data.get("dauer") or "").strip() or None
|
||||||
|
|||||||
@@ -325,6 +325,47 @@ def _db_V01_13():
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _db_V01_14():
|
||||||
|
"""
|
||||||
|
Fügt der Tabelle 'call_logs' die Spalte 'user_id' hinzu: Jede
|
||||||
|
Gesprächsnotiz erfasst, welcher Mitarbeiter den Anruf getätigt hat.
|
||||||
|
Die Spalte referenziert logisch die 'id' in der Auth-Datenbanktabelle
|
||||||
|
'users'; ein physischer Fremdschlüssel ist aufgrund der zwei
|
||||||
|
getrennten SQLite-Datenbanken nicht möglich (Muster wie
|
||||||
|
projekt_users.user_id).
|
||||||
|
|
||||||
|
Kein Backfill: Für bestehende CallLogs ist der Ersteller nicht mehr
|
||||||
|
ermittelbar — die Spalte bleibt dort NULL (Anzeige 'unbekannt').
|
||||||
|
Neue Notizen erhalten die user_id serverseitig über current_user.id
|
||||||
|
(siehe crm.routes.calllog_create).
|
||||||
|
|
||||||
|
Idempotent: Bei Neuinstallation hat create_all die Spalte bereits
|
||||||
|
angelegt — in diesem Fall wird das ALTER TABLE übersprungen. Der
|
||||||
|
Index ist über IF NOT EXISTS ebenfalls idempotent.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
result = session.execute(text("PRAGMA table_info(call_logs)"))
|
||||||
|
spalten = {row[1] for row in result}
|
||||||
|
if 'user_id' not in spalten:
|
||||||
|
session.execute(text(
|
||||||
|
"ALTER TABLE call_logs ADD COLUMN user_id INTEGER"
|
||||||
|
))
|
||||||
|
session.execute(text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_call_logs_user_id"
|
||||||
|
" ON call_logs (user_id)"
|
||||||
|
))
|
||||||
|
# Roh-SQL-Änderungen committen, BEVOR die Systeminfo aktualisiert wird
|
||||||
|
# (dasselbe Muster wie _db_V01_08 und _db_V01_13): 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.14')
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
def checkupdates():
|
def checkupdates():
|
||||||
"""
|
"""
|
||||||
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
|
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
Geschäftslogik für den Tages-Workflow (Telefon-Akquise).
|
||||||
|
|
||||||
|
Aggregiert pro Kontaktadresse die für den Tages-Workflow relevanten
|
||||||
|
Informationen: letzte Gesprächsnotiz, aktuell gültiges Wiedervorlage-Datum
|
||||||
|
und dynamisch berechneter Erledigt-Status. Filter (Kunde, Status,
|
||||||
|
Wiedervorlage-Zeitraum) und die priorisierte Sortierung sind Teil der
|
||||||
|
Aggregation — der Client erhält eine fertige, gefilterte Liste.
|
||||||
|
|
||||||
|
Aufgerufen werden die Funktionen aus crm/routes.py; die Autorisierungs-
|
||||||
|
prüfung erfolgt dort über den Decorator authorisation_required.
|
||||||
|
|
||||||
|
:author: Mistral Vibe <vibe@mistral.ai>
|
||||||
|
:date: 2026-09-23
|
||||||
|
:version: 1.0
|
||||||
|
"""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from db import dbapp
|
||||||
|
from crm.model import Kontaktadresse, Projekt, ProjektUser
|
||||||
|
|
||||||
|
session = dbapp.session
|
||||||
|
|
||||||
|
|
||||||
|
def _naechste_woche_start_und_ende(heute: date) -> tuple[date, date]:
|
||||||
|
"""
|
||||||
|
Liefert Montag und Sonntag der ISO-Kalenderwoche, in die der
|
||||||
|
übergebene Tag fällt.
|
||||||
|
"""
|
||||||
|
jahr, woche, _ = heute.isocalendar()
|
||||||
|
montag = date.fromisocalendar(jahr, woche, 1)
|
||||||
|
sonntag = montag + timedelta(days=6)
|
||||||
|
return montag, sonntag
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_im_wiedervorlage_zeitraum(wiedervorlage_datum: date | None,
|
||||||
|
zeitraum: str | None,
|
||||||
|
heute: date) -> bool:
|
||||||
|
"""
|
||||||
|
Prüft, ob das Wiedervorlage-Datum in den angeforderten Zeitraum
|
||||||
|
fällt. Ohne Zeitraum (None) ist die Prüfung immer wahr.
|
||||||
|
|
||||||
|
Kontakte ohne wiedervorlage_datum fallen bei gesetztem Zeitraum
|
||||||
|
heraus — auch Kontakte ohne Gesprächsnotizen, da für sie kein
|
||||||
|
Wiedervorlage-Datum existiert.
|
||||||
|
"""
|
||||||
|
if not zeitraum:
|
||||||
|
return True
|
||||||
|
if wiedervorlage_datum is None:
|
||||||
|
return False
|
||||||
|
if zeitraum == "heute":
|
||||||
|
return wiedervorlage_datum == heute
|
||||||
|
if zeitraum == "3tage":
|
||||||
|
return heute <= wiedervorlage_datum <= heute + timedelta(days=3)
|
||||||
|
if zeitraum == "ueberfaellig":
|
||||||
|
return wiedervorlage_datum < heute
|
||||||
|
if zeitraum == "woche":
|
||||||
|
montag, sonntag = _naechste_woche_start_und_ende(heute)
|
||||||
|
return montag <= wiedervorlage_datum <= sonntag
|
||||||
|
# Unbekannte Zeitraum-Angaben wirken wie kein Filter.
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _sortierschlüssel(eintrag: dict, heute: date) -> tuple:
|
||||||
|
"""
|
||||||
|
Priorisierte Sortierung der Tages-Workflow-Liste (Konzept 2.2):
|
||||||
|
|
||||||
|
- Prio 0: überfällige Wiedervorlagen (wiedervorlage < heute),
|
||||||
|
aufsteigend nach Datum
|
||||||
|
- Prio 1: Kontakte ohne Gesprächsnotizen
|
||||||
|
- Prio 2: Rest (heute/zukünftig), aufsteigend nach Wiedervorlage;
|
||||||
|
Notizen ohne Wiedervorlage ans Ende von Prio 2
|
||||||
|
|
||||||
|
Innerhalb gleicher Priorität/Datum entscheidet der Kontaktname.
|
||||||
|
"""
|
||||||
|
wiedervorlage = eintrag["wiedervorlage_datum"]
|
||||||
|
hat_notizen = eintrag["letzte_notiz"] is not None
|
||||||
|
# wiedervorlage ist ein ISO-String (yyyy-MM-dd) und sortiert lexikalisch
|
||||||
|
# genauso wie das zugehörige date — daher String-Vergleich mit dem
|
||||||
|
# isoformat() des heutigen Tages.
|
||||||
|
heute_str = heute.isoformat()
|
||||||
|
if wiedervorlage is not None and wiedervorlage < heute_str:
|
||||||
|
prio = 0
|
||||||
|
elif not hat_notizen:
|
||||||
|
prio = 1
|
||||||
|
else:
|
||||||
|
prio = 2
|
||||||
|
# Ohne Wiedervorlage ans Ende der Prioritätsgruppe sortieren.
|
||||||
|
datum_key = wiedervorlage if wiedervorlage is not None else "9999-12-31"
|
||||||
|
return (prio, datum_key, eintrag["name"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_tages_workflow_list(user_id: int | None = None,
|
||||||
|
kunden_id: int | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
wiedervorlage: str | None = None) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Liefert die Tages-Workflow-Liste: pro Kontaktadresse ein Dictionary
|
||||||
|
mit den Kontakt- und Projektdaten, der letzten Gesprächsnotiz, dem
|
||||||
|
aktuell gültigen Wiedervorlage-Datum und dem Erledigt-Status.
|
||||||
|
|
||||||
|
Das aktuell gültige Wiedervorlage-Datum ist das wiedervorlage_datum
|
||||||
|
der jüngsten Gesprächsnotiz, die eines gesetzt hat (Reihenfolge nach
|
||||||
|
datum/uhrzeit_beginn absteigend). Spätere Notizen ohne Wiedervorlage
|
||||||
|
heben eine früher gesetzte nicht auf.
|
||||||
|
|
||||||
|
Ist user_id gesetzt, werden nur Kontaktadressen von Projekten geliefert,
|
||||||
|
denen der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-
|
||||||
|
Filterung). SysAdmin und AppAdmin übergeben user_id=None und sehen alle.
|
||||||
|
|
||||||
|
Filter (alle optional):
|
||||||
|
- kunden_id: nur Kontaktadressen von Projekten dieses Kunden
|
||||||
|
- status: 'offen' (nicht erledigte) oder 'erledigt'; alles andere
|
||||||
|
wirkt wie kein Filter
|
||||||
|
- wiedervorlage: 'heute', '3tage' (bis heute+3), 'ueberfaellig'
|
||||||
|
(< heute) oder 'woche' (ISO-Kalenderwoche des heutigen Tages)
|
||||||
|
"""
|
||||||
|
heute = date.today()
|
||||||
|
query = Kontaktadresse.query
|
||||||
|
if kunden_id is not None:
|
||||||
|
# Nur Kontaktadressen von Projekten des ausgewählten Kunden.
|
||||||
|
query = query.filter(
|
||||||
|
Kontaktadresse.projekt_id.in_(
|
||||||
|
dbapp.session.query(Projekt.id).filter(
|
||||||
|
Projekt.kunden_id == kunden_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if user_id is not None:
|
||||||
|
# Nur Kontaktadressen von Projekten, denen der Nutzer zugeordnet ist.
|
||||||
|
query = query.filter(
|
||||||
|
Kontaktadresse.projekt_id.in_(
|
||||||
|
dbapp.session.query(ProjektUser.projekt_id).filter(
|
||||||
|
ProjektUser.user_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
kontaktadressen = query.all()
|
||||||
|
|
||||||
|
eintraege = []
|
||||||
|
for kontaktadresse in kontaktadressen:
|
||||||
|
# Gesprächsnotizen des Kontakts, jüngste zuerst. uhrzeit_beginn
|
||||||
|
# ist ein 'HH:MM'-String und sortiert lexikalisch korrekt.
|
||||||
|
logs = sorted(
|
||||||
|
kontaktadresse.call_logs,
|
||||||
|
key=lambda log: (log.datum or date.min, log.uhrzeit_beginn or ""),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
letzte_notiz = logs[0] if logs else None
|
||||||
|
# Aktuell gültiges Wiedervorlage-Datum: jüngste Notiz, die
|
||||||
|
# eines gesetzt hat.
|
||||||
|
wiedervorlage_datum = next(
|
||||||
|
(log.wiedervorlage_datum for log in logs
|
||||||
|
if log.wiedervorlage_datum is not None),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
ist_erledigt = kontaktadresse.is_erledigt()
|
||||||
|
|
||||||
|
# Status-Filter: 'offen' bzw. 'erledigt'; alles andere wirkt
|
||||||
|
# wie kein Filter.
|
||||||
|
if status == "offen" and ist_erledigt:
|
||||||
|
continue
|
||||||
|
if status == "erledigt" and not ist_erledigt:
|
||||||
|
continue
|
||||||
|
# Wiedervorlage-Zeitraum-Filter.
|
||||||
|
if not _ist_im_wiedervorlage_zeitraum(wiedervorlage_datum,
|
||||||
|
wiedervorlage, heute):
|
||||||
|
continue
|
||||||
|
|
||||||
|
projekt = kontaktadresse.projekt
|
||||||
|
eintrag = {
|
||||||
|
'kontaktadresse_id': kontaktadresse.id,
|
||||||
|
'projekt_id': kontaktadresse.projekt_id,
|
||||||
|
'projekt_name': projekt.projektname if projekt else None,
|
||||||
|
'kunden_id': projekt.kunden_id if projekt else None,
|
||||||
|
'kunden_name': projekt.kunde.name if projekt and projekt.kunde else None,
|
||||||
|
'anrede': kontaktadresse.anrede,
|
||||||
|
'name': kontaktadresse.name,
|
||||||
|
'firma': kontaktadresse.firma,
|
||||||
|
'position': kontaktadresse.position,
|
||||||
|
'telefon': kontaktadresse.telefon,
|
||||||
|
'email': kontaktadresse.email,
|
||||||
|
'is_erledigt': ist_erledigt,
|
||||||
|
# Text der letzten Gesprächsnotiz (leerer String, falls der
|
||||||
|
# jüngste Log keinen Notiztext enthält) bzw. None, wenn der
|
||||||
|
# Kontakt noch keine Gesprächsnotiz hat.
|
||||||
|
'letzte_notiz': (letzte_notiz.notiz or "") if letzte_notiz else None,
|
||||||
|
'letzte_notiz_datum': letzte_notiz.datum.isoformat() if letzte_notiz and letzte_notiz.datum else None,
|
||||||
|
'letzte_notiz_uhrzeit': letzte_notiz.uhrzeit_beginn if letzte_notiz else None,
|
||||||
|
# Tags der letzten Gesprächsnotiz (für die Listen-Anzeige);
|
||||||
|
# leere Liste, wenn der Kontakt noch keine Notiz hat.
|
||||||
|
'letzte_notiz_tags': (
|
||||||
|
[clt.tag.to_dict() for clt in letzte_notiz.tags_rel]
|
||||||
|
if letzte_notiz else []
|
||||||
|
),
|
||||||
|
'wiedervorlage_datum': wiedervorlage_datum.isoformat() if wiedervorlage_datum else None,
|
||||||
|
}
|
||||||
|
eintraege.append(eintrag)
|
||||||
|
|
||||||
|
# Priorisierte Sortierung: überfällige Wiedervorlagen zuerst, dann
|
||||||
|
# Kontakte ohne Gesprächsnotizen, dann der Rest nach Wiedervorlage.
|
||||||
|
eintraege.sort(key=lambda eintrag: _sortierschlüssel(eintrag, heute))
|
||||||
|
return eintraege
|
||||||
@@ -27,6 +27,7 @@ from crm.model import projekt as projekte
|
|||||||
from crm.model import projekt_user as projekt_users
|
from crm.model import projekt_user as projekt_users
|
||||||
from crm.model import kontaktadresse as kontaktadressen
|
from crm.model import kontaktadresse as kontaktadressen
|
||||||
from crm.model import call_log as call_logs
|
from crm.model import call_log as call_logs
|
||||||
|
from crm.model import tages_workflow
|
||||||
from crm.model import tag as tags
|
from crm.model import tag as tags
|
||||||
|
|
||||||
print("crm-routes")
|
print("crm-routes")
|
||||||
@@ -332,11 +333,15 @@ def kontaktadresse_delete():
|
|||||||
@app.get("/xhr/calllog/list")
|
@app.get("/xhr/calllog/list")
|
||||||
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
||||||
def calllog_list():
|
def calllog_list():
|
||||||
"""Liefert Gesprächsnotizen als Liste. Optional nach projekt_id gefiltert.
|
"""Liefert Gesprächsnotizen als Liste. Optional nach projekt_id und/oder
|
||||||
Für Nicht-SysAdmin/Nicht-AppAdmin nur Notizen sichtbarer Projekte."""
|
kontaktadresse_id gefiltert. Für Nicht-SysAdmin/Nicht-AppAdmin nur
|
||||||
|
Notizen sichtbarer Projekte."""
|
||||||
projekt_id = request.args.get("projekt_id", type=int)
|
projekt_id = request.args.get("projekt_id", type=int)
|
||||||
|
kontaktadresse_id = request.args.get("kontaktadresse_id", type=int)
|
||||||
user_id = _get_visible_user_id()
|
user_id = _get_visible_user_id()
|
||||||
result = [l.to_dict() for l in call_logs.get_call_logs(projekt_id, user_id)]
|
result = [l.to_dict() for l in call_logs.get_call_logs(
|
||||||
|
projekt_id, user_id, kontaktadresse_id
|
||||||
|
)]
|
||||||
return jsonify(result), 200
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -354,9 +359,10 @@ def calllog_get():
|
|||||||
@app.post("/xhr/calllog/create")
|
@app.post("/xhr/calllog/create")
|
||||||
@authorisation_required([KUNDEN_MANAGER])
|
@authorisation_required([KUNDEN_MANAGER])
|
||||||
def calllog_create():
|
def calllog_create():
|
||||||
"""Legt eine neue Gesprächsnotiz an. ID wird serverseitig vergeben."""
|
"""Legt eine neue Gesprächsnotiz an. ID wird serverseitig vergeben.
|
||||||
|
current_user.id wird als Ersteller (user_id) erfasst."""
|
||||||
_require_projekt_access(int(request.json["projekt_id"]))
|
_require_projekt_access(int(request.json["projekt_id"]))
|
||||||
call_log = call_logs.create_call_log(request.json)
|
call_log = call_logs.create_call_log(request.json, current_user.id)
|
||||||
return jsonify(call_log.to_dict()), 200
|
return jsonify(call_log.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -381,6 +387,30 @@ def calllog_delete():
|
|||||||
return jsonify({}), 200
|
return jsonify({}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Tages-Workflow #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/xhr/workflow/list")
|
||||||
|
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
||||||
|
def workflow_list():
|
||||||
|
"""Tages-Workflow-Liste: Kontaktadressen mit letzter Notiz, Wiedervorlage
|
||||||
|
und dynamisch berechnetem Status. Optional gefiltert nach kunden_id,
|
||||||
|
status ('offen'/'erledigt') und wiedervorlage ('heute'/'3tage'/
|
||||||
|
'ueberfaellig'/'woche'). Für Nicht-SysAdmin/Nicht-AppAdmin nur
|
||||||
|
Kontaktadressen sichtbarer Projekte. Die Sortierung ist priorisiert:
|
||||||
|
überfällige Wiedervorlagen, dann Kontakte ohne Notizen, dann der Rest."""
|
||||||
|
user_id = _get_visible_user_id()
|
||||||
|
result = tages_workflow.get_tages_workflow_list(
|
||||||
|
user_id,
|
||||||
|
request.args.get("kunden_id", type=int),
|
||||||
|
request.args.get("status"),
|
||||||
|
request.args.get("wiedervorlage"),
|
||||||
|
)
|
||||||
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Tag (hierarchische Taxonomie) #
|
# Tag (hierarchische Taxonomie) #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCall
|
|||||||
import {iKunde, fetchKunden} from "../models/kunde"
|
import {iKunde, fetchKunden} from "../models/kunde"
|
||||||
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
|
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
|
||||||
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
|
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 {showServerError} from "./auth/authmessages"
|
||||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
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')
|
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 {
|
return {
|
||||||
oninit: (vnode) => {
|
oninit: (vnode) => {
|
||||||
const routeParams = m.route.param()
|
const routeParams = m.route.param()
|
||||||
@@ -397,6 +376,11 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
kontaktadresse_id: ansprechpartnerVoreingestellt
|
kontaktadresse_id: ansprechpartnerVoreingestellt
|
||||||
? currentLog.kontaktadresse_id
|
? currentLog.kontaktadresse_id
|
||||||
: parseInt((formData.get("kontaktadresse_id") as string) || "-1"),
|
: 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: "",
|
projekt_name: "",
|
||||||
kunden_name: "",
|
kunden_name: "",
|
||||||
datum: currentLog.datum || "",
|
datum: currentLog.datum || "",
|
||||||
@@ -537,7 +521,11 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
onchanged: (newSelection: number[]) => {
|
onchanged: (newSelection: number[]) => {
|
||||||
selectedTagIds.splice(0, selectedTagIds.length)
|
selectedTagIds.splice(0, selectedTagIds.length)
|
||||||
Array.prototype.push.apply(selectedTagIds, newSelection)
|
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) {
|
if (vorschlag) {
|
||||||
currentLog.wiedervorlage_datum = vorschlag
|
currentLog.wiedervorlage_datum = vorschlag
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,9 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
|||||||
id: currentKontaktadresse.id,
|
id: currentKontaktadresse.id,
|
||||||
projekt_id: currentKontaktadresse.projekt_id,
|
projekt_id: currentKontaktadresse.projekt_id,
|
||||||
projekt_name: "",
|
projekt_name: "",
|
||||||
|
// Server-seitig aus den Relationships abgeleitet;
|
||||||
|
// Platzhalter beim Senden.
|
||||||
|
kunden_name: "",
|
||||||
anrede: (formData.get("anrede") as string) || "",
|
anrede: (formData.get("anrede") as string) || "",
|
||||||
name: (formData.get("name") as string) || "",
|
name: (formData.get("name") as string) || "",
|
||||||
firma: (formData.get("firma") 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")
|
||||||
|
)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -11,6 +11,7 @@ import { Call_Log, call_log_liste } from "./Components/call_log"
|
|||||||
import { Kontakt, kontaktliste } from "./Components/kontakt"
|
import { Kontakt, kontaktliste } from "./Components/kontakt"
|
||||||
import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse"
|
import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse"
|
||||||
import { Tag, TagList } from "./Components/tags"
|
import { Tag, TagList } from "./Components/tags"
|
||||||
|
import { TagesWorkflowListe, TagesWorkflowMaske } from "./Components/tages_workflow"
|
||||||
import { guardedNavigate } from "./Components/widgets/dirtyguard"
|
import { guardedNavigate } from "./Components/widgets/dirtyguard"
|
||||||
|
|
||||||
|
|
||||||
@@ -133,6 +134,7 @@ const menu: m.ClosureComponent = () => {
|
|||||||
},
|
},
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
return m(menubar, [
|
return m(menubar, [
|
||||||
|
menuItem("/tagesworkflow", "Tages-Workflow"),
|
||||||
menuItem("/kundenliste", "Kunden"),
|
menuItem("/kundenliste", "Kunden"),
|
||||||
menuItem("/projektliste", "Projekte"),
|
menuItem("/projektliste", "Projekte"),
|
||||||
menuItem("/calllogliste", "Gesprächsnotizen"),
|
menuItem("/calllogliste", "Gesprächsnotizen"),
|
||||||
@@ -214,6 +216,8 @@ m.route(gridMountpoints.main, "/kundenliste", {
|
|||||||
"/kontaktadresseliste": kontaktadresseliste,
|
"/kontaktadresseliste": kontaktadresseliste,
|
||||||
"/kontaktadresse": Kontaktadresse,
|
"/kontaktadresse": Kontaktadresse,
|
||||||
"/taglist": TagList,
|
"/taglist": TagList,
|
||||||
"/tag": Tag
|
"/tag": Tag,
|
||||||
|
"/tagesworkflow": TagesWorkflowListe,
|
||||||
|
"/tagesworkflowkontakt": TagesWorkflowMaske
|
||||||
|
|
||||||
})
|
})
|
||||||
@@ -8,6 +8,11 @@ import {iTag} from "./tag"
|
|||||||
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
|
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
|
||||||
* kontaktadresse_id ist Pflichtfeld: Jede Notiz gehört zu genau einer
|
* kontaktadresse_id ist Pflichtfeld: Jede Notiz gehört zu genau einer
|
||||||
* Kontaktadresse (Fremdschlüssel auf kontaktadressen.id).
|
* Kontaktadresse (Fremdschlüssel auf kontaktadressen.id).
|
||||||
|
* user_id erfasst den Ersteller (Mitarbeiter, der den Anruf getätigt hat);
|
||||||
|
* sie wird serverseitig beim Anlegen gesetzt und nie vom Client gesendet.
|
||||||
|
* 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).
|
||||||
* tags (vom Server beim Lesen) und tag_ids (vom Client beim Schreiben)
|
* tags (vom Server beim Lesen) und tag_ids (vom Client beim Schreiben)
|
||||||
* steuern die n:m-Beziehung zu den Tags.
|
* steuern die n:m-Beziehung zu den Tags.
|
||||||
*/
|
*/
|
||||||
@@ -15,8 +20,12 @@ export interface iCallLog {
|
|||||||
id: number
|
id: number
|
||||||
projekt_id: number
|
projekt_id: number
|
||||||
kontaktadresse_id: number
|
kontaktadresse_id: number
|
||||||
|
/** Ersteller (Mitarbeiter), serverseitig gesetzt; null bei Alt-Notizen. */
|
||||||
|
user_id: number | null
|
||||||
projekt_name: string
|
projekt_name: string
|
||||||
kunden_name: string
|
kunden_name: string
|
||||||
|
/** Aufgelöster Username des Erstellers; null bei Alt-Notizen. */
|
||||||
|
mitarbeiter_name: string | null
|
||||||
datum: string
|
datum: string
|
||||||
uhrzeit_beginn: string
|
uhrzeit_beginn: string
|
||||||
dauer: string
|
dauer: string
|
||||||
@@ -73,6 +82,19 @@ export function fetchCallLogById(id: number): Promise<iCallLog> {
|
|||||||
}).then((result) => result as iCallLog)
|
}).then((result) => result as iCallLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Gesprächsnotizen eines einzelnen Kontakts (Kontaktadresse)
|
||||||
|
* vom Server — serverseitig nach kontaktadresse_id gefiltert. Wird für
|
||||||
|
* die Historie in der Tages-Workflow-Maske genutzt.
|
||||||
|
*/
|
||||||
|
export function fetchCallLogsByKontaktadresse(kontaktadresseId: number): Promise<iCallLog[]> {
|
||||||
|
return m.request({
|
||||||
|
method: "GET",
|
||||||
|
url: CALLLOG_REST_BASE + "/list",
|
||||||
|
params: {kontaktadresse_id: kontaktadresseId}
|
||||||
|
}).then((result) => result as iCallLog[])
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legt eine neue Gesprächsnotiz serverseitig an.
|
* Legt eine neue Gesprächsnotiz serverseitig an.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ import m from "mithril"
|
|||||||
* Datenmodell für eine Kontaktadresse (Akquise-Ziel innerhalb eines Projekts).
|
* Datenmodell für eine Kontaktadresse (Akquise-Ziel innerhalb eines Projekts).
|
||||||
* Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projekt_id).
|
* Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projekt_id).
|
||||||
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
||||||
* projekt_name ist keine ORM-Spalte, sondern wird serverseitig aus der
|
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
|
||||||
* Projekt-Relationship abgeleitet und im JSON ergänzt. is_erledigt ist
|
* serverseitig aus den Projekt-/Kunde-Relationships abgeleitet und im
|
||||||
* ebenfalls keine Spalte, sondern wird serverseitig dynamisch berechnet:
|
* JSON ergänzt. is_erledigt ist ebenfalls keine Spalte, sondern wird
|
||||||
* True, sobald mindestens eine Gesprächsnotiz des Kontakts einen Tag mit
|
* serverseitig dynamisch berechnet: True, sobald mindestens eine
|
||||||
* is_erledigend=True hat.
|
* Gesprächsnotiz des Kontakts einen Tag mit is_erledigend=True hat.
|
||||||
*/
|
*/
|
||||||
export interface iKontaktadresse {
|
export interface iKontaktadresse {
|
||||||
id: number
|
id: number
|
||||||
projekt_id: number
|
projekt_id: number
|
||||||
projekt_name: string
|
projekt_name: string
|
||||||
|
kunden_name: string
|
||||||
anrede: string
|
anrede: string
|
||||||
name: string
|
name: string
|
||||||
firma: string
|
firma: string
|
||||||
|
|||||||
@@ -112,3 +112,32 @@ export function deleteTagREST(id: number): Promise<void> {
|
|||||||
params: {id: id}
|
params: {id: id}
|
||||||
}).then(() => undefined)
|
}).then(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Fachliche Hilfsfunktionen */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet das Wiedervorlage-Datum automatisch aus den ausgewählten
|
||||||
|
* Tags. Basis ist das übergebene Basisdatum (z.B. das Datum der Notiz
|
||||||
|
* oder heute). 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.
|
||||||
|
*
|
||||||
|
* Wird von der Call_Log-Maske und der Tages-Workflow-Maske genutzt,
|
||||||
|
* damit die Berechnungslogik nur an einer Stelle gepflegt wird.
|
||||||
|
*/
|
||||||
|
export function berechneWiedervorlageDatum(tagIds: number[],
|
||||||
|
tagListe: iTag[],
|
||||||
|
basisdatum: string): string {
|
||||||
|
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(basisdatum)
|
||||||
|
basisDate.setDate(basisDate.getDate() + minAbstand)
|
||||||
|
return basisDate.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import m from "mithril"
|
||||||
|
import {iTag} from "./tag"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datenmodell für einen Eintrag der Tages-Workflow-Liste. Der Server
|
||||||
|
* aggregiert pro Kontaktadresse: die letzte Gesprächsnotiz, das aktuell
|
||||||
|
* gültige Wiedervorlage-Datum (jüngste Notiz, die eines gesetzt hat)
|
||||||
|
* und den dynamisch berechneten Erledigt-Status. Die Sortierung erfolgt
|
||||||
|
* serverseitig priorisiert (überfällige Wiedervorlagen, dann Kontakte
|
||||||
|
* ohne Notizen, dann der Rest nach Wiedervorlage).
|
||||||
|
*/
|
||||||
|
export interface iTagesWorkflowEntry {
|
||||||
|
kontaktadresse_id: number
|
||||||
|
projekt_id: number
|
||||||
|
projekt_name: string
|
||||||
|
kunden_id: number
|
||||||
|
kunden_name: string
|
||||||
|
anrede: string
|
||||||
|
name: string
|
||||||
|
firma: string
|
||||||
|
position: string
|
||||||
|
telefon: string
|
||||||
|
email: string
|
||||||
|
/** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */
|
||||||
|
is_erledigt: boolean
|
||||||
|
/** Text der letzten Gesprächsnotiz; null, wenn der Kontakt keine Notiz hat. */
|
||||||
|
letzte_notiz: string | null
|
||||||
|
/** Datum der letzten Gesprächsnotiz (ISO yyyy-MM-dd); null ohne Notizen. */
|
||||||
|
letzte_notiz_datum: string | null
|
||||||
|
/** Uhrzeit Beginn der letzten Gesprächsnotiz; null ohne Notizen. */
|
||||||
|
letzte_notiz_uhrzeit: string | null
|
||||||
|
/** Tags der letzten Gesprächsnotiz; leere Liste ohne Notizen. */
|
||||||
|
letzte_notiz_tags: iTag[]
|
||||||
|
/** Aktuell gültiges Wiedervorlage-Datum (ISO yyyy-MM-dd) oder null. */
|
||||||
|
wiedervorlage_datum: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter-Einstellungen der Tages-Workflow-Liste. Alle Felder optional;
|
||||||
|
* nicht gesetzte Felder wirken wie kein Filter. Die Filter werden
|
||||||
|
* serverseitig angewendet.
|
||||||
|
*/
|
||||||
|
export interface iTagesWorkflowFilter {
|
||||||
|
/** Nur Kontaktadressen von Projekten dieses Kunden. */
|
||||||
|
kunden_id?: number
|
||||||
|
/** 'offen' = nur nicht erledigte, 'erledigt' = nur erledigte Kontakte. */
|
||||||
|
status?: string
|
||||||
|
/** 'heute', '3tage', 'ueberfaellig' oder 'woche' (ISO-Kalenderwoche). */
|
||||||
|
wiedervorlage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* REST-API-Aufrufe (Client-Seite) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basis-Pfad der Tages-Workflow-REST-Endpunkte.
|
||||||
|
*/
|
||||||
|
const WORKFLOW_REST_BASE = "/xhr/workflow"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Tages-Workflow-Liste vom Server: pro Kontaktadresse ein
|
||||||
|
* aggregierter Eintrag (letzte Notiz, Wiedervorlage, Status), gefiltert
|
||||||
|
* und priorisiert sortiert.
|
||||||
|
*/
|
||||||
|
export function fetchTagesWorkflowList(filter?: iTagesWorkflowFilter): Promise<iTagesWorkflowEntry[]> {
|
||||||
|
const params: Record<string, any> = {}
|
||||||
|
if (filter) {
|
||||||
|
if (filter.kunden_id !== undefined && filter.kunden_id >= 0) {
|
||||||
|
params.kunden_id = filter.kunden_id
|
||||||
|
}
|
||||||
|
if (filter.status) {
|
||||||
|
params.status = filter.status
|
||||||
|
}
|
||||||
|
if (filter.wiedervorlage) {
|
||||||
|
params.wiedervorlage = filter.wiedervorlage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.request({
|
||||||
|
method: "GET",
|
||||||
|
url: WORKFLOW_REST_BASE + "/list",
|
||||||
|
params: params
|
||||||
|
}).then((result) => result as iTagesWorkflowEntry[])
|
||||||
|
}
|
||||||
Reference in new issue
Block a user