Compare commits
14
Commits
1d8201f46a
...
a23884bf02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a23884bf02 | ||
|
|
6e3cc7375d | ||
|
|
929664453e | ||
|
|
32afb35840 | ||
|
|
4d8febcc3f | ||
|
|
3ba9b0a07f | ||
|
|
c96ed6e97d | ||
|
|
981b28df03 | ||
|
|
3a6732e36b | ||
|
|
977b64292c | ||
|
|
24b492c5c1 | ||
|
|
4e721e9a47 | ||
|
|
5a39c64880 | ||
|
|
6f86bc9808 |
No files matched your search
@@ -9,7 +9,7 @@ import os
|
||||
login_manager = LoginManager()
|
||||
|
||||
|
||||
def init_app():
|
||||
def init_app(debug: bool | None = None):
|
||||
app = Flask(__name__)
|
||||
|
||||
config: Configuration = Configuration("/usr/opt/pythonapp/App_Server/root/config.ini")
|
||||
@@ -20,7 +20,13 @@ def init_app():
|
||||
"auth": "sqlite:////usr/var/pythonappdb/auth.db",
|
||||
"appdb": "sqlite:////usr/var/pythonappdb/{}.db".format(_projektname)
|
||||
}
|
||||
app.config["DEBUG"] = os.environ.get("APP_DEBUG", "false").lower() in ("true", "1", "yes")
|
||||
# Dev-Modus: CLI-Parameter -D (via runserver) oder APP_DEBUG-Env.
|
||||
# debug=None = wie bisher allein über die Env-Variable entscheiden
|
||||
# (unverändertes Verhalten für wsgi.py).
|
||||
if debug is None:
|
||||
app.config["DEBUG"] = os.environ.get("APP_DEBUG", "false").lower() in ("true", "1", "yes")
|
||||
else:
|
||||
app.config["DEBUG"] = debug
|
||||
app.config["PROJEKTNAME"] = config.app_projectname
|
||||
app.config["APPID"] = config.app_appid
|
||||
app.config["APPVERSION"] = config.clientapp_version
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -148,15 +148,33 @@ class Projekt(dbapp.Model):
|
||||
startdatum = Column(Date, nullable=True)
|
||||
|
||||
kunde = relationship('Kunde')
|
||||
# Kontaktadressen dieses Projekts (Akquise-Ziele). Die Rückrichtung
|
||||
# ist Kontaktadresse.projekt (back_populates).
|
||||
kontaktadressen = relationship('Kontaktadresse', back_populates='projekt')
|
||||
|
||||
def is_erledigt(self):
|
||||
"""
|
||||
Prüft dynamisch, ob das Projekt als erledigt gilt.
|
||||
|
||||
True, wenn das Projekt Kontaktadressen hat und alle davon erledigt
|
||||
sind. Ein Projekt ohne Kontaktadressen ist offen (False), da
|
||||
Kontaktadressen erst nach dem Anlegen des Projekts erfasst werden
|
||||
können — ein neues Projekt ist somit zunächst offen.
|
||||
"""
|
||||
if not self.kontaktadressen:
|
||||
return False
|
||||
return all(k.is_erledigt() for k in self.kontaktadressen)
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
Liefert die Projektdaten als Dictionary. Die Tabellenspalten werden
|
||||
über columns2dict serialisiert; kunden_name wird zusätzlich aus der
|
||||
Kunde-Relationship eingefügt.
|
||||
Kunde-Relationship eingefügt. is_erledigt wird dynamisch aus den
|
||||
zugeordneten Kontaktadressen berechnet.
|
||||
"""
|
||||
result = columns2dict(self)
|
||||
result['kunden_name'] = self.kunde.name if self.kunde else None
|
||||
result['is_erledigt'] = self.is_erledigt()
|
||||
return result
|
||||
|
||||
|
||||
@@ -220,34 +238,75 @@ class Kontaktadresse(dbapp.Model):
|
||||
land = Column(String(80), nullable=True)
|
||||
notiz = Column(String(2000), nullable=True)
|
||||
|
||||
projekt = relationship('Projekt')
|
||||
projekt = relationship('Projekt', back_populates='kontaktadressen')
|
||||
# Gesprächsnotizen, die zu diesem Kontakt erfasst wurden. Die
|
||||
# Rückrichtung ist CallLog.kontaktadresse (back_populates); die
|
||||
# Join-Bedingung ergibt sich aus dem Fremdschlüssel
|
||||
# call_logs.kontaktadresse_id -> kontaktadressen.id.
|
||||
call_logs = relationship('CallLog', back_populates='kontaktadresse')
|
||||
|
||||
def is_erledigt(self):
|
||||
"""
|
||||
Prüft dynamisch, ob der Kontakt als erledigt gilt.
|
||||
|
||||
True, sobald mindestens eine Gesprächsnotiz des Kontakts einen Tag
|
||||
mit is_erledigend=True hat. Spätere Notizen ohne erledigend-Tag
|
||||
setzen den Status nicht zurück. False bei keinen Notizen oder wenn
|
||||
keine Notiz einen erledigend-Tag hat.
|
||||
"""
|
||||
return any(
|
||||
clt.tag.is_erledigend
|
||||
for notiz in self.call_logs
|
||||
for clt in notiz.tags_rel
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden
|
||||
über columns2dict serialisiert; projekt_name wird zusätzlich aus der
|
||||
Projekt-Relationship eingefügt.
|
||||
über columns2dict serialisiert; projekt_name, projekt_zweck und
|
||||
kunden_name werden zusätzlich aus der Projekt- bzw. Kunde-Relationship
|
||||
eingefügt. is_erledigt wird dynamisch aus den Gesprächsnotizen des
|
||||
Kontakts berechnet.
|
||||
"""
|
||||
result = columns2dict(self)
|
||||
result['projekt_name'] = self.projekt.projektname if self.projekt else None
|
||||
result['projekt_zweck'] = self.projekt.zweck 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()
|
||||
return result
|
||||
|
||||
|
||||
class CallLog(dbapp.Model):
|
||||
"""
|
||||
Gesprächsnotiz für ein Projekt. Jede Gesprächsnotiz ist genau einem
|
||||
Projekt zugeordnet (projekt_id). Der Projektname (projekt_name) und
|
||||
der Kundenname (kunden_name) sind keine eigenen Spalten, sondern
|
||||
werden über die Projekt-Relationship abgeleitet und nur in to_dict()
|
||||
für die JSON-Serialisierung ergänzt.
|
||||
Gesprächsnotiz für einen Kontakt. Jede Gesprächsnotiz ist genau einem
|
||||
Projekt (projekt_id) und genau einer Kontaktadresse (kontaktadresse_id)
|
||||
zugeordnet. Der Projektname (projekt_name) und der Kundenname
|
||||
(kunden_name) sind keine eigenen Spalten, sondern werden über die
|
||||
Projekt-Relationship abgeleitet und nur in to_dict() für die
|
||||
JSON-Serialisierung ergänzt.
|
||||
"""
|
||||
__tablename__ = 'call_logs'
|
||||
__bind_key__ = BINDKEY
|
||||
__table_args__ = (
|
||||
Index('ix_call_logs_projekt_id', 'projekt_id'),
|
||||
Index('ix_call_logs_kontaktadresse_id', 'kontaktadresse_id'),
|
||||
)
|
||||
id = Column(Integer, primary_key=True)
|
||||
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False)
|
||||
# Jede Gesprächsnotiz gehört zu genau einer Kontaktadresse
|
||||
# (Pflichtfeld). nullable=False im Modell; in bestehenden Datenbanken
|
||||
# ist die physische Spalte nullable (SQLite kann kein nachträgliches
|
||||
# NOT NULL) — die Pflicht wird über die Server-Validierung in
|
||||
# crm.model.call_log durchgesetzt.
|
||||
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)
|
||||
uhrzeit_beginn = Column(String(10), nullable=True)
|
||||
dauer = Column(String(10), nullable=True)
|
||||
@@ -256,9 +315,9 @@ 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')
|
||||
# n:m-Beziehung zu Tags über die Verknüpfungstabelle call_logs_tags.
|
||||
# cascade='all, delete-orphan' stellt sicher, dass die Zuordnungen
|
||||
# automatisch gelöscht werden, wenn die Gesprächsnotiz gelöscht wird.
|
||||
@@ -270,8 +329,10 @@ class CallLog(dbapp.Model):
|
||||
"""
|
||||
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
|
||||
über columns2dict serialisiert; projekt_name und kunden_name werden
|
||||
zusätzlich aus der Projekt-Relationship eingefügt. Die zugeordneten
|
||||
Tags werden als Liste von Tag-Dictionaries unter 'tags' ergänzt.
|
||||
zusätzlich aus der Projekt-Relationship eingefügt. mitarbeiter_name
|
||||
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)
|
||||
if self.projekt:
|
||||
@@ -280,6 +341,14 @@ class CallLog(dbapp.Model):
|
||||
else:
|
||||
result['projekt_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).
|
||||
result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel]
|
||||
return result
|
||||
@@ -325,6 +394,15 @@ class Tag(dbapp.Model):
|
||||
color = Column(String(20), nullable=True)
|
||||
parent_id = Column(Integer, ForeignKey('tags.id'), nullable=True)
|
||||
is_parent = Column(Boolean, nullable=False, default=False)
|
||||
# Felder für die Telefon-Akquise-Workflow-Logik:
|
||||
# is_erledigend: True = Kontakt gilt als erledigt, wenn dieser Tag
|
||||
# zugewiesen wird.
|
||||
# loest_wiedervorlage_aus: True = Tag löst eine Wiedervorlage aus.
|
||||
# wiedervorlage_abstand_tage: Tage bis zur Wiedervorlage (nur relevant,
|
||||
# wenn loest_wiedervorlage_aus True ist).
|
||||
is_erledigend = Column(Boolean, nullable=False, default=False)
|
||||
loest_wiedervorlage_aus = Column(Boolean, nullable=False, default=False)
|
||||
wiedervorlage_abstand_tage = Column(Integer, nullable=True)
|
||||
|
||||
# Selbst-Referenzierende Relationship: parent verweist auf den
|
||||
# uebergeordneten Tag, children auf alle direkten Kinder. Die
|
||||
|
||||
@@ -12,7 +12,7 @@ dort über den Decorator authorisation_required.
|
||||
from datetime import date
|
||||
|
||||
from db import dbapp
|
||||
from crm.model import CallLog, CallLogTag, Projekt, ProjektUser, Tag
|
||||
from crm.model import CallLog, CallLogTag, Kontaktadresse, Projekt, ProjektUser, Tag
|
||||
|
||||
from auth.model.errors import ApplicationError, HtmlStatusCode
|
||||
|
||||
@@ -71,13 +71,40 @@ def _set_call_log_tags(call_log: CallLog, tag_ids: list) -> None:
|
||||
|
||||
|
||||
|
||||
def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
|
||||
def _get_validierte_kontaktadresse_id(data: dict, projekt_id) -> int:
|
||||
"""
|
||||
Liest kontaktadresse_id aus dem data-Dict und validiert sie:
|
||||
- Pflichtfeld: fehlt sie oder ist sie leer, wird Bad Request geliefert.
|
||||
- Die Kontaktadresse muss existieren und zum selben Projekt gehören.
|
||||
|
||||
Liefert die geprüfte ID als int zurück.
|
||||
"""
|
||||
kontaktadresse_id = data.get("kontaktadresse_id")
|
||||
if not kontaktadresse_id:
|
||||
raise ApplicationError(
|
||||
"Es muss eine Kontaktadresse ausgewählt werden.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
kontaktadresse = Kontaktadresse.query.get(int(kontaktadresse_id))
|
||||
if kontaktadresse is None or kontaktadresse.projekt_id != int(projekt_id):
|
||||
raise ApplicationError(
|
||||
"Die ausgewählte Kontaktadresse existiert nicht oder gehört nicht zum Projekt.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
return kontaktadresse.id
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Ist projekt_id gesetzt, werden nur die Notizen dieses Projekts
|
||||
zurückgegeben (serverseitige Filterung). Ohne projekt_id werden
|
||||
alle Notizen geliefert.
|
||||
zurückgegeben (serverseitige Filterung). Ist kontaktadresse_id
|
||||
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
|
||||
der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-Filterung).
|
||||
@@ -86,6 +113,8 @@ def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
|
||||
query = CallLog.query
|
||||
if projekt_id is not None:
|
||||
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:
|
||||
# Nur Notizen von Projekten, denen der Nutzer zugeordnet ist.
|
||||
query = query.filter(
|
||||
@@ -125,14 +154,19 @@ def get_call_log(call_log_id: int, user_id: int | None = None) -> CallLog:
|
||||
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
|
||||
ignoriert; die ID wird serverseitig per Auto-Increment vergeben.
|
||||
|
||||
- data: dict mit den Feldern projekt_id, datum, uhrzeit_beginn, dauer,
|
||||
betreff, name_ansprechpartner, telefon, notiz,
|
||||
wiedervorlage_datum, erledigt
|
||||
- data: dict mit den Feldern projekt_id, kontaktadresse_id, datum,
|
||||
uhrzeit_beginn, dauer, betreff, name_ansprechpartner, telefon,
|
||||
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.
|
||||
Der Ersteller wird beim Anlegen gesetzt und beim späteren
|
||||
Update nicht mehr verändert.
|
||||
"""
|
||||
projekt_id = data.get("projekt_id")
|
||||
if projekt_id is None:
|
||||
@@ -146,8 +180,16 @@ def create_call_log(data: dict) -> CallLog:
|
||||
"Das ausgewählte Projekt existiert nicht.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
kontaktadresse_id = _get_validierte_kontaktadresse_id(data, projekt_id)
|
||||
call_log = CallLog()
|
||||
call_log.projekt_id = int(projekt_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.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
||||
call_log.dauer = (data.get("dauer") or "").strip() or None
|
||||
@@ -156,7 +198,6 @@ def create_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.add(call_log)
|
||||
session.commit()
|
||||
@@ -181,7 +222,9 @@ def update_call_log(data: dict) -> CallLog:
|
||||
"Das ausgewählte Projekt existiert nicht.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
kontaktadresse_id = _get_validierte_kontaktadresse_id(data, projekt_id)
|
||||
call_log.projekt_id = int(projekt_id)
|
||||
call_log.kontaktadresse_id = kontaktadresse_id
|
||||
call_log.datum = _parse_date(data.get("datum"))
|
||||
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
||||
call_log.dauer = (data.get("dauer") or "").strip() or None
|
||||
@@ -190,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
|
||||
|
||||
@@ -243,6 +243,163 @@ def _db_V01_11():
|
||||
session.commit()
|
||||
|
||||
|
||||
def _db_V01_12():
|
||||
"""
|
||||
Fügt der Tabelle 'tags' die drei neuen Spalten für die
|
||||
Telefon-Akquise-Workflow-Logik hinzu:
|
||||
- is_erledigend (BOOLEAN NOT NULL DEFAULT 0)
|
||||
- loest_wiedervorlage_aus (BOOLEAN NOT NULL DEFAULT 0)
|
||||
- wiedervorlage_abstand_tage (INTEGER)
|
||||
|
||||
Idempotent: Bei Neuinstallation hat create_all die Spalten bereits
|
||||
angelegt — in diesem Fall wird das jeweilige ALTER TABLE übersprungen.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
result = session.execute(text("PRAGMA table_info(tags)"))
|
||||
spalten = {row[1] for row in result}
|
||||
|
||||
if 'is_erledigend' not in spalten:
|
||||
session.execute(text(
|
||||
"ALTER TABLE tags ADD COLUMN is_erledigend BOOLEAN NOT NULL DEFAULT 0"
|
||||
))
|
||||
if 'loest_wiedervorlage_aus' not in spalten:
|
||||
session.execute(text(
|
||||
"ALTER TABLE tags ADD COLUMN loest_wiedervorlage_aus BOOLEAN NOT NULL DEFAULT 0"
|
||||
))
|
||||
if 'wiedervorlage_abstand_tage' not in spalten:
|
||||
session.execute(text(
|
||||
"ALTER TABLE tags ADD COLUMN wiedervorlage_abstand_tage INTEGER"
|
||||
))
|
||||
_updatesysteminfo('01.12')
|
||||
session.commit()
|
||||
|
||||
|
||||
def _db_V01_13():
|
||||
"""
|
||||
Fügt der Tabelle 'call_logs' die Spalte 'kontaktadresse_id' hinzu:
|
||||
Jede Gesprächsnotiz gehört zu genau einer Kontaktadresse
|
||||
(Fremdschlüssel -> kontaktadressen.id, Pflichtfeld).
|
||||
|
||||
Da SQLite kein nachträgliches NOT NULL auf einer bestehenden Spalte
|
||||
setzen kann, wird die Spalte als nullable angelegt und anschließend
|
||||
backgefüllt: Für bestehende CallLogs ohne Kontakt-Bezug wird die
|
||||
erste Kontaktadresse des gleichen Projekts eingetragen. Das NOT NULL
|
||||
ist im Modell deklariert und wird über die Server-Validierung in
|
||||
crm.model.call_log durchgesetzt.
|
||||
|
||||
Idempotent: Bei Neuinstallation hat create_all die Spalte bereits
|
||||
angelegt — in diesem Fall wird das ALTER TABLE übersprungen. Der
|
||||
Backfill und der Index sind ebenfalls idempotent.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
result = session.execute(text("PRAGMA table_info(call_logs)"))
|
||||
spalten = {row[1] for row in result}
|
||||
if 'kontaktadresse_id' not in spalten:
|
||||
session.execute(text(
|
||||
"ALTER TABLE call_logs ADD COLUMN kontaktadresse_id INTEGER"
|
||||
))
|
||||
# Backfill: erste Kontaktadresse des Projekts eintragen, falls
|
||||
# noch keine kontaktadresse_id gesetzt ist.
|
||||
session.execute(text(
|
||||
"UPDATE call_logs SET kontaktadresse_id = ("
|
||||
" SELECT ka.id FROM kontaktadressen ka"
|
||||
" WHERE ka.projekt_id = call_logs.projekt_id"
|
||||
" LIMIT 1"
|
||||
") WHERE kontaktadresse_id IS NULL"
|
||||
))
|
||||
session.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_call_logs_kontaktadresse_id"
|
||||
" ON call_logs (kontaktadresse_id)"
|
||||
))
|
||||
# Roh-SQL-Änderungen committen, BEVOR die Systeminfo aktualisiert wird
|
||||
# (dasselbe Muster wie _db_V01_08): Das Backfill-UPDATE öffnet eine
|
||||
# Schreib-Transaktion auf der 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.13')
|
||||
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 _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
|
||||
|
||||
@@ -20,6 +20,48 @@ from auth.model.errors import ApplicationError, HtmlStatusCode
|
||||
session = dbapp.session
|
||||
|
||||
|
||||
def _apply_workflow_felder(tag: Tag, data: dict) -> None:
|
||||
"""
|
||||
Übernimmt die Wiedervorlage- und Erledigt-Felder aus dem data-Dict in
|
||||
den Tag. Für Parent-Tags (is_parent=True) werden die Felder auf False
|
||||
bzw. None gesetzt, da sie dort keine Wirkung haben (Parent-Tags sind
|
||||
nicht zuordenbar).
|
||||
|
||||
Validierung: Ist loest_wiedervorlage_aus True, muss wiedervorlage_abstand_tage
|
||||
einen gültigen positiven Integer-Wert haben; sonst wird ein
|
||||
ApplicationError (Bad_Request) geworfen.
|
||||
"""
|
||||
is_parent = bool(data.get("is_parent", tag.is_parent))
|
||||
if is_parent:
|
||||
# Parent-Tags: Workflow-Felder sind nicht relevant.
|
||||
tag.is_erledigend = False
|
||||
tag.loest_wiedervorlage_aus = False
|
||||
tag.wiedervorlage_abstand_tage = None
|
||||
return
|
||||
|
||||
tag.is_erledigend = bool(data.get("is_erledigend", False))
|
||||
loest = bool(data.get("loest_wiedervorlage_aus", False))
|
||||
tag.loest_wiedervorlage_aus = loest
|
||||
|
||||
abstand_raw = data.get("wiedervorlage_abstand_tage")
|
||||
abstand = None
|
||||
if abstand_raw is not None and str(abstand_raw).strip() != "":
|
||||
try:
|
||||
abstand = int(abstand_raw)
|
||||
except (TypeError, ValueError):
|
||||
abstand = None
|
||||
if loest:
|
||||
if abstand is None or abstand < 0:
|
||||
raise ApplicationError(
|
||||
"Wiedervorlage-Abstand in Tagen ist erforderlich, wenn "
|
||||
"Wiedervorlage ausgelöst wird.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
tag.wiedervorlage_abstand_tage = abstand
|
||||
else:
|
||||
tag.wiedervorlage_abstand_tage = None
|
||||
|
||||
|
||||
def get_tags(parent_id: int | None = None) -> list[Tag]:
|
||||
"""
|
||||
Liefert Tags, sortiert nach name (alphabetisch).
|
||||
@@ -76,6 +118,7 @@ def create_tag(data: dict) -> Tag:
|
||||
tag.color = (data.get("color") or "").strip() or None
|
||||
tag.parent_id = parent_id
|
||||
tag.is_parent = bool(data.get("is_parent", False))
|
||||
_apply_workflow_felder(tag, data)
|
||||
session.add(tag)
|
||||
session.commit()
|
||||
return tag
|
||||
@@ -124,6 +167,7 @@ def update_tag(data: dict) -> Tag:
|
||||
tag.color = (data.get("color") or "").strip() or None
|
||||
tag.parent_id = parent_id
|
||||
tag.is_parent = bool(data.get("is_parent", False))
|
||||
_apply_workflow_felder(tag, data)
|
||||
session.commit()
|
||||
return tag
|
||||
|
||||
|
||||
@@ -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 kontaktadresse as kontaktadressen
|
||||
from crm.model import call_log as call_logs
|
||||
from crm.model import tages_workflow
|
||||
from crm.model import tag as tags
|
||||
|
||||
print("crm-routes")
|
||||
@@ -332,11 +333,15 @@ def kontaktadresse_delete():
|
||||
@app.get("/xhr/calllog/list")
|
||||
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
||||
def calllog_list():
|
||||
"""Liefert Gesprächsnotizen als Liste. Optional nach projekt_id gefiltert.
|
||||
Für Nicht-SysAdmin/Nicht-AppAdmin nur Notizen sichtbarer Projekte."""
|
||||
"""Liefert Gesprächsnotizen als Liste. Optional nach projekt_id und/oder
|
||||
kontaktadresse_id gefiltert. Für Nicht-SysAdmin/Nicht-AppAdmin nur
|
||||
Notizen sichtbarer Projekte."""
|
||||
projekt_id = request.args.get("projekt_id", type=int)
|
||||
kontaktadresse_id = request.args.get("kontaktadresse_id", type=int)
|
||||
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
|
||||
|
||||
|
||||
@@ -354,9 +359,10 @@ def calllog_get():
|
||||
@app.post("/xhr/calllog/create")
|
||||
@authorisation_required([KUNDEN_MANAGER])
|
||||
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"]))
|
||||
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
|
||||
|
||||
|
||||
@@ -381,6 +387,30 @@ def calllog_delete():
|
||||
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) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -1,23 +1,98 @@
|
||||
from waitress import serve
|
||||
from werkzeug import serving
|
||||
from approot import init_app
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def run_server():
|
||||
try:
|
||||
if app.config["DEBUG"] is False:
|
||||
_server = "waitress"
|
||||
# serve(app, listen='nginx-proxy:8080') # Funktioniert aus unbekannten Gründen nicht. Der Hostname wird im Docker Container nicht korrekt aufgelöst
|
||||
print(f"Server startet mit Server type: {_server}", flush=True)
|
||||
serve(app, listen='*:8080')
|
||||
else:
|
||||
_server = "werkzeug"
|
||||
print(f"Server startet mit Server type: {_server}", flush=True)
|
||||
serving.run_simple("0.0.0.0", 8080, app, use_reloader=True)
|
||||
except Exception as ex:
|
||||
print(str(ex))
|
||||
print(f"Server gestartet. Server type: {_server}", flush=True)
|
||||
def _is_debug_requested() -> bool:
|
||||
"""
|
||||
True, wenn -D übergeben wurde oder APP_DEBUG gesetzt ist.
|
||||
|
||||
-D erlaubt den Dev-Start direkt von der Befehlszeile — ohne Devcontainer
|
||||
und ohne Env-Variable setzen zu müssen. APP_DEBUG steuert den
|
||||
Devcontainer-Betrieb (docker-compose.dev.yaml).
|
||||
"""
|
||||
if "-D" in sys.argv[1:]:
|
||||
return True
|
||||
return os.environ.get("APP_DEBUG", "false").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
def run_server(debug: bool):
|
||||
"""
|
||||
Startet den Server. Prod: Waitress. Dev: Werkzeug mit Reloader.
|
||||
Der Aufruf erfolgt entweder direkt (Prod) oder als supervidierter
|
||||
Subprozess (Dev, siehe run_dev_supervised).
|
||||
|
||||
debug wird an init_app() durchgereicht, damit app.config["DEBUG"]
|
||||
konsistent ist — davon hängt außer dem Server-Typ auch die Auswahl
|
||||
des JS-Bundles ab (routes/general.py: .js im Dev, .min.js in Prod).
|
||||
"""
|
||||
from waitress import serve
|
||||
from werkzeug import serving
|
||||
from approot import init_app
|
||||
|
||||
app = init_app(debug=debug)
|
||||
if not debug:
|
||||
print("Server startet mit Server type: waitress", flush=True)
|
||||
serve(app, listen='*:8080')
|
||||
else:
|
||||
print("Server startet mit Server type: werkzeug", flush=True)
|
||||
serving.run_simple("0.0.0.0", 8080, app, use_reloader=True)
|
||||
|
||||
|
||||
def run_dev_supervised():
|
||||
"""
|
||||
Crash-toleranter Dev-Betrieb.
|
||||
|
||||
Der Werkzeug-Reloader beendet sich komplett, wenn der App-Prozess beim
|
||||
Start crasht (z.B. SQLAlchemy-Fehler beim Import der Modelle). Wäre der
|
||||
Server direkt der Hauptprozess des Containers, würde damit der gesamte
|
||||
Dev-Container stoppen. Der Supervisor startet den Server daher als
|
||||
Subprozess und nach einem Abbruch einfach neu — sobald die fehlerhafte
|
||||
Datei korrigiert ist, läuft der Server automatisch wieder an.
|
||||
|
||||
Der Supervisor importiert die Anwendung absichtlich nicht
|
||||
(run_server importiert approot erst im Subprozess): Ein defekter
|
||||
Anwendungscode kann ihn nicht mitreißen.
|
||||
"""
|
||||
child = None
|
||||
|
||||
def _forward_signal(signum, frame):
|
||||
# SIGTERM/SIGINT an den Subprozess weiterleiten, damit der
|
||||
# Container sauber heruntergefahren werden kann.
|
||||
if child is not None and child.poll() is None:
|
||||
child.terminate()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, _forward_signal)
|
||||
signal.signal(signal.SIGINT, _forward_signal)
|
||||
|
||||
env = dict(os.environ)
|
||||
env["KBP_DEV_SUPERVISED"] = "true"
|
||||
while True:
|
||||
print("=== Supervisor: starte Dev-Server ===", flush=True)
|
||||
# sys.argv[1:] durchreichen, damit -D (und künftige Parameter)
|
||||
# im Subprozess ebenfalls wirksam sind.
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, os.path.abspath(__file__)] + sys.argv[1:],
|
||||
env=env,
|
||||
)
|
||||
child = proc
|
||||
code = proc.wait()
|
||||
print(f"=== Dev-Server beendet (Exit-Code {code}) — Neustart in 2 Sekunden ===",
|
||||
flush=True)
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = init_app()
|
||||
run_server()
|
||||
_debug = _is_debug_requested()
|
||||
if _debug and os.environ.get("KBP_DEV_SUPERVISED") != "true":
|
||||
# Dev-Modus und noch nicht beaufsichtigt: Supervisor werden
|
||||
# (im Devcontainer ist das PID 1 und hält den Container am Leben)
|
||||
run_dev_supervised()
|
||||
else:
|
||||
# Prod-Modus, oder wir sind bereits der beaufsichtigte
|
||||
# Dev-Subprozess (der Supervisor setzt KBP_DEV_SUPERVISED):
|
||||
# als eigentlicher Server starten.
|
||||
run_server(_debug)
|
||||
+113
-45
@@ -8,7 +8,7 @@ import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCall
|
||||
import {iKunde, fetchKunden} from "../models/kunde"
|
||||
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
|
||||
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
|
||||
import {iTag, fetchTags} from "../models/tag"
|
||||
import {iTag, fetchTags, berechneWiedervorlageDatum} from "../models/tag"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
|
||||
@@ -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"))
|
||||
]
|
||||
}
|
||||
@@ -189,6 +187,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
let currentLog: {
|
||||
id: number
|
||||
projekt_id: number
|
||||
kontaktadresse_id: number
|
||||
projekt_name: string
|
||||
kunden_name: string
|
||||
datum: string
|
||||
@@ -199,10 +198,10 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
telefon: string
|
||||
notiz: string
|
||||
wiedervorlage_datum: string
|
||||
erledigt: boolean
|
||||
} = {
|
||||
id: -1,
|
||||
projekt_id: -1,
|
||||
kontaktadresse_id: -1,
|
||||
projekt_name: "",
|
||||
kunden_name: "",
|
||||
datum: "",
|
||||
@@ -212,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
|
||||
@@ -229,6 +227,9 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
let kundenListe: iKunde[] = []
|
||||
let projektListe: iProjekt[] = []
|
||||
let kontaktadressenListe: iKontaktadresse[] = []
|
||||
// Im Auswahl-Modus (nicht vorbelegt): gewählte Kunden-ID für die
|
||||
// abhängige Projekt-Filterung.
|
||||
let selectedKundenId: number = -1
|
||||
// Asynchron geladene Tag-Liste (nur zuordenbare, nicht is_parent).
|
||||
let tagListe: iTag[] = []
|
||||
// Aktuell ausgewählte Tag-IDs im Formular.
|
||||
@@ -291,7 +292,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
fetchCallLogById(id).then((result) => {
|
||||
currentLog = result
|
||||
projektVoreingestellt = true
|
||||
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
|
||||
ansprechpartnerVoreingestellt = currentLog.kontaktadresse_id > 0
|
||||
selectedTagIds = (result.tags || []).map((t: iTag) => t.id)
|
||||
}).catch((e) => showServerError(e))
|
||||
} else {
|
||||
@@ -307,8 +308,10 @@ 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")
|
||||
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
|
||||
// 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
|
||||
ansprechpartnerVoreingestellt = currentLog.kontaktadresse_id > 0
|
||||
// Bei Neuanlage Startzeit merken und Dauer-Timer starten.
|
||||
startZeit = new Date()
|
||||
timerId = window.setInterval(() => {
|
||||
@@ -332,23 +335,12 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
guard.cleanup()
|
||||
},
|
||||
view: (vnode) => {
|
||||
// kunden_id aus dem geladenen Projekt ableiten (fuer abhaengiges
|
||||
// Projekt-Select). Wird nur bei nicht voreingestelltem Projekt
|
||||
// benoetigt.
|
||||
const internKundenId = (() => {
|
||||
if (currentLog.projekt_id !== -1) {
|
||||
const p = projektListe.find((p: iProjekt) => p.id === currentLog.projekt_id)
|
||||
return p ? p.kunden_id : -1
|
||||
}
|
||||
return -1
|
||||
})()
|
||||
|
||||
const kundenOptionen: iSelectOption[] = kundenListe.map((k: iKunde) => {
|
||||
return {value: k.id, label: k.name}
|
||||
})
|
||||
// Projekt-Optionen sind vom gewählten Kunden abhängig.
|
||||
const projektOptionen: iSelectOption[] = projektListe
|
||||
.filter((p: iProjekt) => internKundenId === -1 || p.kunden_id === internKundenId)
|
||||
.filter((p: iProjekt) => selectedKundenId === -1 || p.kunden_id === selectedKundenId)
|
||||
.map((p: iProjekt) => {
|
||||
return {value: p.id, label: p.projektname}
|
||||
})
|
||||
@@ -370,18 +362,30 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
const formData = new FormData(form)
|
||||
const gespeichertesLog: iCallLog = {
|
||||
id: currentLog.id,
|
||||
projekt_id: currentLog.projekt_id,
|
||||
projekt_id: projektVoreingestellt
|
||||
? currentLog.projekt_id
|
||||
: parseInt((formData.get("projekt_id_select") as string) || "-1"),
|
||||
// Pflichtfeld: voreingestellt aus dem currentLog, sonst
|
||||
// aus dem Select-Widget (kontaktadresse_id ist im Server
|
||||
// ein Pflichtfeld, ohne wird Bad Request geliefert).
|
||||
kontaktadresse_id: ansprechpartnerVoreingestellt
|
||||
? currentLog.kontaktadresse_id
|
||||
: parseInt((formData.get("kontaktadresse_id") as string) || "-1"),
|
||||
// Ersteller wird serverseitig aus current_user.id
|
||||
// gesetzt — hier nur Platzhalter, mitarbeiter_name
|
||||
// wird ausschließlich vom Server geliefert.
|
||||
user_id: null,
|
||||
mitarbeiter_name: null,
|
||||
projekt_name: "",
|
||||
kunden_name: "",
|
||||
datum: (formData.get("datum") as string) || "",
|
||||
datum: currentLog.datum || "",
|
||||
uhrzeit_beginn: (formData.get("uhrzeit_beginn") as string) || "",
|
||||
dauer: (formData.get("dauer") as string) || "",
|
||||
betreff: (formData.get("betreff") as string) || "",
|
||||
name_ansprechpartner: (formData.get("name_ansprechpartner") as string) || "",
|
||||
telefon: (formData.get("telefon") as string) || "",
|
||||
notiz: (formData.get("notiz") as string) || "",
|
||||
wiedervorlage_datum: (formData.get("wiedervorlage_datum") as string) || "",
|
||||
erledigt: formData.get("erledigt") === "on",
|
||||
wiedervorlage_datum: currentLog.wiedervorlage_datum || "",
|
||||
tag_ids: formData.getAll("tag_ids").map((v) => parseInt(v as string)).filter((n) => !isNaN(n))
|
||||
}
|
||||
if (currentLog.id === -1) {
|
||||
@@ -400,26 +404,70 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
} as iBreadcrumbBar),
|
||||
m("div", {style: {flex: '1', 'min-height': '0'}},
|
||||
m(Form, guard.formAttrs(submit) as iForm, [
|
||||
m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}),
|
||||
// Kunde und Projekt als Read-Only-Text, da voreingestellt.
|
||||
m("label", "Kunde",
|
||||
m("input.w3-input.w3-border", {
|
||||
type: "text",
|
||||
value: currentLog.kunden_name,
|
||||
readonly: true,
|
||||
style: {'margin-bottom': '5px'}
|
||||
})),
|
||||
m("label", "Projekt",
|
||||
m("input.w3-input.w3-border", {
|
||||
type: "text",
|
||||
value: currentLog.projekt_name,
|
||||
readonly: true,
|
||||
style: {'margin-bottom': '5px'}
|
||||
})),
|
||||
projektVoreingestellt
|
||||
? m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id})
|
||||
: null,
|
||||
// Kunde und Projekt: vorbelegt als Read-Only-Text,
|
||||
// nicht vorbelegt als auswählbare Select-Widgets.
|
||||
projektVoreingestellt
|
||||
? [
|
||||
m("label", "Kunde",
|
||||
m("input.w3-input.w3-border", {
|
||||
type: "text",
|
||||
value: currentLog.kunden_name,
|
||||
readonly: true,
|
||||
style: {'margin-bottom': '5px'}
|
||||
})),
|
||||
m("label", "Projekt",
|
||||
m("input.w3-input.w3-border", {
|
||||
type: "text",
|
||||
value: currentLog.projekt_name,
|
||||
readonly: true,
|
||||
style: {'margin-bottom': '5px'}
|
||||
}))
|
||||
]
|
||||
: [
|
||||
m("label", "Kunde", m(Select, {
|
||||
name: "kunden_id",
|
||||
value: selectedKundenId,
|
||||
options: kundenOptionen,
|
||||
required: true,
|
||||
onchanged: (kundenId: number) => {
|
||||
selectedKundenId = kundenId
|
||||
// Projekt zurücksetzen, da es zum
|
||||
// alten Kunden gehörte.
|
||||
currentLog.projekt_id = -1
|
||||
currentLog.projekt_name = ""
|
||||
currentLog.kunden_name = kundenListe.find(k => k.id === kundenId)?.name || ""
|
||||
guard.markDirty()
|
||||
}
|
||||
} as iSelect)),
|
||||
m("label", "Projekt", m(Select, {
|
||||
name: "projekt_id_select",
|
||||
value: currentLog.projekt_id,
|
||||
options: projektOptionen,
|
||||
required: true,
|
||||
onchanged: (projektId: number) => {
|
||||
const p = projektListe.find(pr => pr.id === projektId)
|
||||
currentLog.projekt_id = projektId
|
||||
currentLog.projekt_name = p?.projektname || ""
|
||||
currentLog.kunden_name = p?.kunden_name || ""
|
||||
guard.markDirty()
|
||||
}
|
||||
} as iSelect))
|
||||
],
|
||||
// Datum, Startzeit und Dauer in einer Zeile.
|
||||
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
|
||||
m("label", {style: {flex: '1'}}, "Datum",
|
||||
m(Input, {name: "datum", value: currentLog.datum, type: "date"} as iInput)),
|
||||
m(Input, {
|
||||
name: "datum",
|
||||
value: currentLog.datum,
|
||||
type: "date",
|
||||
oninput: (e: Event) => {
|
||||
currentLog.datum = (e.target as HTMLInputElement).value
|
||||
guard.markDirty()
|
||||
}
|
||||
} as iInput)),
|
||||
m("label", {style: {flex: '1'}}, "Uhrzeit Beginn",
|
||||
m(Input, {name: "uhrzeit_beginn", value: currentLog.uhrzeit_beginn, type: "time"} as iInput)),
|
||||
m("label", {style: {flex: '1'}}, "Dauer (mm:ss)",
|
||||
@@ -442,6 +490,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
name: "kontaktadresse_id",
|
||||
value: -1,
|
||||
options: kontaktadressenOptionen,
|
||||
required: true,
|
||||
onchanged: (kontaktadresseId: number) => {
|
||||
const ka = kontaktadressenListe.find((k: iKontaktadresse) => k.id === kontaktadresseId)
|
||||
if (ka) {
|
||||
@@ -463,10 +512,29 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
placeholder: "kein Tag",
|
||||
modalTitle: "Tags auswählen",
|
||||
editTooltip: "Tags bearbeiten",
|
||||
onchanged: () => { guard.markDirty() }
|
||||
onchanged: (newSelection: number[]) => {
|
||||
selectedTagIds.splice(0, selectedTagIds.length)
|
||||
Array.prototype.push.apply(selectedTagIds, newSelection)
|
||||
const vorschlag = berechneWiedervorlageDatum(
|
||||
newSelection,
|
||||
tagListe,
|
||||
currentLog.datum || new Date().toISOString().split('T')[0]
|
||||
)
|
||||
if (vorschlag) {
|
||||
currentLog.wiedervorlage_datum = vorschlag
|
||||
}
|
||||
guard.markDirty()
|
||||
}
|
||||
} as iMultiSelect)),
|
||||
m("label", "Wiedervorlage Datum", m(Input, {name: "wiedervorlage_datum", value: currentLog.wiedervorlage_datum, type: "date"} as iInput)),
|
||||
m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt")
|
||||
m("label", "Wiedervorlage Datum", m(Input, {
|
||||
name: "wiedervorlage_datum",
|
||||
value: currentLog.wiedervorlage_datum,
|
||||
type: "date",
|
||||
oninput: (e: Event) => {
|
||||
currentLog.wiedervorlage_datum = (e.target as HTMLInputElement).value
|
||||
guard.markDirty()
|
||||
}
|
||||
} as iInput))
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
@@ -63,6 +63,8 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
||||
id: -1,
|
||||
projekt_id: kontaktadresse.projekt_id,
|
||||
projekt_name: kontaktadresse.projekt_name,
|
||||
// Kontaktadresse als Pflichtfeld an die Notizmaske durchreichen
|
||||
kontaktadresse_id: kontaktadresse.id,
|
||||
datum: datum,
|
||||
uhrzeit_beginn: uhrzeit,
|
||||
dauer: "",
|
||||
@@ -71,7 +73,6 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
||||
telefon: kontaktadresse.telefon,
|
||||
notiz: "",
|
||||
wiedervorlage_datum: "",
|
||||
erledigt: false,
|
||||
bc: serializeBc(chain)
|
||||
})
|
||||
}
|
||||
@@ -236,6 +237,10 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
||||
id: currentKontaktadresse.id,
|
||||
projekt_id: currentKontaktadresse.projekt_id,
|
||||
projekt_name: "",
|
||||
projekt_zweck: "",
|
||||
// Server-seitig aus den Relationships abgeleitet;
|
||||
// Platzhalter beim Senden.
|
||||
kunden_name: "",
|
||||
anrede: (formData.get("anrede") as string) || "",
|
||||
name: (formData.get("name") as string) || "",
|
||||
firma: (formData.get("firma") as string) || "",
|
||||
@@ -246,7 +251,9 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
||||
plz: (formData.get("plz") as string) || "",
|
||||
ort: (formData.get("ort") as string) || "",
|
||||
land: (formData.get("land") as string) || "",
|
||||
notiz: (formData.get("notiz") as string) || ""
|
||||
notiz: (formData.get("notiz") as string) || "",
|
||||
// Server-seitig dynamisch berechnet; Platzhalter beim Senden.
|
||||
is_erledigt: false
|
||||
}
|
||||
if (currentKontaktadresse.id === -1) {
|
||||
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import m from "mithril"
|
||||
import {Form, iForm} from "./widgets/form"
|
||||
import { Input, iInput} from "./widgets/inputs"
|
||||
import {Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
|
||||
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
|
||||
import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt"
|
||||
@@ -85,7 +85,6 @@ export const projektliste: m.ClosureComponent = (vnode) => {
|
||||
telefon: "",
|
||||
notiz: "",
|
||||
wiedervorlage_datum: "",
|
||||
erledigt: false,
|
||||
bc: serializeBc(chain)
|
||||
})
|
||||
}
|
||||
@@ -239,7 +238,9 @@ export const Projekt: m.ClosureComponent = (vnode) => {
|
||||
projektname: (formData.get("projektname") as string) || "",
|
||||
zweck: (formData.get("zweck") as string) || "",
|
||||
verantwortlicher: (formData.get("verantwortlicher") as string) || "",
|
||||
startdatum: (formData.get("startdatum") as string) || ""
|
||||
startdatum: (formData.get("startdatum") as string) || "",
|
||||
// Server-seitig dynamisch berechnet; Platzhalter beim Senden.
|
||||
is_erledigt: false
|
||||
}
|
||||
if (currentProjekt.id === -1) {
|
||||
createProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
@@ -274,7 +275,7 @@ export const Projekt: m.ClosureComponent = (vnode) => {
|
||||
m(Form, guard.formAttrs(submit) as iForm, [
|
||||
m("input", {type: "hidden", name: "kunden_id", value: currentProjekt.kunden_id}),
|
||||
m("label", "Projektname", m(Input, {name: "projektname", value: currentProjekt.projektname, type: "text", required: true} as iInput)),
|
||||
m("label", "Zweck", m(Input, {name: "zweck", value: currentProjekt.zweck, type: "text"} as iInput)),
|
||||
m("label", "Aufgabenbeschreibung", m(Textarea, {name: "zweck", value: currentProjekt.zweck, rows: 4} as iTextarea)),
|
||||
m("label", "Verantwortlicher", m(Input, {name: "verantwortlicher", value: currentProjekt.verantwortlicher, type: "text"} as iInput)),
|
||||
m("label", "Startdatum", m(Input, {name: "startdatum", value: currentProjekt.startdatum, type: "date"} as iInput))
|
||||
])
|
||||
|
||||
@@ -0,0 +1,973 @@
|
||||
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 {iProjekt, fetchProjektById} from "../models/projekt"
|
||||
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]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// ============================================================================
|
||||
|
||||
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[] = []
|
||||
// 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.
|
||||
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 = []
|
||||
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
|
||||
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)
|
||||
// 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)
|
||||
}).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) => {
|
||||
if (timerId != null) { window.clearInterval(timerId) }
|
||||
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, 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 || "",
|
||||
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)
|
||||
}
|
||||
// Workflow-Buttons nicht als eigene Zeile über der Standard-
|
||||
// Leiste, sondern in diese eingereiht (extrabuttons der Form):
|
||||
// "Nächster Kontakt" und "Speichern und weiter" erscheinen
|
||||
// rechts neben "Speichern", "Speichern und weiter" ganz rechts
|
||||
// als Primäraktion des Workflows.
|
||||
formAttrs.extrabuttons = [
|
||||
m("button.w3-button.w3-theme.w3-right.w3-mobile", {
|
||||
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.w3-mobile", {
|
||||
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")
|
||||
]
|
||||
// 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) => {
|
||||
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})
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet ein Modal mit den Projektdetails (Aufgabenbeschreibung,
|
||||
* Verantwortlicher, Startdatum) des Projekts des aktuellen Kontakts
|
||||
* (Info-Button hinter dem Projektnamen in der Kopfzeile). Das
|
||||
* Projekt wird on-demand frisch vom Server geladen, damit
|
||||
* Verantwortlicher und Startdatum aktuell sind.
|
||||
*/
|
||||
const zweckAnzeigen = () => {
|
||||
if (!kontaktadresse) return
|
||||
const kontaktadresseId = kontaktadresse.id
|
||||
fetchProjektById(kontaktadresse.projekt_id).then((projekt: iProjekt) => {
|
||||
m.mount(gridMountpoints.modalhook, {
|
||||
view: () => {
|
||||
return m(ModalView,
|
||||
{
|
||||
title: "Aufgabenbeschreibung des Projekts",
|
||||
type: ModalType.MESSAGE,
|
||||
width: '40%'
|
||||
} as iModalView,
|
||||
m("div.w3-panel",
|
||||
m("p", m("strong", "Projekt: "), projekt.projektname),
|
||||
m("p", m("strong", "Verantwortlicher: "), projekt.verantwortlicher || "—"),
|
||||
m("p", m("strong", "Startdatum: "), projekt.startdatum || "—"),
|
||||
m("p", m("strong", "Aufgabenbeschreibung: ")),
|
||||
m("p", projekt.zweck
|
||||
? projekt.zweck
|
||||
: "Für dieses Projekt ist keine Aufgabenbeschreibung hinterlegt.")),
|
||||
m("div.w3-bar.w3-padding-16",
|
||||
m("button.w3-button.w3-theme.w3-right",
|
||||
{
|
||||
onclick: () => {
|
||||
m.mount(gridMountpoints.modalhook, null)
|
||||
}
|
||||
},
|
||||
"Schließen"))
|
||||
)
|
||||
}
|
||||
})
|
||||
}).catch((e: any) => {
|
||||
// Der Kontakt kann inzwischen gewechselt haben — dann das
|
||||
// Modal für den alten Kontakt nicht mehr öffnen.
|
||||
if (kontaktadresse && kontaktadresse.id === kontaktadresseId) {
|
||||
showServerError(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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, [
|
||||
// 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 : ""),
|
||||
// Zweck des Projekts als Tooltip: wird beim
|
||||
// Überfahren von Label und Projektname angezeigt.
|
||||
m("p", {
|
||||
style: {margin: '4px 0'},
|
||||
title: kontaktadresse && kontaktadresse.projekt_zweck ? kontaktadresse.projekt_zweck : undefined
|
||||
},
|
||||
m("strong", "Projekt: "), kontaktadresse ? kontaktadresse.projekt_name : "",
|
||||
// Info-Button hinter dem Projektnamen: öffnet
|
||||
// ein Modal mit der Aufgabenbeschreibung
|
||||
// (Alternative zum Tooltip, auch auf
|
||||
// Touch-Geräten nutzbar).
|
||||
// Ohne Hintergrundfarbe — nur das Icon ist
|
||||
// sichtbar, der w3-button liefert weiterhin
|
||||
// den Hover-Effekt als Klick-Feedback.
|
||||
m("button.w3-button.w3-small",
|
||||
{
|
||||
type: "button",
|
||||
title: "Projekt-Aufgabenbeschreibung anzeigen",
|
||||
style: {padding: '0 6px', 'margin-left': '4px'},
|
||||
onclick: zweckAnzeigen
|
||||
},
|
||||
m("i.material-icons", {style: {fontSize: '16px'}}, "info")))),
|
||||
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 ----
|
||||
// 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 || ""))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
])
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
-4
@@ -77,6 +77,9 @@ export const Tag: m.ClosureComponent = () => {
|
||||
color: "",
|
||||
parent_id: null,
|
||||
is_parent: false,
|
||||
is_erledigend: false,
|
||||
loest_wiedervorlage_aus: false,
|
||||
wiedervorlage_abstand_tage: null,
|
||||
}
|
||||
|
||||
// Liste aller Parent-Tags fuer die Eltern-Auswahl (nur Parent-Tags
|
||||
@@ -109,6 +112,9 @@ export const Tag: m.ClosureComponent = () => {
|
||||
color: routeParams.color || "",
|
||||
parent_id: routeParams.parent_id ? parseInt(routeParams.parent_id) : null,
|
||||
is_parent: routeParams.is_parent ? routeParams.is_parent === "true" : false,
|
||||
is_erledigend: routeParams.is_erledigend ? routeParams.is_erledigend === "true" : false,
|
||||
loest_wiedervorlage_aus: routeParams.loest_wiedervorlage_aus ? routeParams.loest_wiedervorlage_aus === "true" : false,
|
||||
wiedervorlage_abstand_tage: routeParams.wiedervorlage_abstand_tage ? parseInt(routeParams.wiedervorlage_abstand_tage) : null,
|
||||
}
|
||||
}
|
||||
// Parent-Tag-Liste vom Server laden (nur is_parent-Tags sind
|
||||
@@ -129,8 +135,11 @@ export const Tag: m.ClosureComponent = () => {
|
||||
name: (formData.get("name") as string) || "",
|
||||
description: (formData.get("description") as string) || "",
|
||||
color: currentTag.color || "",
|
||||
parent_id: formData.get("parent_id") ? parseInt(formData.get("parent_id") as string) : null,
|
||||
parent_id: currentTag.parent_id,
|
||||
is_parent: currentTag.is_parent,
|
||||
is_erledigend: currentTag.is_erledigend,
|
||||
loest_wiedervorlage_aus: currentTag.loest_wiedervorlage_aus,
|
||||
wiedervorlage_abstand_tage: currentTag.wiedervorlage_abstand_tage,
|
||||
}
|
||||
if (currentTag.id === -1) {
|
||||
createTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
@@ -168,7 +177,11 @@ export const Tag: m.ClosureComponent = () => {
|
||||
m("select.w3-select", {
|
||||
name: "parent_id",
|
||||
value: currentTag.parent_id || "",
|
||||
onchange: () => { guard.markDirty() }
|
||||
onchange: (e: Event) => {
|
||||
const val = (e.target as HTMLSelectElement).value
|
||||
currentTag.parent_id = val ? parseInt(val) : null
|
||||
guard.markDirty()
|
||||
}
|
||||
},
|
||||
m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
|
||||
allParentTags.map(tag =>
|
||||
@@ -176,6 +189,62 @@ export const Tag: m.ClosureComponent = () => {
|
||||
)
|
||||
),
|
||||
|
||||
// Workflow-Felder (nur für echte, zuordenbare Tags sichtbar).
|
||||
// Parent-Tags sind Gruppierungsknoten und nicht zuordenbar,
|
||||
// daher sind diese Felder für sie irrelevant.
|
||||
currentTag.is_parent ? null : [
|
||||
m("div", {style: {'margin-top': '16px', 'margin-bottom': '5px'}},
|
||||
m("label",
|
||||
m("input.w3-check", {
|
||||
type: "checkbox",
|
||||
name: "is_erledigend",
|
||||
checked: currentTag.is_erledigend,
|
||||
style: {'margin-right': '8px'},
|
||||
onclick: (e: Event) => {
|
||||
currentTag.is_erledigend = (e.target as HTMLInputElement).checked
|
||||
guard.markDirty()
|
||||
}
|
||||
}),
|
||||
"Kontakt als erledigt markieren"
|
||||
)
|
||||
),
|
||||
m("div", {style: {'margin-bottom': '5px'}},
|
||||
m("label",
|
||||
m("input.w3-check", {
|
||||
type: "checkbox",
|
||||
name: "loest_wiedervorlage_aus",
|
||||
checked: currentTag.loest_wiedervorlage_aus,
|
||||
style: {'margin-right': '8px'},
|
||||
onclick: (e: Event) => {
|
||||
currentTag.loest_wiedervorlage_aus = (e.target as HTMLInputElement).checked
|
||||
if (!currentTag.loest_wiedervorlage_aus) {
|
||||
currentTag.wiedervorlage_abstand_tage = null
|
||||
}
|
||||
guard.markDirty()
|
||||
}
|
||||
}),
|
||||
"Löst Wiedervorlage aus"
|
||||
)
|
||||
),
|
||||
m("label", "Wiedervorlage-Abstand (Tage)"),
|
||||
m("input.w3-input", {
|
||||
type: "number",
|
||||
name: "wiedervorlage_abstand_tage",
|
||||
value: currentTag.wiedervorlage_abstand_tage ?? "",
|
||||
min: 0,
|
||||
disabled: !currentTag.loest_wiedervorlage_aus,
|
||||
style: {
|
||||
'margin-bottom': '12px',
|
||||
opacity: currentTag.loest_wiedervorlage_aus ? '1' : '0.5'
|
||||
},
|
||||
oninput: (e: Event) => {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
currentTag.wiedervorlage_abstand_tage = val === "" ? null : parseInt(val)
|
||||
guard.markDirty()
|
||||
}
|
||||
})
|
||||
],
|
||||
|
||||
m("label", "Farbe"),
|
||||
m("div", {style: {display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px'}},
|
||||
colorPalette.map(color =>
|
||||
@@ -279,6 +348,47 @@ export const TagList: m.ClosureComponent = () => {
|
||||
const tagIcon = tag.is_parent ? "folder" : "label"
|
||||
const tagTitle = tag.is_parent ? "Parent-Tag (nicht zuordenbar)" : ""
|
||||
|
||||
// Workflow-Badges für echte Tags (nicht für Parent-Tags)
|
||||
let workflowBadges: any[] = []
|
||||
if (!tag.is_parent) {
|
||||
workflowBadges = []
|
||||
if (tag.is_erledigend) {
|
||||
workflowBadges.push(
|
||||
m("i.material-icons", {
|
||||
title: "Erledigend: Kontakt gilt als erledigt",
|
||||
style: {
|
||||
verticalAlign: 'middle',
|
||||
marginRight: '4px',
|
||||
fontSize: '16px',
|
||||
color: '#2e7d32'
|
||||
}
|
||||
}, "check_circle")
|
||||
)
|
||||
}
|
||||
if (tag.loest_wiedervorlage_aus) {
|
||||
workflowBadges.push(
|
||||
m("span", {
|
||||
title: "Löst Wiedervorlage aus: " + (tag.wiedervorlage_abstand_tage ?? 0) + " Tage",
|
||||
style: {
|
||||
verticalAlign: 'middle',
|
||||
marginRight: '4px',
|
||||
fontSize: '12px',
|
||||
color: '#1565c0'
|
||||
}
|
||||
},
|
||||
m("i.material-icons", {
|
||||
style: {
|
||||
verticalAlign: 'middle',
|
||||
marginRight: '2px',
|
||||
fontSize: '16px'
|
||||
}
|
||||
}, "schedule"),
|
||||
tag.wiedervorlage_abstand_tage != null ? tag.wiedervorlage_abstand_tage + "d" : ""
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
m("span",
|
||||
m("i.material-icons", {
|
||||
@@ -291,7 +401,7 @@ export const TagList: m.ClosureComponent = () => {
|
||||
}, tagIcon),
|
||||
m("span", {
|
||||
style: {
|
||||
backgroundColor: tag.color,
|
||||
backgroundColor: tag.color || '#9e9e9e',
|
||||
color: 'white',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
@@ -300,7 +410,8 @@ export const TagList: m.ClosureComponent = () => {
|
||||
marginRight: '8px'
|
||||
},
|
||||
title: tagTitle
|
||||
}, tag.name)
|
||||
}, tag.name),
|
||||
workflowBadges
|
||||
),
|
||||
m("span", tag.description),
|
||||
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}},
|
||||
|
||||
@@ -39,6 +39,20 @@ export interface iTreetable {
|
||||
* <tr>-Zeile; die columnmapper-Inhalte brauchen dann kein eigenes
|
||||
* Selektions-Styling mehr. */
|
||||
isSelected?: (node: iTreenode) => boolean
|
||||
/** Optional: Spaltenüberschriften (ein Titel je columnmapper-Spalte,
|
||||
* 1:1 von links nach rechts). Wenn gesetzt, rendert das Widget eine
|
||||
* Kopfzeile über den Datenzeilen. Hinweis zur ersten Spalte: Die
|
||||
* Einrückung/der Expand-Pfeil der Baumdarstellung liegt im selben
|
||||
* td wie die ERSTE columnmapper-Zelle — der erste Titel steht
|
||||
* daher direkt über der ersten Datenspalte. Die Kopfzeile bleibt
|
||||
* beim Scrollen sticky sichtbar. */
|
||||
header?: string[]
|
||||
/** Optional: Ein-/Ausklappen-Buttons (unfold_more/unfold_less) in der
|
||||
* Topbar anzeigen. Default true. Für flache Listen ohne Hierarchie
|
||||
* auf false setzen — die Topbar entfällt dann ganz, sofern auch
|
||||
* keine Children (z.B. TopbarButtons) übergeben und kein filter
|
||||
* gesetzt ist. */
|
||||
showExpandButtons?: boolean
|
||||
}
|
||||
|
||||
export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
|
||||
@@ -58,26 +72,73 @@ export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
|
||||
const topHasSiblingsWithChildren = vnode.attrs.treenodes.some(
|
||||
(tn: iTreenode) => tn.hasChildren
|
||||
)
|
||||
// Topbar (Expand-Buttons, Children, Suchfeld) nur rendern, wenn
|
||||
// tatsächlich Inhalt vorhanden ist — bei flachen Listen ohne
|
||||
// Expand-Buttons, Children und filter bleibt sie komplett weg.
|
||||
const showExpand = vnode.attrs.showExpandButtons !== false
|
||||
// vnode.children kann ein Array oder ein Primitiv sein
|
||||
// (Mithril-Typ ChildArrayOrPrimitive).
|
||||
const hatChildren = Array.isArray(vnode.children)
|
||||
? vnode.children.length > 0
|
||||
: vnode.children != null
|
||||
const hatTopbar = showExpand || hatChildren || vnode.attrs.filter != undefined
|
||||
return m("div.w3-border-theme", {style: {height: '99%', overflow: 'hidden', 'border-style': 'solid', 'border-width': '1px'}},
|
||||
m("div.w3-bar.w3-theme",
|
||||
m(TopbarButton, {
|
||||
onclick: () => {
|
||||
expandtoggle = true; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
|
||||
}},
|
||||
m("i.material-icons", "unfold_more")
|
||||
),
|
||||
m(TopbarButton, {
|
||||
onclick: () => {
|
||||
expandtoggle = false; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
|
||||
}},
|
||||
m("i.material-icons", "unfold_less")
|
||||
),
|
||||
vnode.children,
|
||||
(vnode.attrs.filter != undefined) ? m(SearchField, {setSearchvalue: setSearchvalue} as iSearchfield) : null
|
||||
),
|
||||
hatTopbar
|
||||
? m("div.w3-bar.w3-theme",
|
||||
showExpand
|
||||
? [
|
||||
m(TopbarButton, {
|
||||
onclick: () => {
|
||||
expandtoggle = true; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
|
||||
}},
|
||||
m("i.material-icons", "unfold_more")
|
||||
),
|
||||
m(TopbarButton, {
|
||||
onclick: () => {
|
||||
expandtoggle = false; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
|
||||
}},
|
||||
m("i.material-icons", "unfold_less")
|
||||
)
|
||||
]
|
||||
: null,
|
||||
vnode.children,
|
||||
(vnode.attrs.filter != undefined) ? m(SearchField, {setSearchvalue: setSearchvalue} as iSearchfield) : null
|
||||
)
|
||||
: null,
|
||||
m("div", {style: {height: '100%'}},
|
||||
m("div", {style: {height: 'calc(100% - 32px', overflow: 'auto', width: '100%'}},
|
||||
m("table.w3-table", {style: {'overflow-y': 'auto'}},
|
||||
// Ohne Topbar nutzt der scrollbare Bereich die volle Höhe.
|
||||
// Wichtig für den Sticky-Header: Dieser div muss selbst der
|
||||
// Scroll-Container sein (feste Höhe + overflow), damit sich
|
||||
// position:sticky der th-Zellen auf ihn bezieht.
|
||||
m("div", {style: {height: hatTopbar ? 'calc(100% - 32px)' : '100%', overflow: 'auto', width: '100%'}},
|
||||
// overflow darf hier NICHT auf dem table-Element gesetzt
|
||||
// werden — sonst wird die Tabelle selbst zum Scroll-Container
|
||||
// und der Sticky-Header bricht.
|
||||
m("table.w3-table",
|
||||
// Optionale Spaltenüberschriften: Ein Titel je
|
||||
// columnmapper-Spalte, 1:1 von links nach rechts
|
||||
// (die Einrückung teilt sich das td mit der
|
||||
// ersten Datenspalte, keine Führungs-Spalte).
|
||||
// Die Kopfzeile bleibt beim Scrollen sticky.
|
||||
vnode.attrs.header
|
||||
? m("thead",
|
||||
m("tr.w3-theme",
|
||||
vnode.attrs.header.map((titel: string) =>
|
||||
m("th.w3-theme", {
|
||||
// Sticky-Header: Eigener Hintergrund
|
||||
// nötig, da der tr-Hintergrund beim
|
||||
// Scrollen mit den Daten wegwandert
|
||||
// und sonst Zeilen durchscheinen.
|
||||
style: {
|
||||
position: 'sticky',
|
||||
top: '0',
|
||||
'z-index': '1',
|
||||
'text-align': 'left',
|
||||
'white-space': 'nowrap'
|
||||
},
|
||||
title: titel
|
||||
}, titel))))
|
||||
: null,
|
||||
vnode.attrs.treenodes.map((tn:iTreenode, z) => {
|
||||
if (vnode.attrs.filter == undefined || (vnode.attrs.filter != undefined && vnode.attrs.filter(tn, searchvalue) == true)) {
|
||||
return m(Treerow, {
|
||||
|
||||
@@ -19,6 +19,21 @@ export interface iForm {
|
||||
* der Form synchron zu halten und so den Navigations-Guard
|
||||
* (dirtyguard.ts) zu speisen. */
|
||||
onDirtyChange?: (dirty: boolean) => void
|
||||
/** Zusätzliche Buttons, die in die Standard-Button-Leiste der Form
|
||||
* eingereiht werden. Sie werden im DOM vor den Standard-Buttons
|
||||
* gesetzt und floaten daher (mit w3-right) rechts außen — die
|
||||
* Standard-Buttons (Zurücksetzen, Abbrechen, Speichern) behalten
|
||||
* ihre gewohnte Position. Die Buttons sind selbst als m(...) zu
|
||||
* übergeben; die aufrufende Maske ist für Styling und Callbacks
|
||||
* verantwortlich. */
|
||||
extrabuttons?: m.Child[]
|
||||
/** Wenn true, wird der scrollbare Inhaltsbereich der Form als
|
||||
* Flex-Spalte gerendert. Masken können dann einzelne Kinder mit
|
||||
* flex-Styles ausstatten, z.B. einen Bereich, der den verbleibenden
|
||||
* Platz füllt (flex: '01 1 auto' + 'min-height': '0') und in sich
|
||||
* scrollt, statt die gesamte Maske scrollbar zu machen. Wirkt nur
|
||||
* zusammen mit stickyButtons (Default true). */
|
||||
flexContent?: boolean
|
||||
}
|
||||
|
||||
export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
@@ -103,6 +118,11 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
: {}
|
||||
},
|
||||
[
|
||||
// Zusätzliche Buttons der Maske zuerst ins DOM:
|
||||
// mit w3-right landen sie optisch rechts außen,
|
||||
// die Standard-Buttons bleiben links davon in
|
||||
// ihrer gewohnten Reihenfolge.
|
||||
...(vnode.attrs.extrabuttons || []),
|
||||
m("button.w3-button.w3-theme.w3-right.w3-mobile", {style: {margin: '0px 0px 2px 4px'}, type: "submit"}, "Speichern"),
|
||||
m("button.w3-button.w3-theme.w3-right.w3-mobile", {style: {margin: '0px 0px 2px 4px'}, onclick: cancel, type: "button"}, "Abbrechen"),
|
||||
m("button.w3-button.w3-theme.w3-right.w3-mobile", {onclick: reset, type: "button"}, "Zurücksetzen")
|
||||
@@ -113,6 +133,14 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
// Container, die Buttons bleiben am unteren Rand fixiert. Das
|
||||
// umschließende form-Element wird zum flex-Container, damit das
|
||||
// Layout auch bei Fenstergrößenänderung stabil bleibt.
|
||||
// Stil des scrollbaren Inhaltsbereichs; optional als Flex-Spalte
|
||||
// (flexContent), damit Masken-Kinder den verbleibenden Platz
|
||||
// füllen können.
|
||||
const contentStyle: any = {flex: '1', 'overflow-y': 'auto', 'padding-right': '8px'}
|
||||
if (vnode.attrs.flexContent === true) {
|
||||
contentStyle.display = 'flex'
|
||||
contentStyle['flex-direction'] = 'column'
|
||||
}
|
||||
if (stickyButtons) {
|
||||
return m("form", {
|
||||
style: {display: 'flex', 'flex-direction': 'column', height: '100%'},
|
||||
@@ -133,7 +161,7 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
}
|
||||
},
|
||||
// Scrollbarer Formular-Inhalt.
|
||||
m("div", {style: {flex: '1', 'overflow-y': 'auto', 'padding-right': '8px'}},
|
||||
m("div", {style: contentStyle},
|
||||
vnode.children
|
||||
),
|
||||
// Fixierter Button-Bereich.
|
||||
|
||||
@@ -47,6 +47,20 @@ export const Select: m.ClosureComponent<iSelect> = () => {
|
||||
let highlightIndex: number = -1
|
||||
// Referenz auf das Eingabefeld für Fokus nach Clear.
|
||||
let inputfield: HTMLInputElement | null = null
|
||||
// Zuletzt im Suchtext dargestellter value. Dient der Synchronisation:
|
||||
// Der Anzeigetext wird beim Initialisieren und bei der Optionsauswahl
|
||||
// gesetzt; ändert sich der attrs.value von außen (z.B. Zurücksetzen
|
||||
// eines Filters im Eltern-View), wird der Text im view nachgezogen.
|
||||
let lastSyncedValue: any = undefined
|
||||
// True, solange der attrs.value noch keinem label zugeordnet werden
|
||||
// konnte — typischerweise weil die Optionen asynchron geladen werden
|
||||
// und beim oninit noch leer waren. Der view wiederholt die Synchro-
|
||||
// nisierung dann, bis eine passende Option existiert.
|
||||
let syncPending: boolean = false
|
||||
// Zeigt die Optionliste ungefiltert an, unabhängig vom Suchtext.
|
||||
// Wird vom Öffnen-Button gesetzt (neben dem Clear-Button) und beim
|
||||
// Tippen, bei der Auswahl und mit Escape zurückgesetzt.
|
||||
let showAll: boolean = false
|
||||
|
||||
/**
|
||||
* Liefert die gefilterten Optionen abhängig vom aktuellen Suchtext.
|
||||
@@ -59,13 +73,29 @@ export const Select: m.ClosureComponent<iSelect> = () => {
|
||||
return options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower))
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den Suchtext auf das label des übergebenen Werts und merkt
|
||||
* sich diesen als synchronisiert. Wird beim Initialisieren, bei der
|
||||
* Optionsauswahl und bei externen value-Änderungen aufgerufen.
|
||||
*/
|
||||
const syncSearchZumValue = (value: any, options: iSelectOption[]) => {
|
||||
const option = options.find((o: iSelectOption) => o.value === value)
|
||||
search = option ? option.label : ""
|
||||
lastSyncedValue = value
|
||||
// Konnte der value keinem label zugeordnet werden, bleibt die
|
||||
// Synchronisierung offen und wird im view wiederholt, sobald
|
||||
// die Optionen (asynchron) nachgeladen wurden.
|
||||
syncPending = !option
|
||||
}
|
||||
|
||||
/**
|
||||
* Wählt eine Option aus: value setzen, Suchtext = label, Dropdown zu.
|
||||
*/
|
||||
const selectOption = (option: iSelectOption, vnode: m.Vnode<iSelect>) => {
|
||||
vnode.attrs.value = option.value
|
||||
search = option.label
|
||||
syncSearchZumValue(option.value, vnode.attrs.options)
|
||||
isOpen = false
|
||||
showAll = false
|
||||
highlightIndex = -1
|
||||
if (vnode.attrs.onchanged != undefined) {
|
||||
vnode.attrs.onchanged(option.value)
|
||||
@@ -86,22 +116,36 @@ export const Select: m.ClosureComponent<iSelect> = () => {
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
// Aktuelles label zum value ermitteln (für die Anzeige im Feld).
|
||||
const initialOption = vnode.attrs.options.find((o: iSelectOption) => o.value === vnode.attrs.value)
|
||||
search = initialOption ? initialOption.label : ""
|
||||
syncSearchZumValue(vnode.attrs.value, vnode.attrs.options)
|
||||
},
|
||||
view: (vnode) => {
|
||||
// Extern geänderte value nachziehen: Der Suchtext wird nur beim
|
||||
// Initialisieren und bei der Optionsauswahl gesetzt. Ändert der
|
||||
// Eltern-View den attrs.value (z.B. Zurücksetzen eines Filters),
|
||||
// wird der angezeigte Text hier auf das neue label synchronisiert.
|
||||
if (vnode.attrs.value !== lastSyncedValue || syncPending) {
|
||||
syncSearchZumValue(vnode.attrs.value, vnode.attrs.options)
|
||||
}
|
||||
const isReadonly = vnode.attrs.readonly === true
|
||||
const filteredOptions = getFilteredOptions(vnode.attrs.options)
|
||||
// Angezeigt wird die ungefilterte Liste, wenn der Öffnen-Button
|
||||
// genutzt wurde, sonst die vom Suchtext gefilterte.
|
||||
const angezeigteOptionen = showAll ? vnode.attrs.options : filteredOptions
|
||||
const inputAttrs: any = {
|
||||
type: "text",
|
||||
value: search,
|
||||
style: {'margin-bottom': '5px', width: '100%', 'padding-right': '32px'},
|
||||
style: {'margin-bottom': '5px', width: '100%', 'padding-right': '58px'},
|
||||
oncreate: (vnode: m.VnodeDOM) => {
|
||||
inputfield = vnode.dom as HTMLInputElement
|
||||
},
|
||||
oninput: (e: Event) => {
|
||||
if (isReadonly) { return }
|
||||
search = (e.target as HTMLInputElement).value
|
||||
// Der Nutzer tippt selbst — eine offene Synchronisierung
|
||||
// (asynchron nachgeladene Optionen) nicht mehr nachziehen.
|
||||
syncPending = false
|
||||
// Tippen sucht wieder im Suchtext-Basis.
|
||||
showAll = false
|
||||
isOpen = true
|
||||
highlightIndex = filteredOptions.length > 0 ? 0 : -1
|
||||
},
|
||||
@@ -115,22 +159,23 @@ export const Select: m.ClosureComponent<iSelect> = () => {
|
||||
},
|
||||
onkeydown: (e: KeyboardEvent) => {
|
||||
if (isReadonly) { return }
|
||||
if (filteredOptions.length === 0) { return }
|
||||
if (angezeigteOptionen.length === 0) { return }
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
|
||||
highlightIndex = (highlightIndex + 1) % filteredOptions.length
|
||||
highlightIndex = (highlightIndex + 1) % angezeigteOptionen.length
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
|
||||
highlightIndex = (highlightIndex - 1 + filteredOptions.length) % filteredOptions.length
|
||||
highlightIndex = (highlightIndex - 1 + angezeigteOptionen.length) % angezeigteOptionen.length
|
||||
} else if (e.key === "Enter") {
|
||||
if (isOpen && highlightIndex >= 0 && highlightIndex < filteredOptions.length) {
|
||||
if (isOpen && highlightIndex >= 0 && highlightIndex < angezeigteOptionen.length) {
|
||||
e.preventDefault()
|
||||
selectOption(filteredOptions[highlightIndex], vnode)
|
||||
selectOption(angezeigteOptionen[highlightIndex], vnode)
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
isOpen = false
|
||||
showAll = false
|
||||
highlightIndex = -1
|
||||
}
|
||||
}
|
||||
@@ -145,31 +190,68 @@ export const Select: m.ClosureComponent<iSelect> = () => {
|
||||
return m("div", {style: {width: '100%', position: 'relative'}},
|
||||
// Verstecktes Feld für die Formularübergabe (echter value).
|
||||
m("input", {type: "hidden", name: vnode.attrs.name, value: vnode.attrs.value}),
|
||||
// Sichtbares Such-/Anzeigefeld mit Clear-Button darin.
|
||||
// Sichtbares Such-/Anzeigefeld mit Öffnen- und Clear-Button darin.
|
||||
m("div", {style: {position: 'relative', width: '100%'}},
|
||||
m("input.w3-input.w3-border", inputAttrs),
|
||||
// Clear-Button: leert das Suchfeld (nur wenn nicht readonly).
|
||||
// Absolut positioniert, sodass der Button optisch im
|
||||
// Eingabefeld liegt und vertikal zentriert ist.
|
||||
!isReadonly
|
||||
? m("button.w3-button", {
|
||||
style: {
|
||||
position: 'absolute',
|
||||
right: '4px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
'min-width': '24px',
|
||||
height: '24px',
|
||||
padding: '2px',
|
||||
'line-height': '1'
|
||||
},
|
||||
onclick: () => {
|
||||
search = ""
|
||||
isOpen = true
|
||||
highlightIndex = filteredOptions.length > 0 ? 0 : -1
|
||||
if (inputfield) { inputfield.focus() }
|
||||
}
|
||||
}, m("i.material-icons", {style: {'font-size': '20px'}}, "clear"))
|
||||
? [
|
||||
// Öffnen-Button: blendet die Optionliste ungefiltert
|
||||
// ein, ohne das Feld zu leeren — der aktuelle Wert
|
||||
// bleibt stehen, bis eine neue Option gewählt wird.
|
||||
m("button.w3-button", {
|
||||
type: "button",
|
||||
title: "Liste öffnen (alle Optionen anzeigen)",
|
||||
style: {
|
||||
position: 'absolute',
|
||||
right: '28px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
'min-width': '24px',
|
||||
height: '24px',
|
||||
padding: '2px',
|
||||
'line-height': '1'
|
||||
},
|
||||
// mousedown mit preventDefault: hält den Fokus
|
||||
// auf dem Eingabefeld (kein Blur-Rennen mit dem
|
||||
// verzögerten Schließen) und öffnet/schließt
|
||||
// die ungefilterte Liste. Klick bei offener
|
||||
// Gesamtliste schließt sie wieder (Toggle).
|
||||
onmousedown: (e: Event) => {
|
||||
e.preventDefault()
|
||||
if (isOpen && showAll) {
|
||||
isOpen = false
|
||||
showAll = false
|
||||
highlightIndex = -1
|
||||
} else {
|
||||
showAll = true
|
||||
isOpen = true
|
||||
highlightIndex = 0
|
||||
}
|
||||
}
|
||||
}, m("i.material-icons", {style: {'font-size': '20px'}}, "arrow_drop_down")),
|
||||
// Clear-Button: leert das Suchfeld.
|
||||
// Absolut positioniert, sodass der Button optisch im
|
||||
// Eingabefeld liegt und vertikal zentriert ist.
|
||||
m("button.w3-button", {
|
||||
type: "button",
|
||||
style: {
|
||||
position: 'absolute',
|
||||
right: '4px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
'min-width': '24px',
|
||||
height: '24px',
|
||||
padding: '2px',
|
||||
'line-height': '1'
|
||||
},
|
||||
onclick: () => {
|
||||
search = ""
|
||||
isOpen = true
|
||||
highlightIndex = filteredOptions.length > 0 ? 0 : -1
|
||||
if (inputfield) { inputfield.focus() }
|
||||
}
|
||||
}, m("i.material-icons", {style: {'font-size': '20px'}}, "clear"))
|
||||
]
|
||||
: null
|
||||
),
|
||||
// Gefilterte, aufklappbare Optionliste. Eigene Positionierung,
|
||||
@@ -186,9 +268,9 @@ export const Select: m.ClosureComponent<iSelect> = () => {
|
||||
'box-shadow': '2px 2px 4px darkgrey'
|
||||
}
|
||||
},
|
||||
filteredOptions.length === 0
|
||||
angezeigteOptionen.length === 0
|
||||
? m("div.w3-padding-small", "Keine Treffer")
|
||||
: filteredOptions.map((option: iSelectOption, index: number) =>
|
||||
: angezeigteOptionen.map((option: iSelectOption, index: number) =>
|
||||
m("a.w3-bar-item.w3-button.w3-text-left", {
|
||||
style: {whiteSpace: 'normal'},
|
||||
class: index === highlightIndex ? "w3-theme" : "w3-hover-light-grey",
|
||||
|
||||
+6
-2
@@ -11,6 +11,7 @@ import { Call_Log, call_log_liste } from "./Components/call_log"
|
||||
import { Kontakt, kontaktliste } from "./Components/kontakt"
|
||||
import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse"
|
||||
import { Tag, TagList } from "./Components/tags"
|
||||
import { TagesWorkflowListe, TagesWorkflowMaske } from "./Components/tages_workflow"
|
||||
import { guardedNavigate } from "./Components/widgets/dirtyguard"
|
||||
|
||||
|
||||
@@ -133,6 +134,7 @@ const menu: m.ClosureComponent = () => {
|
||||
},
|
||||
view: (vnode) => {
|
||||
return m(menubar, [
|
||||
menuItem("/tagesworkflow", "Tages-Workflow"),
|
||||
menuItem("/kundenliste", "Kunden"),
|
||||
menuItem("/projektliste", "Projekte"),
|
||||
menuItem("/calllogliste", "Gesprächsnotizen"),
|
||||
@@ -214,6 +216,8 @@ m.route(gridMountpoints.main, "/kundenliste", {
|
||||
"/kontaktadresseliste": kontaktadresseliste,
|
||||
"/kontaktadresse": Kontaktadresse,
|
||||
"/taglist": TagList,
|
||||
"/tag": Tag
|
||||
|
||||
"/tag": Tag,
|
||||
"/tagesworkflow": TagesWorkflowListe,
|
||||
"/tagesworkflowkontakt": TagesWorkflowMaske
|
||||
|
||||
})
|
||||
@@ -6,14 +6,29 @@ import {iTag} from "./tag"
|
||||
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
||||
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
|
||||
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
|
||||
* kontaktadresse_id ist Pflichtfeld: Jede Notiz gehört zu genau einer
|
||||
* 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).
|
||||
* 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.
|
||||
*/
|
||||
export interface iCallLog {
|
||||
id: number
|
||||
projekt_id: number
|
||||
kontaktadresse_id: number
|
||||
/** Ersteller (Mitarbeiter), serverseitig gesetzt; null bei Alt-Notizen. */
|
||||
user_id: number | null
|
||||
projekt_name: string
|
||||
kunden_name: string
|
||||
/** Aufgelöster Username des Erstellers; null bei Alt-Notizen. */
|
||||
mitarbeiter_name: string | null
|
||||
datum: string
|
||||
uhrzeit_beginn: string
|
||||
dauer: string
|
||||
@@ -22,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). */
|
||||
@@ -70,6 +84,19 @@ export function fetchCallLogById(id: number): Promise<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.
|
||||
*/
|
||||
|
||||
@@ -4,13 +4,18 @@ import m from "mithril"
|
||||
* Datenmodell für eine Kontaktadresse (Akquise-Ziel innerhalb eines Projekts).
|
||||
* Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projekt_id).
|
||||
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
||||
* projekt_name ist keine ORM-Spalte, sondern wird serverseitig aus der
|
||||
* Projekt-Relationship abgeleitet und im JSON ergänzt.
|
||||
* projekt_name, projekt_zweck und kunden_name sind keine ORM-Spalten, sondern
|
||||
* werden serverseitig aus den Projekt-/Kunde-Relationships abgeleitet und im
|
||||
* JSON ergänzt. is_erledigt ist ebenfalls keine Spalte, sondern wird
|
||||
* serverseitig dynamisch berechnet: True, sobald mindestens eine
|
||||
* Gesprächsnotiz des Kontakts einen Tag mit is_erledigend=True hat.
|
||||
*/
|
||||
export interface iKontaktadresse {
|
||||
id: number
|
||||
projekt_id: number
|
||||
projekt_name: string
|
||||
projekt_zweck: string
|
||||
kunden_name: string
|
||||
anrede: string
|
||||
name: string
|
||||
firma: string
|
||||
@@ -22,6 +27,8 @@ export interface iKontaktadresse {
|
||||
ort: string
|
||||
land: string
|
||||
notiz: string
|
||||
/** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */
|
||||
is_erledigt: boolean
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -4,7 +4,9 @@ import m from "mithril"
|
||||
* Datenmodell für ein Projekt.
|
||||
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
||||
* kunden_name ist keine ORM-Spalte, sondern wird serverseitig aus der
|
||||
* Kunde-Relationship abgeleitet und im JSON ergänzt.
|
||||
* Kunde-Relationship abgeleitet und im JSON ergänzt. is_erledigt ist
|
||||
* ebenfalls keine Spalte, sondern wird serverseitig dynamisch berechnet:
|
||||
* True, wenn das Projekt Kontaktadressen hat und alle davon erledigt sind.
|
||||
*/
|
||||
export interface iProjekt {
|
||||
id: number
|
||||
@@ -14,6 +16,8 @@ export interface iProjekt {
|
||||
zweck: string
|
||||
verantwortlicher: string
|
||||
startdatum: string
|
||||
/** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */
|
||||
is_erledigt: boolean
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -12,6 +12,12 @@ export interface iTag {
|
||||
color: string
|
||||
parent_id: number | null
|
||||
is_parent: boolean
|
||||
/** True = Kontakt gilt als erledigt, wenn dieser Tag zugewiesen wird. */
|
||||
is_erledigend: boolean
|
||||
/** True = Tag löst eine Wiedervorlage aus. */
|
||||
loest_wiedervorlage_aus: boolean
|
||||
/** Tage bis zur Wiedervorlage (nur relevant, wenn loest_wiedervorlage_aus True). */
|
||||
wiedervorlage_abstand_tage: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,3 +112,32 @@ export function deleteTagREST(id: number): Promise<void> {
|
||||
params: {id: id}
|
||||
}).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[])
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
# 📌 CRM-Konzept für den Telefon-Akquise-Workflow
|
||||
|
||||
---
|
||||
|
||||
## **📂 1. Datenmodell (Anpassungen)**
|
||||
|
||||
---
|
||||
|
||||
### **1.1. Bestehende Tabellen (ohne Änderungen)**
|
||||
|
||||
- **`User`** (Mitarbeiter)
|
||||
- **`Customer`** (Kunde)
|
||||
- **`Project`** (Projekt)
|
||||
- **`Contact`** (Kontakt)
|
||||
- **`ProjektUser`** (Zuordnung User → Projekt)
|
||||
- **`CallLogsTag`** (Zuordnung CallLog → Tag)
|
||||
|
||||
---
|
||||
|
||||
### **1.2. Anpassungen an bestehenden Tabellen**
|
||||
|
||||
#### **`Tag` (Erweiterung für Wiedervorlage-Logik)**
|
||||
|
||||
|
||||
| **Feld** | **Typ** | **Beschreibung** | **Standardwert** |
|
||||
| -------------------------------- | ------------ | ---------------------------------------------------------------------------------- | ---------------- |
|
||||
| `id` | Integer | Primärschlüssel | - |
|
||||
| `name` | String(200) | Name des Tags | - |
|
||||
| `description` | String(2000) | Beschreibung (optional) | `NULL` |
|
||||
| `color` | String(20) | Farbe für UI (optional) | `NULL` |
|
||||
| `parent_id` | Integer | Selbstreferenz für hierarchische Tags | `NULL` |
|
||||
| `is_parent` | Boolean | `True` = Gruppierungsknoten | `False` |
|
||||
| **`is_erledigend`** | **Boolean** | **`True` = Kontakt wird als *erledigt* markiert, wenn dieser Tag zugewiesen wird** | **`False`** |
|
||||
| **`loest_wiedervorlage_aus`** | **Boolean** | **`True` = Tag löst eine Wiedervorlage aus** | **`False`** |
|
||||
| **`wiedervorlage_abstand_tage`** | **Integer** | **Anzahl Tage bis zur Wiedervorlage (falls `loest_wiedervorlage_aus = True`)** | **`NULL`** |
|
||||
|
||||
|
||||
---
|
||||
|
||||
#### **`CallLog` (Anpassung für Wiedervorlage-Datum)**
|
||||
|
||||
|
||||
| **Feld** | **Typ** | **Beschreibung** | **Standardwert** |
|
||||
| ------------------------- | -------- | -------------------------------------------------------------------------- | ------------------- |
|
||||
| `id` | Integer | Primärschlüssel | - |
|
||||
| `contact_id` | Integer | Fremdschlüssel → `Contact` | - |
|
||||
| `user_id` | Integer | Fremdschlüssel → `User` | - |
|
||||
| `note_text` | Text | Text der Gesprächsnotiz | - |
|
||||
| `created_at` | DateTime | Erstellungsdatum | `CURRENT_TIMESTAMP` |
|
||||
| **`wiedervorlage_datum`** | **Date** | **Wiedervorlage-Datum (automatisch berechnet oder manuell überschrieben)** | **`NULL`** |
|
||||
|
||||
|
||||
---
|
||||
|
||||
#### **`Contact` (Anpassung für optimistisches Locking)**
|
||||
|
||||
|
||||
| **Feld** | **Typ** | **Beschreibung** | **Standardwert** |
|
||||
| ------------- | ----------- | --------------------------------------------- | ---------------- |
|
||||
| `id` | Integer | Primärschlüssel | - |
|
||||
| `project_id` | Integer | Fremdschlüssel → `Project` | - |
|
||||
| `first_name` | String(100) | Vorname des Kontakts | - |
|
||||
| `last_name` | String(100) | Nachname des Kontakts | - |
|
||||
| `phone` | String(20) | Telefonnummer | - |
|
||||
| `email` | String(100) | E-Mail (optional) | `NULL` |
|
||||
| **`version`** | **Integer** | **Versionsnummer für optimistisches Locking** | **`0`** |
|
||||
|
||||
|
||||
---
|
||||
|
||||
#### **`Project` (kein Status-Feld)**
|
||||
|
||||
|
||||
| **Feld** | **Typ** | **Beschreibung** | **Standardwert** |
|
||||
| ------------- | ----------- | --------------------------- | ------------------- |
|
||||
| `id` | Integer | Primärschlüssel | - |
|
||||
| `customer_id` | Integer | Fremdschlüssel → `Customer` | - |
|
||||
| `name` | String(200) | Name des Projekts | - |
|
||||
| `created_at` | DateTime | Erstellungsdatum | `CURRENT_TIMESTAMP` |
|
||||
|
||||
|
||||
---
|
||||
|
||||
### **1.3. Neue Methoden (Statusberechnung)**
|
||||
|
||||
#### **Methode für `Contact`: `is_erledigt()`**
|
||||
|
||||
- **Zweck**: Dynamisch prüfen, ob ein Kontakt als *erledigt* gilt.
|
||||
- **Logik**:
|
||||
- Falls der Kontakt **keine Gesprächsnotizen** hat → `False` (nicht erledigt).
|
||||
- Falls **alle Gesprächsnotizen** des Kontakts **mindestens einen Tag mit `is_erledigend = True`** haben → `True` (erledigt).
|
||||
- Sonst → `False` (nicht erledigt).
|
||||
|
||||
**Pseudocode:**
|
||||
|
||||
```python
|
||||
# Methode in der Contact-Klasse
|
||||
def is_erledigt(self):
|
||||
if not self.call_logs:
|
||||
return False
|
||||
for notiz in self.call_logs:
|
||||
for tag in notiz.tags:
|
||||
if not tag.is_erledigend:
|
||||
return False
|
||||
return True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **Methode für `Project`: `is_erledigt()`**
|
||||
|
||||
- **Zweck**: Dynamisch prüfen, ob ein Projekt als *erledigt* gilt.
|
||||
- **Logik**:
|
||||
- Falls das Projekt **keine Kontakte** hat → `True` (erledigt).
|
||||
- Falls **alle Kontakte** des Projekts `is_erledigt() = True` sind → `True` (erledigt).
|
||||
- Sonst → `False` (nicht erledigt).
|
||||
|
||||
**Pseudocode:**
|
||||
|
||||
```python
|
||||
# Methode in der Project-Klasse
|
||||
def is_erledigt(self):
|
||||
if not self.contacts:
|
||||
return True
|
||||
for kontakt in self.contacts:
|
||||
if not kontakt.is_erledigt():
|
||||
return False
|
||||
return True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **1.4. Neue Tabellen (für spätere Protokollierung)**
|
||||
|
||||
#### **`KontaktLog` (optional)**
|
||||
|
||||
|
||||
| **Feld** | **Typ** | **Beschreibung** |
|
||||
| ------------ | ---------- | ---------------------------------------------------------- |
|
||||
| `id` | Integer | Primärschlüssel |
|
||||
| `kontakt_id` | Integer | Fremdschlüssel → `Contact` |
|
||||
| `user_id` | Integer | Fremdschlüssel → `User` |
|
||||
| `timestamp` | DateTime | Zeitstempel der Änderung |
|
||||
| `aktion` | String(50) | Art der Änderung (z. B. *"Notiz hinzugefügt"*) |
|
||||
| `alte_werte` | JSON | Alte Werte (z. B. `{"wiedervorlage_datum": "2026-09-20"}`) |
|
||||
| `neue_werte` | JSON | Neue Werte (z. B. `{"wiedervorlage_datum": "2026-09-25"}`) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## **🖥️ 2. Benutzeroberfläche (Masken & Workflows)**
|
||||
|
||||
---
|
||||
|
||||
### **2.1. Hauptmenü**
|
||||
|
||||
- **Menüpunkte**:
|
||||
- **Tages-Workflow**
|
||||
- **Projekte**
|
||||
- **Kontakte**
|
||||
- **Statistiken**
|
||||
|
||||
---
|
||||
|
||||
### **2.2. Tages-Workflow: Gefilterte Liste**
|
||||
|
||||
#### **Anzeige**
|
||||
|
||||
- **Tabellenansicht** mit Spalten:
|
||||
|
||||
| **Spalte** | **Beschreibung** | **Beispiel** |
|
||||
| ------------- | ----------------------------------------- | ------------------------- |
|
||||
| Kunde | Name des Kunden | "Muster GmbH" |
|
||||
| Projekt | Name des Projekts | "Neukundengewinnung 2026" |
|
||||
| Kontakt | Name des Ansprechpartners | "Max Mustermann" |
|
||||
| Telefon | Telefonnummer | "+49 123 45678" |
|
||||
| Letzte Notiz | Kurztext der letzten Gesprächsnotiz | "Nicht erreicht" |
|
||||
| Wiedervorlage | Nächstes Wiedervorlage-Datum | "20.09.2026" |
|
||||
| **Status** | **Dynamisch berechnet (`is_erledigt()`)** | "Offen" / "Erledigt" |
|
||||
|
||||
- **Sortierung**:
|
||||
1. Überfällige Wiedervorlagen
|
||||
2. Kontakte ohne Gesprächsnotizen
|
||||
3. Kontakte mit Wiedervorlage in der aktuellen Woche
|
||||
|
||||
---
|
||||
|
||||
#### **Filterleiste**
|
||||
|
||||
|
||||
| **Filter** | **Typ** | **Mögliche Werte** |
|
||||
| ------------------- | ---------------- | ----------------------------------------------------------------------- |
|
||||
| Kunde | Dropdown | Alle Kunden, die der Mitarbeiter bearbeiten darf |
|
||||
| Projektstatus | Dropdown | *Offen*, *In Bearbeitung* (dynamisch berechnet) |
|
||||
| **Tag-Kategorie** | **Dropdown** | **`Erledigend`**, **`Nicht-erledigend`**, **`Wiedervorlage-Tags`** |
|
||||
| **Einzelne Tags** | **Multi-Select** | **Alle verfügbaren Tags** (z. B. *"Nicht erreicht"*, *"Wiedervorlage"*) |
|
||||
| Wiedervorlage-Datum | Dropdown | *Heute*, *Nächste 3 Tage*, *Überfällig*, *Aktuelle Woche* |
|
||||
|
||||
|
||||
- **Aktionsbuttons**:
|
||||
- **"Filter anwenden"** (Aktualisiert die Liste)
|
||||
- **"Zurücksetzen"** (Setzt alle Filter zurück)
|
||||
- **"Workflow beginnen"** (Startet die Bearbeitung mit dem ersten Eintrag)
|
||||
|
||||
---
|
||||
|
||||
### **2.3. Bearbeitungsmaske**
|
||||
|
||||
#### **Kopfzeile: Projekt- und Kontaktinformationen**
|
||||
|
||||
- **Kundeninfo**: Name des Kunden und Projektname.
|
||||
- **Kontaktinfo**: Name, Telefonnummer, E-Mail.
|
||||
- **Button**: *"Kontakt bearbeiten"*
|
||||
|
||||
**Beispiel:**
|
||||
|
||||
```
|
||||
Kunde: Muster GmbH
|
||||
Projekt: Neukundengewinnung 2026
|
||||
Kontakt: Max Mustermann | +49 123 45678 | max@muster.de
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **Tabelle der bisherigen Gesprächsnotizen**
|
||||
|
||||
|
||||
| **Spalte** | **Beschreibung** | **Beispiel** |
|
||||
| ------------- | ---------------------------- | ------------------------------- |
|
||||
| Datum | Erstellungsdatum und Uhrzeit | "19.09.2026, 14:30" |
|
||||
| Mitarbeiter | Name des Mitarbeiters | "Gerd Goldberg" |
|
||||
| Notiz | Text der Gesprächsnotiz | "Nicht erreicht" |
|
||||
| Tags | Zugewiesene Tags | "Nicht erreicht, Wiedervorlage" |
|
||||
| Wiedervorlage | Wiedervorlage-Datum | "20.09.2026" |
|
||||
|
||||
|
||||
- **Sortierung**: Neueste Notiz zuerst.
|
||||
- **Funktionen**:
|
||||
- Klick auf Notiz → Details anzeigen.
|
||||
- **Button**: *"Notiz bearbeiten"*
|
||||
|
||||
---
|
||||
|
||||
#### **Formular für neue Gesprächsnotiz**
|
||||
|
||||
- **Felder**:
|
||||
- **Notiztext** (Textarea, Pflichtfeld)
|
||||
- **Tags** (Multi-Select-Dropdown mit allen verfügbaren Tags)
|
||||
- **Wiedervorlage-Datum** (Datumspicker, **automatisch berechnet oder manuell überschreibbar**)
|
||||
- **Buttons**:
|
||||
- **"Speichern und weiter"** (Speichert und lädt den nächsten Kontakt)
|
||||
- **"Speichern"** (Speichert und bleibt auf dem aktuellen Kontakt)
|
||||
- **"Abbrechen"** (Verwirft Änderungen, Rückkehr zur Liste)
|
||||
|
||||
---
|
||||
|
||||
#### **Navigation**
|
||||
|
||||
- **Button: "Zurück zur Liste"**:
|
||||
- Kehrt zur **gefilterten Liste** zurück.
|
||||
- Die Liste berücksichtigt bereits alle Änderungen (z. B. neue Notizen, Statusänderungen).
|
||||
- **Button: "Nächster Kontakt"**:
|
||||
- Lädt manuell den nächsten Kontakt aus der Liste.
|
||||
|
||||
---
|
||||
|
||||
### **2.4. Konfliktmeldung (bei optimistischem Locking)**
|
||||
|
||||
- **Anzeige**:
|
||||
```
|
||||
Dieser Kontakt wurde von [Mitarbeiter] bearbeitet und als erledigt markiert.
|
||||
Möchten Sie die aktuelle Version neu laden?
|
||||
```
|
||||
- **Optionen**:
|
||||
- *"Neu laden"* (Lädt die aktuelle Version des Kontakts).
|
||||
- *"Trotzdem speichern"* (Überschreibt die Änderungen, **nur für Admins**).
|
||||
|
||||
---
|
||||
|
||||
### **2.5. Hinweis bei übersprungenen Kontakten**
|
||||
|
||||
- **Automatisches Überspringen** von Kontakten, die nicht mehr den Filterkriterien entsprechen.
|
||||
- **Hinweis in der UI**:
|
||||
```
|
||||
Kontakt [Name] wurde übersprungen, da er nicht mehr den Filterkriterien entspricht
|
||||
(Grund: [z. B. 'Wiedervorlage-Datum außerhalb der gefilterten Woche' oder 'Kontakt wurde als erledigt markiert']).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## **🔄 3. Workflows & Logik**
|
||||
|
||||
---
|
||||
|
||||
### **3.1. Wiedervorlage-Datum Berechnung**
|
||||
|
||||
- **Automatische Berechnung**:
|
||||
- Falls **kein manuelles `wiedervorlage_datum`** in der Notiz gesetzt ist:
|
||||
- **Minimum** der `wiedervorlage_abstand_tage` aller ausgewählten Tags mit `loest_wiedervorlage_aus = True`.
|
||||
- **Wiedervorlage-Datum = Erstellungsdatum der Notiz + Minimum-Abstand**.
|
||||
- **Manuelle Überschreibung**:
|
||||
- Der Mitarbeiter kann das **`wiedervorlage_datum`** jederzeit manuell ändern (z. B. über den Datumspicker).
|
||||
|
||||
---
|
||||
|
||||
### **3.2. Statusberechnung (dynamisch)**
|
||||
|
||||
- **Kontaktstatus**:
|
||||
- Wird **dynamisch** über die Methode `is_erledigt()` berechnet (siehe [1.3](#13-neue-methoden-statusberechnung)).
|
||||
- **Kein statisches `status`-Feld** in der Datenbank.
|
||||
- **Projektstatus**:
|
||||
- Wird **dynamisch** über die Methode `is_erledigt()` berechnet.
|
||||
|
||||
---
|
||||
|
||||
### **3.3. Tages-Workflow (Ablauf)**
|
||||
|
||||
1. **Aufruf des Tages-Workflows**:
|
||||
- Mitarbeiter wählt **"Tages-Workflow"** aus.
|
||||
- **System**: Zeigt die **priorisierte Liste** der Kontakte an (sortiert nach Wiedervorlage-Datum).
|
||||
2. **Filter anwenden**:
|
||||
- Mitarbeiter wählt Filter (z. B. Kunde, Tag-Kategorie, einzelne Tags).
|
||||
- **System**: Aktualisiert die Liste basierend auf den Filtern.
|
||||
3. **Workflow beginnen**:
|
||||
- Mitarbeiter klickt auf **"Workflow beginnen"**.
|
||||
- **System**: Lädt den **ersten Kontakt** aus der gefilterten Liste in die Bearbeitungsmaske.
|
||||
4. **Bearbeitung des Kontakts**:
|
||||
- Mitarbeiter erfasst eine **neue Gesprächsnotiz** (Text, Tags, **Wiedervorlage-Datum**).
|
||||
- **System**:
|
||||
- Berechnet das **`wiedervorlage_datum`** (falls nicht manuell gesetzt).
|
||||
- Inkrementiert die **`version`** des Kontakts (für optimistisches Locking).
|
||||
5. **Speichern und weiter**:
|
||||
- Mitarbeiter klickt auf **"Speichern und weiter"**.
|
||||
- **System**:
|
||||
- Speichert die Notiz und prüft die **`version`** des Kontakts.
|
||||
- Falls **Konflikt** (Version stimmt nicht überein **UND** Status wurde Wesentlich geändert):
|
||||
- Zeigt **Konfliktmeldung** an.
|
||||
- Lädt den **nächsten Kontakt** aus der Liste, der die **Filterkriterien erfüllt**.
|
||||
- Falls kein Kontakt mehr passt:
|
||||
- Zeigt Meldung: *"Keine weiteren Kontakte entsprechen den Filterkriterien."*
|
||||
- Kehrt zur **gefilterten Liste** zurück.
|
||||
|
||||
---
|
||||
|
||||
### **3.4. Dynamische Filterprüfung**
|
||||
|
||||
- **Beim Laden des nächsten Kontakts**:
|
||||
- **System prüft**:
|
||||
- Ist der Kontakt noch **nicht erledigt** (`is_erledigt() = False`)?
|
||||
- Liegt das **Wiedervorlage-Datum** noch im gefilterten Zeitraum?
|
||||
- Falls der Kontakt **nicht mehr passt**:
|
||||
- Lädt den **nächsten Kontakt**, der die Kriterien erfüllt.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## **📊 4. Beispiel-Daten**
|
||||
|
||||
---
|
||||
|
||||
### **4.1. Tags**
|
||||
|
||||
|
||||
| **Name** | **`is_erledigend`** | **`loest_wiedervorlage_aus`** | **`wiedervorlage_abstand_tage`** |
|
||||
| ------------------ | ------------------- | ----------------------------- | -------------------------------- |
|
||||
| Uninteressiert | `True` | `False` | `NULL` |
|
||||
| Wiedervorlage | `False` | `True` | `7` |
|
||||
| Nicht erreicht | `False` | `True` | `3` |
|
||||
| Rückruf vereinbart | `False` | `True` | `1` |
|
||||
| Falsche Nummer | `False` | `False` | `NULL` |
|
||||
| Erfolgreich | `False` | `False` | `NULL` |
|
||||
| Termin vereinbart | `False` | `True` | `14` |
|
||||
|
||||
|
||||
---
|
||||
|
||||
### **4.2. Gesprächsnotizen**
|
||||
|
||||
|
||||
| **Kontakt** | **Notiz** | **Tags** | **Wiedervorlage-Datum** | **Berechnung** |
|
||||
| -------------- | -------------------- | ------------------ | ----------------------- | ----------------------------- |
|
||||
| Max Mustermann | "Nicht erreicht" | Nicht erreicht | **20.09.2026** | Heute + 3 Tage (Tag-Abstand) |
|
||||
| Anna Schmidt | "Rückruf vereinbart" | Rückruf vereinbart | **21.09.2026** | Heute + 1 Tag (Tag-Abstand) |
|
||||
| Peter Müller | "Termin vereinbart" | Termin vereinbart | **04.10.2026** | Heute + 14 Tage (Tag-Abstand) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## **🔍 5. Zusammenfassung der Änderungen**
|
||||
|
||||
|
||||
| **Bereich** | **Änderung / Neuerung** |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| **Datenmodell** | **Kein `status`-Feld** in `Project` oder `Contact` → **dynamische Methoden `is_erledigt()`**. |
|
||||
| **`CallLog`** | **Einziges `wiedervorlage_datum`-Feld** (automatisch berechnet oder manuell überschrieben). |
|
||||
| **`Contact`** | **`version`-Feld** für optimistisches Locking. |
|
||||
| **Filter** | **Einzelne Tags** als Filteroption (Mehrfachauswahl). |
|
||||
| **Bearbeitungsmaske** | **Wiedervorlage-Datum** ist manuell änderbar, wird aber automatisch aus Tags berechnet. |
|
||||
| **Workflow-Logik** | Dynamische Filterprüfung beim Laden des nächsten Kontakts. |
|
||||
| **Konfliktbehandlung** | Optimistisches Locking mit `version`-Feld + Meldung bei **wesentlichen Änderungen**. |
|
||||
Reference in new issue
Block a user