Compare commits

...
14 Commits
Author SHA1 Message Date
gg a23884bf02 feat(crm): Aufgabenbeschreibung des Projekts prominent zugänglich machen
- Projektmaske: Zweck-Feld als Textarea (längere Eingaben), Label
  in "Aufgabenbeschreibung" umbenannt
- Tages-Workflow-Maske: Aufgabenbeschreibung als Tooltip auf dem
  Projekt-Label/-Namen der Kopfzeile, plus Icon-Button, der ein Modal
  mit Aufgabenbeschreibung, Verantwortlichem und Startdatum öffnet
- Server: Kontaktadresse.to_dict() liefert zusätzlich projekt_zweck

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-27 16:12:00 +02:00
gg 6e3cc7375d feat(workflow): Erledigt-Feld entfernen und Tages-Workflow-Maske überarbeiten
Das redundante 'erledigt'-Feld wird aus CallLog entfernt (Modell, API,
Client, Migration _db_V01_15 DROP COLUMN). Der Erledigt-Status ergibt
sich dynamisch aus den zugeordneten Tags (is_erledigend).

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

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-27 10:18:09 +02:00
gg 929664453e feat(form): flexContent-Option für Flex-Spalte im Formular-Inhalt
Der scrollbare Inhaltsbereich der Form kann bei Bedarf als Flex-Spalte
gerendert werden, damit Masken-Kinder den verbleibenden Platz füllen und
in sich scrollen können, statt die gesamte Maske scrollbar zu machen.
Wirkt nur zusammen mit stickyButtons (Default true).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-27 10:09:09 +02:00
gg 32afb35840 feat(workflow): Workflow-Buttons in die Standard-Button-Leiste der Form integrieren
Die Workflow-Buttons ("Nächster Kontakt", "Speichern und weiter") standen
bisher in einer eigenen Zeile über der Standard-Leiste (Zurücksetzen,
Abbrechen, Speichern). Das Form-Widget erhält dafür das neue optionale
Attribut extrabuttons, dessen Buttons rechts neben "Speichern" in die
Leiste eingereiht werden — sie profitieren damit auch vom Sticky-Verhalten
der Leiste.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-24 19:24:50 +02:00
gg 4d8febcc3f fix(select): Anzeige bei asynchron nachgeladenen Optionen nachziehen
Beim oninit war attrs.value teils keiner Option zuzuordnen, weil die
Optionen (z.B. Kundenliste) asynchron geladen werden — das Feld blieb
dauerhaft leer, da lastSyncedValue bereits gesetzt war und der Sync im
view nicht mehr griff. syncPending markiert die offene Synchronisierung
und wiederholt sie im view, bis eine passende Option existiert; Tippen
verwirft das Flag, damit die Nutzereingabe nicht überschrieben wird.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-24 19:14:16 +02:00
gg 3ba9b0a07f fix(treetable): Sticky-Header der Kopfzeile reparieren
Die schließende Klammer fehlte im calc() der Scroll-Container-Höhe, wodurch
die Höhe auf auto fiel und der Div nie selbst scrollte — Sticky bezog sich
damit auf keinen funktionierenden Scroll-Container. Zusätzlich das
overflow-y am table-Element entfernt (macht die Tabelle selbst zum
Scroll-Container und bricht Sticky) und w3-theme als eigener Hintergrund
auf die th gelegt, damit beim Scrollen keine Zeilen durchscheinen.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-24 19:02:40 +02:00
gg c96ed6e97d feat(workflow): Tages-Workflow-UI für Telefon-Akquise (Liste + Maske)
Server: neuer Endpunkt /xhr/workflow/list aggregiert pro Kontaktadresse
die letzte Gesprächsnotiz, das effektiv gültige Wiedervorlage-Datum
(jüngste Notiz mit Wert), den Erledigt-Status und eine priorisierte
Sortierung (überfällig → ohne Notizen → Rest). Filter: kunden_id, status
(offen/erledigt), wiedervorlage (heute/3tage/ueberfaellig/woche).
Sichtbarkeit wie kontaktadressen über projekt_users.

CallLog erfasst den Ersteller serverseitig (user_id, Migration _db_V01_14,
kein FK wegen separater Auth-DB); to_dict ergänzt mitarbeiter_name.
calllog_list unterstützt kontaktadresse_id als Filter.

Client: TagesWorkflowListe (Route /tagesworkflow, Filterleiste) und
TagesWorkflowMaske (Route /tagesworkflowkontakt, lesende Historie,
Notiz-Formular mit "Speichern und weiter"), Routes + Menüpunkt erster
Eintrag. berechneWiedervorlageDatum aus der Call_Log-Maske nach
models/tag.ts ausgelagert (gemeinsame Nutzung).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-24 18:56:23 +02:00
gg 981b28df03 feat(treetable): optionale Kopfzeile und abschaltbare Expand-Buttons
Neues Attribut header rendert eine sticky Spaltenüberschriftenzeile.
showExpandButtons=false blendet die Ein-/Ausklappen-Buttons aus; die
Topbar entfällt dann ganz, wenn auch keine Children oder Filter vorhanden
sind — der scrollbare Bereich nutzt in dem Fall die volle Höhe.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-24 18:54:42 +02:00
gg 3a6732e36b feat(select): Öffnen-Button für ungefilterte Liste und Sync externer value-Änderungen
Der neue Öffnen-Button blendet die Optionsliste ungefiltert ein, ohne das
Feld zu leeren. Der Anzeigetext wird zudem im view nachgezogen, wenn der
Eltern-View den value ändert (z.B. Zurücksetzen eines Filters).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-24 18:53:44 +02:00
gg 977b64292c feat(calllog): kontaktadresse_id als Pflicht-FK + is_erledigt()-Methoden
- Migration V01_13: Spalte kontaktadresse_id auf call_logs mit Backfill
  über die erste Kontaktadresse des Projekts; Commit vor dem
  Systeminfo-Update (Muster wie V01_08) gegen 'database is locked'.
- CallLog gehört zu genau einer Kontaktadresse (FK, Pflichtfeld);
  create/update validieren Existenz und Projekt-Zugehörigkeit.
- is_erledigt() auf Kontaktadresse (mind. ein erledigend-Tag reicht)
  und Projekt (False ohne Kontakte, sonst alle erledigt); beide in
  to_dict() serialisiert.
- Client: kontaktadresse_id in Notizmaske/Submit (required), Interfaces
  um is_erledigt erweitert.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-22 20:12:02 +02:00
gg 24b492c5c1 feat(runserver): Selbst-supervidierenden Dev-Server, -D-Parameter funktional
- Supervisor-Modus im Dev-Betrieb: Der Server läuft als Subprozess und
  wird nach einem Abbruch neu gestartet. Der Werkzeug-Reloader beendet
  sich sonst komplett, wenn der App-Prozess beim Start crasht — als
  Hauptprozess des Containers würde damit der gesamte Dev-Container
  stoppen. Der Supervisor importiert die App nicht und überlebt so
  jeden Anwendungsfehler.
- -D-Parameter ist jetzt funktional: Dev-Start direkt von der
  Befehlszeile ohne Devcontainer/Env-Variable. Debug-Entscheidung:
  -D ODER APP_DEBUG.
- approot.py: init_app(debug) reicht die Debug-Entscheidung durch,
  damit Server-Typ und JS-Bundle-Auswahl (.js/.min.js) konsistent
  bleiben. wsgi.py (init_app() ohne Argument) unverändert.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-22 19:12:27 +02:00
gg 4e721e9a47 feat(call-log): Wiedervorlage-Auto-Berechnung und Kunde/Projekt-Select
- Wiedervorlage-Datum wird automatisch aus ausgewählten Tags berechnet
  (Minimum von wiedervorlage_abstand_tage aller Tags mit
  loest_wiedervorlage_aus)
- Kunde/Projekt: Conditional Rendering — vorbelegt als Read-Only,
  nicht vorbelegt als abhängige Select-Widgets
- Live-Binding für datum und wiedervorlage_datum via oninput, um
  Mithril-Redraw-Override durch FormData-Auslesung zu verhindern

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-22 16:42:36 +02:00
gg 5a39c64880 docs(ai-context): Konzept für Telefon-Akquise-Workflow hinzugefügt
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-20 20:21:40 +02:00
gg 6f86bc9808 feat(tag): Workflow-Felder für Telefon-Akquise (is_erledigend, Wiedervorlage)
Tag-Modell um is_erledigend, loest_wiedervorlage_aus und
wiedervorlage_abstand_tage erweitert. Migration V01_12 idempotent
angelegt, Geschäftslogik mit Validierung, Client-Maske mit Controls
und Workflow-Badges. Farb-Default Grau bei fehlender Farbe, parent_id
im onchange direkt in currentTag übernehmen.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-20 20:20:52 +02:00
24 changed files with 2702 additions and 161 deletions

No files matched your search

+8 -2
View File
@@ -9,7 +9,7 @@ import os
login_manager = LoginManager() login_manager = LoginManager()
def init_app(): def init_app(debug: bool | None = None):
app = Flask(__name__) app = Flask(__name__)
config: Configuration = Configuration("/usr/opt/pythonapp/App_Server/root/config.ini") 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", "auth": "sqlite:////usr/var/pythonappdb/auth.db",
"appdb": "sqlite:////usr/var/pythonappdb/{}.db".format(_projektname) "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["PROJEKTNAME"] = config.app_projectname
app.config["APPID"] = config.app_appid app.config["APPID"] = config.app_appid
app.config["APPVERSION"] = config.clientapp_version app.config["APPVERSION"] = config.clientapp_version
-1
View File
@@ -257,7 +257,6 @@ def _seed_muster_calllogs():
log.notiz = rng.choice(notizvorlagen) log.notiz = rng.choice(notizvorlagen)
wiedervorlage_monat = monat + 1 if monat < 12 else 1 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.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.add(log)
session.commit() session.commit()
+90 -12
View File
@@ -148,15 +148,33 @@ class Projekt(dbapp.Model):
startdatum = Column(Date, nullable=True) startdatum = Column(Date, nullable=True)
kunde = relationship('Kunde') 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): def to_dict(self):
""" """
Liefert die Projektdaten als Dictionary. Die Tabellenspalten werden Liefert die Projektdaten als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; kunden_name wird zusätzlich aus der ü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 = columns2dict(self)
result['kunden_name'] = self.kunde.name if self.kunde else None result['kunden_name'] = self.kunde.name if self.kunde else None
result['is_erledigt'] = self.is_erledigt()
return result return result
@@ -220,34 +238,75 @@ class Kontaktadresse(dbapp.Model):
land = Column(String(80), nullable=True) land = Column(String(80), nullable=True)
notiz = Column(String(2000), 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): def to_dict(self):
""" """
Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; projekt_name wird zusätzlich aus der über columns2dict serialisiert; projekt_name, projekt_zweck und
Projekt-Relationship eingefügt. 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 = columns2dict(self)
result['projekt_name'] = self.projekt.projektname if self.projekt else None result['projekt_name'] = self.projekt.projektname if self.projekt else None
result['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 return result
class CallLog(dbapp.Model): class CallLog(dbapp.Model):
""" """
Gesprächsnotiz für ein Projekt. Jede Gesprächsnotiz ist genau einem Gesprächsnotiz für einen Kontakt. Jede Gesprächsnotiz ist genau einem
Projekt zugeordnet (projekt_id). Der Projektname (projekt_name) und Projekt (projekt_id) und genau einer Kontaktadresse (kontaktadresse_id)
der Kundenname (kunden_name) sind keine eigenen Spalten, sondern zugeordnet. Der Projektname (projekt_name) und der Kundenname
werden über die Projekt-Relationship abgeleitet und nur in to_dict() (kunden_name) sind keine eigenen Spalten, sondern werden über die
für die JSON-Serialisierung ergänzt. Projekt-Relationship abgeleitet und nur in to_dict() für die
JSON-Serialisierung ergänzt.
""" """
__tablename__ = 'call_logs' __tablename__ = 'call_logs'
__bind_key__ = BINDKEY __bind_key__ = BINDKEY
__table_args__ = ( __table_args__ = (
Index('ix_call_logs_projekt_id', 'projekt_id'), Index('ix_call_logs_projekt_id', 'projekt_id'),
Index('ix_call_logs_kontaktadresse_id', 'kontaktadresse_id'),
) )
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False) 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) datum = Column(Date, nullable=True)
uhrzeit_beginn = Column(String(10), nullable=True) uhrzeit_beginn = Column(String(10), nullable=True)
dauer = Column(String(10), nullable=True) dauer = Column(String(10), nullable=True)
@@ -256,9 +315,9 @@ class CallLog(dbapp.Model):
telefon = Column(String(100), nullable=True) telefon = Column(String(100), nullable=True)
notiz = Column(String(2000), nullable=True) notiz = Column(String(2000), nullable=True)
wiedervorlage_datum = Column(Date, nullable=True) wiedervorlage_datum = Column(Date, nullable=True)
erledigt = Column(Boolean, nullable=False, default=False)
projekt = relationship('Projekt') projekt = relationship('Projekt')
kontaktadresse = relationship('Kontaktadresse', back_populates='call_logs')
# n:m-Beziehung zu Tags über die Verknüpfungstabelle call_logs_tags. # n:m-Beziehung zu Tags über die Verknüpfungstabelle call_logs_tags.
# cascade='all, delete-orphan' stellt sicher, dass die Zuordnungen # cascade='all, delete-orphan' stellt sicher, dass die Zuordnungen
# automatisch gelöscht werden, wenn die Gesprächsnotiz gelöscht wird. # 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 Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; projekt_name und kunden_name werden über columns2dict serialisiert; projekt_name und kunden_name werden
zusätzlich aus der Projekt-Relationship eingefügt. Die zugeordneten zusätzlich aus der Projekt-Relationship eingefügt. mitarbeiter_name
Tags werden als Liste von Tag-Dictionaries unter 'tags' ergänzt. löst die user_id gegen die Auth-Datenbank auf (None bei Alt-Notizen
ohne erfassten Ersteller). Die zugeordneten Tags werden als Liste
von Tag-Dictionaries unter 'tags' ergänzt.
""" """
result = columns2dict(self) result = columns2dict(self)
if self.projekt: if self.projekt:
@@ -280,6 +341,14 @@ class CallLog(dbapp.Model):
else: else:
result['projekt_name'] = None result['projekt_name'] = None
result['kunden_name'] = None result['kunden_name'] = None
# Ersteller gegen die Auth-Datenbank auflösen (Muster wie
# crm.model.projekt_user). Alt-Notizen ohne user_id erhalten None.
result['mitarbeiter_name'] = None
if self.user_id is not None:
from auth.model import User
user = User.query.get(self.user_id)
if user is not None:
result['mitarbeiter_name'] = user.username
# Zugeordnete Tags serialisieren (nur is_parent=False sind zuordenbar). # Zugeordnete Tags serialisieren (nur is_parent=False sind zuordenbar).
result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel] result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel]
return result return result
@@ -325,6 +394,15 @@ class Tag(dbapp.Model):
color = Column(String(20), nullable=True) color = Column(String(20), nullable=True)
parent_id = Column(Integer, ForeignKey('tags.id'), nullable=True) parent_id = Column(Integer, ForeignKey('tags.id'), nullable=True)
is_parent = Column(Boolean, nullable=False, default=False) 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 # Selbst-Referenzierende Relationship: parent verweist auf den
# uebergeordneten Tag, children auf alle direkten Kinder. Die # uebergeordneten Tag, children auf alle direkten Kinder. Die
+52 -10
View File
@@ -12,7 +12,7 @@ dort über den Decorator authorisation_required.
from datetime import date from datetime import date
from db import dbapp 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 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. Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
Ist projekt_id gesetzt, werden nur die Notizen dieses Projekts Ist projekt_id gesetzt, werden nur die Notizen dieses Projekts
zurückgegeben (serverseitige Filterung). Ohne projekt_id werden zurückgegeben (serverseitige Filterung). Ist kontaktadresse_id
alle Notizen geliefert. gesetzt, werden nur die Notizen dieses Kontakts zurückgegeben —
beide Filter können kombiniert werden. Ohne Filter werden alle
Notizen geliefert.
Ist user_id gesetzt, werden nur Notizen von Projekten geliefert, denen Ist user_id gesetzt, werden nur Notizen von Projekten geliefert, denen
der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-Filterung). der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-Filterung).
@@ -86,6 +113,8 @@ def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
query = CallLog.query query = CallLog.query
if projekt_id is not None: if projekt_id is not None:
query = query.filter(CallLog.projekt_id == projekt_id) query = query.filter(CallLog.projekt_id == projekt_id)
if kontaktadresse_id is not None:
query = query.filter(CallLog.kontaktadresse_id == kontaktadresse_id)
if user_id is not None: if user_id is not None:
# Nur Notizen von Projekten, denen der Nutzer zugeordnet ist. # Nur Notizen von Projekten, denen der Nutzer zugeordnet ist.
query = query.filter( query = query.filter(
@@ -125,14 +154,19 @@ def get_call_log(call_log_id: int, user_id: int | None = None) -> CallLog:
return call_log return call_log
def create_call_log(data: dict) -> CallLog: def create_call_log(data: dict, user_id: int | None = None) -> CallLog:
""" """
Legt eine neue Gesprächsnotiz an. Die vom Client übergebene ID wird Legt eine neue Gesprächsnotiz an. Die vom Client übergebene ID wird
ignoriert; die ID wird serverseitig per Auto-Increment vergeben. ignoriert; die ID wird serverseitig per Auto-Increment vergeben.
- data: dict mit den Feldern projekt_id, datum, uhrzeit_beginn, dauer, - data: dict mit den Feldern projekt_id, kontaktadresse_id, datum,
betreff, name_ansprechpartner, telefon, notiz, uhrzeit_beginn, dauer, betreff, name_ansprechpartner, telefon,
wiedervorlage_datum, erledigt 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") projekt_id = data.get("projekt_id")
if projekt_id is None: if projekt_id is None:
@@ -146,8 +180,16 @@ def create_call_log(data: dict) -> CallLog:
"Das ausgewählte Projekt existiert nicht.", "Das ausgewählte Projekt existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request, htmlstatus=HtmlStatusCode.Bad_Request,
) )
kontaktadresse_id = _get_validierte_kontaktadresse_id(data, projekt_id)
call_log = CallLog() call_log = CallLog()
call_log.projekt_id = int(projekt_id) call_log.projekt_id = int(projekt_id)
call_log.kontaktadresse_id = kontaktadresse_id
# Ersteller erfassen: welcher Mitarbeiter den Anruf getätigt hat.
# Alt-Notizen (vor der user_id-Spalte angelegt) haben NULL; neue
# Notizen ohne Angabe erhalten ebenfalls NULL, bleiben aber die
# Ausnahme, da die Route immer current_user.id übergibt.
if user_id is not None:
call_log.user_id = int(user_id)
call_log.datum = _parse_date(data.get("datum")) call_log.datum = _parse_date(data.get("datum"))
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
call_log.dauer = (data.get("dauer") or "").strip() or None call_log.dauer = (data.get("dauer") or "").strip() or None
@@ -156,7 +198,6 @@ def create_call_log(data: dict) -> CallLog:
call_log.telefon = (data.get("telefon") or "").strip() or None call_log.telefon = (data.get("telefon") or "").strip() or None
call_log.notiz = (data.get("notiz") 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.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")) _set_call_log_tags(call_log, data.get("tag_ids"))
session.add(call_log) session.add(call_log)
session.commit() session.commit()
@@ -181,7 +222,9 @@ def update_call_log(data: dict) -> CallLog:
"Das ausgewählte Projekt existiert nicht.", "Das ausgewählte Projekt existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request, htmlstatus=HtmlStatusCode.Bad_Request,
) )
kontaktadresse_id = _get_validierte_kontaktadresse_id(data, projekt_id)
call_log.projekt_id = int(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.datum = _parse_date(data.get("datum"))
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
call_log.dauer = (data.get("dauer") or "").strip() or None call_log.dauer = (data.get("dauer") or "").strip() or None
@@ -190,7 +233,6 @@ def update_call_log(data: dict) -> CallLog:
call_log.telefon = (data.get("telefon") or "").strip() or None call_log.telefon = (data.get("telefon") or "").strip() or None
call_log.notiz = (data.get("notiz") 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.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")) _set_call_log_tags(call_log, data.get("tag_ids"))
session.commit() session.commit()
return call_log return call_log
+157
View File
@@ -243,6 +243,163 @@ def _db_V01_11():
session.commit() 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(): def checkupdates():
""" """
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
+44
View File
@@ -20,6 +20,48 @@ from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session 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]: def get_tags(parent_id: int | None = None) -> list[Tag]:
""" """
Liefert Tags, sortiert nach name (alphabetisch). 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.color = (data.get("color") or "").strip() or None
tag.parent_id = parent_id tag.parent_id = parent_id
tag.is_parent = bool(data.get("is_parent", False)) tag.is_parent = bool(data.get("is_parent", False))
_apply_workflow_felder(tag, data)
session.add(tag) session.add(tag)
session.commit() session.commit()
return tag return tag
@@ -124,6 +167,7 @@ def update_tag(data: dict) -> Tag:
tag.color = (data.get("color") or "").strip() or None tag.color = (data.get("color") or "").strip() or None
tag.parent_id = parent_id tag.parent_id = parent_id
tag.is_parent = bool(data.get("is_parent", False)) tag.is_parent = bool(data.get("is_parent", False))
_apply_workflow_felder(tag, data)
session.commit() session.commit()
return tag return tag
+203
View File
@@ -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
+35 -5
View File
@@ -27,6 +27,7 @@ from crm.model import projekt as projekte
from crm.model import projekt_user as projekt_users from crm.model import projekt_user as projekt_users
from crm.model import kontaktadresse as kontaktadressen from crm.model import kontaktadresse as kontaktadressen
from crm.model import call_log as call_logs from crm.model import call_log as call_logs
from crm.model import tages_workflow
from crm.model import tag as tags from crm.model import tag as tags
print("crm-routes") print("crm-routes")
@@ -332,11 +333,15 @@ def kontaktadresse_delete():
@app.get("/xhr/calllog/list") @app.get("/xhr/calllog/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER]) @authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def calllog_list(): def calllog_list():
"""Liefert Gesprächsnotizen als Liste. Optional nach projekt_id gefiltert. """Liefert Gesprächsnotizen als Liste. Optional nach projekt_id und/oder
Für Nicht-SysAdmin/Nicht-AppAdmin nur Notizen sichtbarer Projekte.""" kontaktadresse_id gefiltert. Für Nicht-SysAdmin/Nicht-AppAdmin nur
Notizen sichtbarer Projekte."""
projekt_id = request.args.get("projekt_id", type=int) projekt_id = request.args.get("projekt_id", type=int)
kontaktadresse_id = request.args.get("kontaktadresse_id", type=int)
user_id = _get_visible_user_id() user_id = _get_visible_user_id()
result = [l.to_dict() for l in call_logs.get_call_logs(projekt_id, user_id)] result = [l.to_dict() for l in call_logs.get_call_logs(
projekt_id, user_id, kontaktadresse_id
)]
return jsonify(result), 200 return jsonify(result), 200
@@ -354,9 +359,10 @@ def calllog_get():
@app.post("/xhr/calllog/create") @app.post("/xhr/calllog/create")
@authorisation_required([KUNDEN_MANAGER]) @authorisation_required([KUNDEN_MANAGER])
def calllog_create(): def calllog_create():
"""Legt eine neue Gesprächsnotiz an. ID wird serverseitig vergeben.""" """Legt eine neue Gesprächsnotiz an. ID wird serverseitig vergeben.
current_user.id wird als Ersteller (user_id) erfasst."""
_require_projekt_access(int(request.json["projekt_id"])) _require_projekt_access(int(request.json["projekt_id"]))
call_log = call_logs.create_call_log(request.json) call_log = call_logs.create_call_log(request.json, current_user.id)
return jsonify(call_log.to_dict()), 200 return jsonify(call_log.to_dict()), 200
@@ -381,6 +387,30 @@ def calllog_delete():
return jsonify({}), 200 return jsonify({}), 200
# --------------------------------------------------------------------------- #
# Tages-Workflow #
# --------------------------------------------------------------------------- #
@app.get("/xhr/workflow/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def workflow_list():
"""Tages-Workflow-Liste: Kontaktadressen mit letzter Notiz, Wiedervorlage
und dynamisch berechnetem Status. Optional gefiltert nach kunden_id,
status ('offen'/'erledigt') und wiedervorlage ('heute'/'3tage'/
'ueberfaellig'/'woche'). Für Nicht-SysAdmin/Nicht-AppAdmin nur
Kontaktadressen sichtbarer Projekte. Die Sortierung ist priorisiert:
überfällige Wiedervorlagen, dann Kontakte ohne Notizen, dann der Rest."""
user_id = _get_visible_user_id()
result = tages_workflow.get_tages_workflow_list(
user_id,
request.args.get("kunden_id", type=int),
request.args.get("status"),
request.args.get("wiedervorlage"),
)
return jsonify(result), 200
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Tag (hierarchische Taxonomie) # # Tag (hierarchische Taxonomie) #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+94 -19
View File
@@ -1,23 +1,98 @@
from waitress import serve import os
from werkzeug import serving import signal
from approot import init_app import subprocess
import sys
import time
def run_server(): def _is_debug_requested() -> bool:
try: """
if app.config["DEBUG"] is False: True, wenn -D übergeben wurde oder APP_DEBUG gesetzt ist.
_server = "waitress"
# serve(app, listen='nginx-proxy:8080') # Funktioniert aus unbekannten Gründen nicht. Der Hostname wird im Docker Container nicht korrekt aufgelöst -D erlaubt den Dev-Start direkt von der Befehlszeile — ohne Devcontainer
print(f"Server startet mit Server type: {_server}", flush=True) und ohne Env-Variable setzen zu müssen. APP_DEBUG steuert den
serve(app, listen='*:8080') Devcontainer-Betrieb (docker-compose.dev.yaml).
else: """
_server = "werkzeug" if "-D" in sys.argv[1:]:
print(f"Server startet mit Server type: {_server}", flush=True) return True
serving.run_simple("0.0.0.0", 8080, app, use_reloader=True) return os.environ.get("APP_DEBUG", "false").lower() in ("true", "1", "yes")
except Exception as ex:
print(str(ex))
print(f"Server gestartet. Server type: {_server}", flush=True) 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__': if __name__ == '__main__':
app = init_app() _debug = _is_debug_requested()
run_server() 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
View File
@@ -8,7 +8,7 @@ import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCall
import {iKunde, fetchKunden} from "../models/kunde" import {iKunde, fetchKunden} from "../models/kunde"
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt" import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse" import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
import {iTag, fetchTags} from "../models/tag" import {iTag, fetchTags, berechneWiedervorlageDatum} from "../models/tag"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard" import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
@@ -52,7 +52,6 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
telefon: "", telefon: "",
notiz: "", notiz: "",
wiedervorlage_datum: "", wiedervorlage_datum: "",
erledigt: false,
from_liste: "1" from_liste: "1"
} }
if (filterProjektId !== null) { if (filterProjektId !== null) {
@@ -105,7 +104,6 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
m("span", log.dauer + " min"), m("span", log.dauer + " min"),
m("span", log.betreff, ...tagBadges), m("span", log.betreff, ...tagBadges),
m("span", log.wiedervorlage_datum), 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")) 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: { let currentLog: {
id: number id: number
projekt_id: number projekt_id: number
kontaktadresse_id: number
projekt_name: string projekt_name: string
kunden_name: string kunden_name: string
datum: string datum: string
@@ -199,10 +198,10 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
telefon: string telefon: string
notiz: string notiz: string
wiedervorlage_datum: string wiedervorlage_datum: string
erledigt: boolean
} = { } = {
id: -1, id: -1,
projekt_id: -1, projekt_id: -1,
kontaktadresse_id: -1,
projekt_name: "", projekt_name: "",
kunden_name: "", kunden_name: "",
datum: "", datum: "",
@@ -212,8 +211,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
name_ansprechpartner: "", name_ansprechpartner: "",
telefon: "", telefon: "",
notiz: "", notiz: "",
wiedervorlage_datum: "", wiedervorlage_datum: ""
erledigt: false
} }
// Ist das Projekt voreingestellt (Liste wurde projektgefiltert)? // Ist das Projekt voreingestellt (Liste wurde projektgefiltert)?
let projektVoreingestellt: boolean = false let projektVoreingestellt: boolean = false
@@ -229,6 +227,9 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
let kundenListe: iKunde[] = [] let kundenListe: iKunde[] = []
let projektListe: iProjekt[] = [] let projektListe: iProjekt[] = []
let kontaktadressenListe: iKontaktadresse[] = [] 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). // Asynchron geladene Tag-Liste (nur zuordenbare, nicht is_parent).
let tagListe: iTag[] = [] let tagListe: iTag[] = []
// Aktuell ausgewählte Tag-IDs im Formular. // Aktuell ausgewählte Tag-IDs im Formular.
@@ -291,7 +292,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
fetchCallLogById(id).then((result) => { fetchCallLogById(id).then((result) => {
currentLog = result currentLog = result
projektVoreingestellt = true projektVoreingestellt = true
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== "" ansprechpartnerVoreingestellt = currentLog.kontaktadresse_id > 0
selectedTagIds = (result.tags || []).map((t: iTag) => t.id) selectedTagIds = (result.tags || []).map((t: iTag) => t.id)
}).catch((e) => showServerError(e)) }).catch((e) => showServerError(e))
} else { } else {
@@ -307,8 +308,10 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
currentLog.telefon = routeParams && routeParams.telefon ? routeParams.telefon : "" currentLog.telefon = routeParams && routeParams.telefon ? routeParams.telefon : ""
currentLog.notiz = routeParams && routeParams.notiz ? routeParams.notiz : "" currentLog.notiz = routeParams && routeParams.notiz ? routeParams.notiz : ""
currentLog.wiedervorlage_datum = routeParams && routeParams.wiedervorlage_datum ? routeParams.wiedervorlage_datum : "" currentLog.wiedervorlage_datum = routeParams && routeParams.wiedervorlage_datum ? routeParams.wiedervorlage_datum : ""
currentLog.erledigt = routeParams && (routeParams.erledigt === true || routeParams.erledigt === "true") // Kontaktadresse-ID aus den Route-Params übernehmen, wenn die
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== "" // 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. // Bei Neuanlage Startzeit merken und Dauer-Timer starten.
startZeit = new Date() startZeit = new Date()
timerId = window.setInterval(() => { timerId = window.setInterval(() => {
@@ -332,23 +335,12 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
guard.cleanup() guard.cleanup()
}, },
view: (vnode) => { 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) => { const kundenOptionen: iSelectOption[] = kundenListe.map((k: iKunde) => {
return {value: k.id, label: k.name} return {value: k.id, label: k.name}
}) })
// Projekt-Optionen sind vom gewählten Kunden abhängig. // Projekt-Optionen sind vom gewählten Kunden abhängig.
const projektOptionen: iSelectOption[] = projektListe 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) => { .map((p: iProjekt) => {
return {value: p.id, label: p.projektname} return {value: p.id, label: p.projektname}
}) })
@@ -370,18 +362,30 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
const formData = new FormData(form) const formData = new FormData(form)
const gespeichertesLog: iCallLog = { const gespeichertesLog: iCallLog = {
id: currentLog.id, 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: "", projekt_name: "",
kunden_name: "", kunden_name: "",
datum: (formData.get("datum") as string) || "", datum: currentLog.datum || "",
uhrzeit_beginn: (formData.get("uhrzeit_beginn") as string) || "", uhrzeit_beginn: (formData.get("uhrzeit_beginn") as string) || "",
dauer: (formData.get("dauer") as string) || "", dauer: (formData.get("dauer") as string) || "",
betreff: (formData.get("betreff") as string) || "", betreff: (formData.get("betreff") as string) || "",
name_ansprechpartner: (formData.get("name_ansprechpartner") as string) || "", name_ansprechpartner: (formData.get("name_ansprechpartner") as string) || "",
telefon: (formData.get("telefon") as string) || "", telefon: (formData.get("telefon") as string) || "",
notiz: (formData.get("notiz") as string) || "", notiz: (formData.get("notiz") as string) || "",
wiedervorlage_datum: (formData.get("wiedervorlage_datum") as string) || "", wiedervorlage_datum: currentLog.wiedervorlage_datum || "",
erledigt: formData.get("erledigt") === "on",
tag_ids: formData.getAll("tag_ids").map((v) => parseInt(v as string)).filter((n) => !isNaN(n)) tag_ids: formData.getAll("tag_ids").map((v) => parseInt(v as string)).filter((n) => !isNaN(n))
} }
if (currentLog.id === -1) { if (currentLog.id === -1) {
@@ -400,26 +404,70 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
} as iBreadcrumbBar), } as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}}, m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, guard.formAttrs(submit) as iForm, [ m(Form, guard.formAttrs(submit) as iForm, [
m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}), projektVoreingestellt
// Kunde und Projekt als Read-Only-Text, da voreingestellt. ? m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id})
m("label", "Kunde", : null,
m("input.w3-input.w3-border", { // Kunde und Projekt: vorbelegt als Read-Only-Text,
type: "text", // nicht vorbelegt als auswählbare Select-Widgets.
value: currentLog.kunden_name, projektVoreingestellt
readonly: true, ? [
style: {'margin-bottom': '5px'} m("label", "Kunde",
})), m("input.w3-input.w3-border", {
m("label", "Projekt", type: "text",
m("input.w3-input.w3-border", { value: currentLog.kunden_name,
type: "text", readonly: true,
value: currentLog.projekt_name, style: {'margin-bottom': '5px'}
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. // Datum, Startzeit und Dauer in einer Zeile.
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}}, m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
m("label", {style: {flex: '1'}}, "Datum", 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("label", {style: {flex: '1'}}, "Uhrzeit Beginn",
m(Input, {name: "uhrzeit_beginn", value: currentLog.uhrzeit_beginn, type: "time"} as iInput)), m(Input, {name: "uhrzeit_beginn", value: currentLog.uhrzeit_beginn, type: "time"} as iInput)),
m("label", {style: {flex: '1'}}, "Dauer (mm:ss)", m("label", {style: {flex: '1'}}, "Dauer (mm:ss)",
@@ -442,6 +490,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
name: "kontaktadresse_id", name: "kontaktadresse_id",
value: -1, value: -1,
options: kontaktadressenOptionen, options: kontaktadressenOptionen,
required: true,
onchanged: (kontaktadresseId: number) => { onchanged: (kontaktadresseId: number) => {
const ka = kontaktadressenListe.find((k: iKontaktadresse) => k.id === kontaktadresseId) const ka = kontaktadressenListe.find((k: iKontaktadresse) => k.id === kontaktadresseId)
if (ka) { if (ka) {
@@ -463,10 +512,29 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
placeholder: "kein Tag", placeholder: "kein Tag",
modalTitle: "Tags auswählen", modalTitle: "Tags auswählen",
editTooltip: "Tags bearbeiten", 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)), } as iMultiSelect)),
m("label", "Wiedervorlage Datum", m(Input, {name: "wiedervorlage_datum", value: currentLog.wiedervorlage_datum, type: "date"} as iInput)), m("label", "Wiedervorlage Datum", m(Input, {
m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt") name: "wiedervorlage_datum",
value: currentLog.wiedervorlage_datum,
type: "date",
oninput: (e: Event) => {
currentLog.wiedervorlage_datum = (e.target as HTMLInputElement).value
guard.markDirty()
}
} as iInput))
]) ])
) )
) )
+9 -2
View File
@@ -63,6 +63,8 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
id: -1, id: -1,
projekt_id: kontaktadresse.projekt_id, projekt_id: kontaktadresse.projekt_id,
projekt_name: kontaktadresse.projekt_name, projekt_name: kontaktadresse.projekt_name,
// Kontaktadresse als Pflichtfeld an die Notizmaske durchreichen
kontaktadresse_id: kontaktadresse.id,
datum: datum, datum: datum,
uhrzeit_beginn: uhrzeit, uhrzeit_beginn: uhrzeit,
dauer: "", dauer: "",
@@ -71,7 +73,6 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
telefon: kontaktadresse.telefon, telefon: kontaktadresse.telefon,
notiz: "", notiz: "",
wiedervorlage_datum: "", wiedervorlage_datum: "",
erledigt: false,
bc: serializeBc(chain) bc: serializeBc(chain)
}) })
} }
@@ -236,6 +237,10 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
id: currentKontaktadresse.id, id: currentKontaktadresse.id,
projekt_id: currentKontaktadresse.projekt_id, projekt_id: currentKontaktadresse.projekt_id,
projekt_name: "", projekt_name: "",
projekt_zweck: "",
// Server-seitig aus den Relationships abgeleitet;
// Platzhalter beim Senden.
kunden_name: "",
anrede: (formData.get("anrede") as string) || "", anrede: (formData.get("anrede") as string) || "",
name: (formData.get("name") as string) || "", name: (formData.get("name") as string) || "",
firma: (formData.get("firma") as string) || "", firma: (formData.get("firma") as string) || "",
@@ -246,7 +251,9 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
plz: (formData.get("plz") as string) || "", plz: (formData.get("plz") as string) || "",
ort: (formData.get("ort") as string) || "", ort: (formData.get("ort") as string) || "",
land: (formData.get("land") 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) { if (currentKontaktadresse.id === -1) {
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) createKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
+5 -4
View File
@@ -1,6 +1,6 @@
import m from "mithril" import m from "mithril"
import {Form, iForm} from "./widgets/form" 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 {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb" import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt" import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt"
@@ -85,7 +85,6 @@ export const projektliste: m.ClosureComponent = (vnode) => {
telefon: "", telefon: "",
notiz: "", notiz: "",
wiedervorlage_datum: "", wiedervorlage_datum: "",
erledigt: false,
bc: serializeBc(chain) bc: serializeBc(chain)
}) })
} }
@@ -239,7 +238,9 @@ export const Projekt: m.ClosureComponent = (vnode) => {
projektname: (formData.get("projektname") as string) || "", projektname: (formData.get("projektname") as string) || "",
zweck: (formData.get("zweck") as string) || "", zweck: (formData.get("zweck") as string) || "",
verantwortlicher: (formData.get("verantwortlicher") 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) { if (currentProjekt.id === -1) {
createProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) 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(Form, guard.formAttrs(submit) as iForm, [
m("input", {type: "hidden", name: "kunden_id", value: currentProjekt.kunden_id}), 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", "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", "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)) m("label", "Startdatum", m(Input, {name: "startdatum", value: currentProjekt.startdatum, type: "date"} as iInput))
]) ])
+973
View File
@@ -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
View File
@@ -77,6 +77,9 @@ export const Tag: m.ClosureComponent = () => {
color: "", color: "",
parent_id: null, parent_id: null,
is_parent: false, 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 // Liste aller Parent-Tags fuer die Eltern-Auswahl (nur Parent-Tags
@@ -109,6 +112,9 @@ export const Tag: m.ClosureComponent = () => {
color: routeParams.color || "", color: routeParams.color || "",
parent_id: routeParams.parent_id ? parseInt(routeParams.parent_id) : null, parent_id: routeParams.parent_id ? parseInt(routeParams.parent_id) : null,
is_parent: routeParams.is_parent ? routeParams.is_parent === "true" : false, 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 // 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) || "", name: (formData.get("name") as string) || "",
description: (formData.get("description") as string) || "", description: (formData.get("description") as string) || "",
color: currentTag.color || "", 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_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) { if (currentTag.id === -1) {
createTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() }) createTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
@@ -168,7 +177,11 @@ export const Tag: m.ClosureComponent = () => {
m("select.w3-select", { m("select.w3-select", {
name: "parent_id", name: "parent_id",
value: currentTag.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 --"), m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
allParentTags.map(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("label", "Farbe"),
m("div", {style: {display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px'}}, m("div", {style: {display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px'}},
colorPalette.map(color => colorPalette.map(color =>
@@ -279,6 +348,47 @@ export const TagList: m.ClosureComponent = () => {
const tagIcon = tag.is_parent ? "folder" : "label" const tagIcon = tag.is_parent ? "folder" : "label"
const tagTitle = tag.is_parent ? "Parent-Tag (nicht zuordenbar)" : "" 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 [ return [
m("span", m("span",
m("i.material-icons", { m("i.material-icons", {
@@ -291,7 +401,7 @@ export const TagList: m.ClosureComponent = () => {
}, tagIcon), }, tagIcon),
m("span", { m("span", {
style: { style: {
backgroundColor: tag.color, backgroundColor: tag.color || '#9e9e9e',
color: 'white', color: 'white',
padding: '2px 6px', padding: '2px 6px',
borderRadius: '3px', borderRadius: '3px',
@@ -300,7 +410,8 @@ export const TagList: m.ClosureComponent = () => {
marginRight: '8px' marginRight: '8px'
}, },
title: tagTitle title: tagTitle
}, tag.name) }, tag.name),
workflowBadges
), ),
m("span", tag.description), m("span", tag.description),
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}}, m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}},
+79 -18
View File
@@ -39,6 +39,20 @@ export interface iTreetable {
* <tr>-Zeile; die columnmapper-Inhalte brauchen dann kein eigenes * <tr>-Zeile; die columnmapper-Inhalte brauchen dann kein eigenes
* Selektions-Styling mehr. */ * Selektions-Styling mehr. */
isSelected?: (node: iTreenode) => boolean 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) => { export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
@@ -58,26 +72,73 @@ export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
const topHasSiblingsWithChildren = vnode.attrs.treenodes.some( const topHasSiblingsWithChildren = vnode.attrs.treenodes.some(
(tn: iTreenode) => tn.hasChildren (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'}}, return m("div.w3-border-theme", {style: {height: '99%', overflow: 'hidden', 'border-style': 'solid', 'border-width': '1px'}},
m("div.w3-bar.w3-theme", hatTopbar
m(TopbarButton, { ? m("div.w3-bar.w3-theme",
onclick: () => { showExpand
expandtoggle = true; changeTreenodes(vnode.attrs.treenodes, expandtoggle) ? [
}}, m(TopbarButton, {
m("i.material-icons", "unfold_more") onclick: () => {
), expandtoggle = true; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
m(TopbarButton, { }},
onclick: () => { m("i.material-icons", "unfold_more")
expandtoggle = false; changeTreenodes(vnode.attrs.treenodes, expandtoggle) ),
}}, m(TopbarButton, {
m("i.material-icons", "unfold_less") onclick: () => {
), expandtoggle = false; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
vnode.children, }},
(vnode.attrs.filter != undefined) ? m(SearchField, {setSearchvalue: setSearchvalue} as iSearchfield) : null 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: '100%'}},
m("div", {style: {height: 'calc(100% - 32px', overflow: 'auto', width: '100%'}}, // Ohne Topbar nutzt der scrollbare Bereich die volle Höhe.
m("table.w3-table", {style: {'overflow-y': 'auto'}}, // 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) => { vnode.attrs.treenodes.map((tn:iTreenode, z) => {
if (vnode.attrs.filter == undefined || (vnode.attrs.filter != undefined && vnode.attrs.filter(tn, searchvalue) == true)) { if (vnode.attrs.filter == undefined || (vnode.attrs.filter != undefined && vnode.attrs.filter(tn, searchvalue) == true)) {
return m(Treerow, { return m(Treerow, {
+29 -1
View File
@@ -19,6 +19,21 @@ export interface iForm {
* der Form synchron zu halten und so den Navigations-Guard * der Form synchron zu halten und so den Navigations-Guard
* (dirtyguard.ts) zu speisen. */ * (dirtyguard.ts) zu speisen. */
onDirtyChange?: (dirty: boolean) => void 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) => { 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'}, 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", {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") 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 // Container, die Buttons bleiben am unteren Rand fixiert. Das
// umschließende form-Element wird zum flex-Container, damit das // umschließende form-Element wird zum flex-Container, damit das
// Layout auch bei Fenstergrößenänderung stabil bleibt. // 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) { if (stickyButtons) {
return m("form", { return m("form", {
style: {display: 'flex', 'flex-direction': 'column', height: '100%'}, style: {display: 'flex', 'flex-direction': 'column', height: '100%'},
@@ -133,7 +161,7 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
} }
}, },
// Scrollbarer Formular-Inhalt. // Scrollbarer Formular-Inhalt.
m("div", {style: {flex: '1', 'overflow-y': 'auto', 'padding-right': '8px'}}, m("div", {style: contentStyle},
vnode.children vnode.children
), ),
// Fixierter Button-Bereich. // Fixierter Button-Bereich.
+115 -33
View File
@@ -47,6 +47,20 @@ export const Select: m.ClosureComponent<iSelect> = () => {
let highlightIndex: number = -1 let highlightIndex: number = -1
// Referenz auf das Eingabefeld für Fokus nach Clear. // Referenz auf das Eingabefeld für Fokus nach Clear.
let inputfield: HTMLInputElement | null = null 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. * 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)) 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. * Wählt eine Option aus: value setzen, Suchtext = label, Dropdown zu.
*/ */
const selectOption = (option: iSelectOption, vnode: m.Vnode<iSelect>) => { const selectOption = (option: iSelectOption, vnode: m.Vnode<iSelect>) => {
vnode.attrs.value = option.value vnode.attrs.value = option.value
search = option.label syncSearchZumValue(option.value, vnode.attrs.options)
isOpen = false isOpen = false
showAll = false
highlightIndex = -1 highlightIndex = -1
if (vnode.attrs.onchanged != undefined) { if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(option.value) vnode.attrs.onchanged(option.value)
@@ -86,22 +116,36 @@ export const Select: m.ClosureComponent<iSelect> = () => {
return { return {
oninit: (vnode) => { oninit: (vnode) => {
// Aktuelles label zum value ermitteln (für die Anzeige im Feld). // Aktuelles label zum value ermitteln (für die Anzeige im Feld).
const initialOption = vnode.attrs.options.find((o: iSelectOption) => o.value === vnode.attrs.value) syncSearchZumValue(vnode.attrs.value, vnode.attrs.options)
search = initialOption ? initialOption.label : ""
}, },
view: (vnode) => { 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 isReadonly = vnode.attrs.readonly === true
const filteredOptions = getFilteredOptions(vnode.attrs.options) 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 = { const inputAttrs: any = {
type: "text", type: "text",
value: search, value: search,
style: {'margin-bottom': '5px', width: '100%', 'padding-right': '32px'}, style: {'margin-bottom': '5px', width: '100%', 'padding-right': '58px'},
oncreate: (vnode: m.VnodeDOM) => { oncreate: (vnode: m.VnodeDOM) => {
inputfield = vnode.dom as HTMLInputElement inputfield = vnode.dom as HTMLInputElement
}, },
oninput: (e: Event) => { oninput: (e: Event) => {
if (isReadonly) { return } if (isReadonly) { return }
search = (e.target as HTMLInputElement).value 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 isOpen = true
highlightIndex = filteredOptions.length > 0 ? 0 : -1 highlightIndex = filteredOptions.length > 0 ? 0 : -1
}, },
@@ -115,22 +159,23 @@ export const Select: m.ClosureComponent<iSelect> = () => {
}, },
onkeydown: (e: KeyboardEvent) => { onkeydown: (e: KeyboardEvent) => {
if (isReadonly) { return } if (isReadonly) { return }
if (filteredOptions.length === 0) { return } if (angezeigteOptionen.length === 0) { return }
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault() e.preventDefault()
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return } if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
highlightIndex = (highlightIndex + 1) % filteredOptions.length highlightIndex = (highlightIndex + 1) % angezeigteOptionen.length
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
e.preventDefault() e.preventDefault()
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return } 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") { } else if (e.key === "Enter") {
if (isOpen && highlightIndex >= 0 && highlightIndex < filteredOptions.length) { if (isOpen && highlightIndex >= 0 && highlightIndex < angezeigteOptionen.length) {
e.preventDefault() e.preventDefault()
selectOption(filteredOptions[highlightIndex], vnode) selectOption(angezeigteOptionen[highlightIndex], vnode)
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
isOpen = false isOpen = false
showAll = false
highlightIndex = -1 highlightIndex = -1
} }
} }
@@ -145,31 +190,68 @@ export const Select: m.ClosureComponent<iSelect> = () => {
return m("div", {style: {width: '100%', position: 'relative'}}, return m("div", {style: {width: '100%', position: 'relative'}},
// Verstecktes Feld für die Formularübergabe (echter value). // Verstecktes Feld für die Formularübergabe (echter value).
m("input", {type: "hidden", name: vnode.attrs.name, value: vnode.attrs.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("div", {style: {position: 'relative', width: '100%'}},
m("input.w3-input.w3-border", inputAttrs), 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 !isReadonly
? m("button.w3-button", { ? [
style: { // Öffnen-Button: blendet die Optionliste ungefiltert
position: 'absolute', // ein, ohne das Feld zu leeren — der aktuelle Wert
right: '4px', // bleibt stehen, bis eine neue Option gewählt wird.
top: '50%', m("button.w3-button", {
transform: 'translateY(-50%)', type: "button",
'min-width': '24px', title: "Liste öffnen (alle Optionen anzeigen)",
height: '24px', style: {
padding: '2px', position: 'absolute',
'line-height': '1' right: '28px',
}, top: '50%',
onclick: () => { transform: 'translateY(-50%)',
search = "" 'min-width': '24px',
isOpen = true height: '24px',
highlightIndex = filteredOptions.length > 0 ? 0 : -1 padding: '2px',
if (inputfield) { inputfield.focus() } 'line-height': '1'
} },
}, m("i.material-icons", {style: {'font-size': '20px'}}, "clear")) // 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 : null
), ),
// Gefilterte, aufklappbare Optionliste. Eigene Positionierung, // Gefilterte, aufklappbare Optionliste. Eigene Positionierung,
@@ -186,9 +268,9 @@ export const Select: m.ClosureComponent<iSelect> = () => {
'box-shadow': '2px 2px 4px darkgrey' 'box-shadow': '2px 2px 4px darkgrey'
} }
}, },
filteredOptions.length === 0 angezeigteOptionen.length === 0
? m("div.w3-padding-small", "Keine Treffer") ? 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", { m("a.w3-bar-item.w3-button.w3-text-left", {
style: {whiteSpace: 'normal'}, style: {whiteSpace: 'normal'},
class: index === highlightIndex ? "w3-theme" : "w3-hover-light-grey", class: index === highlightIndex ? "w3-theme" : "w3-hover-light-grey",
+5 -1
View File
@@ -11,6 +11,7 @@ import { Call_Log, call_log_liste } from "./Components/call_log"
import { Kontakt, kontaktliste } from "./Components/kontakt" import { Kontakt, kontaktliste } from "./Components/kontakt"
import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse" import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse"
import { Tag, TagList } from "./Components/tags" import { Tag, TagList } from "./Components/tags"
import { TagesWorkflowListe, TagesWorkflowMaske } from "./Components/tages_workflow"
import { guardedNavigate } from "./Components/widgets/dirtyguard" import { guardedNavigate } from "./Components/widgets/dirtyguard"
@@ -133,6 +134,7 @@ const menu: m.ClosureComponent = () => {
}, },
view: (vnode) => { view: (vnode) => {
return m(menubar, [ return m(menubar, [
menuItem("/tagesworkflow", "Tages-Workflow"),
menuItem("/kundenliste", "Kunden"), menuItem("/kundenliste", "Kunden"),
menuItem("/projektliste", "Projekte"), menuItem("/projektliste", "Projekte"),
menuItem("/calllogliste", "Gesprächsnotizen"), menuItem("/calllogliste", "Gesprächsnotizen"),
@@ -214,6 +216,8 @@ m.route(gridMountpoints.main, "/kundenliste", {
"/kontaktadresseliste": kontaktadresseliste, "/kontaktadresseliste": kontaktadresseliste,
"/kontaktadresse": Kontaktadresse, "/kontaktadresse": Kontaktadresse,
"/taglist": TagList, "/taglist": TagList,
"/tag": Tag "/tag": Tag,
"/tagesworkflow": TagesWorkflowListe,
"/tagesworkflowkontakt": TagesWorkflowMaske
}) })
+28 -1
View File
@@ -6,14 +6,29 @@ import {iTag} from "./tag"
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case). * Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden * projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt. * serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
* kontaktadresse_id ist Pflichtfeld: Jede Notiz gehört zu genau einer
* Kontaktadresse (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) * tags (vom Server beim Lesen) und tag_ids (vom Client beim Schreiben)
* steuern die n:m-Beziehung zu den Tags. * steuern die n:m-Beziehung zu den Tags.
*/ */
export interface iCallLog { export interface iCallLog {
id: number id: number
projekt_id: number projekt_id: number
kontaktadresse_id: number
/** Ersteller (Mitarbeiter), serverseitig gesetzt; null bei Alt-Notizen. */
user_id: number | null
projekt_name: string projekt_name: string
kunden_name: string kunden_name: string
/** Aufgelöster Username des Erstellers; null bei Alt-Notizen. */
mitarbeiter_name: string | null
datum: string datum: string
uhrzeit_beginn: string uhrzeit_beginn: string
dauer: string dauer: string
@@ -22,7 +37,6 @@ export interface iCallLog {
telefon: string telefon: string
notiz: string notiz: string
wiedervorlage_datum: string wiedervorlage_datum: string
erledigt: boolean
/** Vom Server gelieferte zugeordnete Tags (nur beim Lesen). */ /** Vom Server gelieferte zugeordnete Tags (nur beim Lesen). */
tags?: iTag[] tags?: iTag[]
/** Vom Client zu übermittelnde Tag-IDs (nur beim Schreiben). */ /** 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) }).then((result) => result as iCallLog)
} }
/**
* Lädt die Gesprächsnotizen eines einzelnen Kontakts (Kontaktadresse)
* vom Server — serverseitig nach kontaktadresse_id gefiltert. Wird für
* die Historie in der Tages-Workflow-Maske genutzt.
*/
export function fetchCallLogsByKontaktadresse(kontaktadresseId: number): Promise<iCallLog[]> {
return m.request({
method: "GET",
url: CALLLOG_REST_BASE + "/list",
params: {kontaktadresse_id: kontaktadresseId}
}).then((result) => result as iCallLog[])
}
/** /**
* Legt eine neue Gesprächsnotiz serverseitig an. * Legt eine neue Gesprächsnotiz serverseitig an.
*/ */
+9 -2
View File
@@ -4,13 +4,18 @@ import m from "mithril"
* Datenmodell für eine Kontaktadresse (Akquise-Ziel innerhalb eines Projekts). * Datenmodell für eine Kontaktadresse (Akquise-Ziel innerhalb eines Projekts).
* Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projekt_id). * Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projekt_id).
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case). * Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* projekt_name ist keine ORM-Spalte, sondern wird serverseitig aus der * projekt_name, projekt_zweck und kunden_name sind keine ORM-Spalten, sondern
* Projekt-Relationship abgeleitet und im JSON ergänzt. * 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 { export interface iKontaktadresse {
id: number id: number
projekt_id: number projekt_id: number
projekt_name: string projekt_name: string
projekt_zweck: string
kunden_name: string
anrede: string anrede: string
name: string name: string
firma: string firma: string
@@ -22,6 +27,8 @@ export interface iKontaktadresse {
ort: string ort: string
land: string land: string
notiz: string notiz: string
/** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */
is_erledigt: boolean
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
+5 -1
View File
@@ -4,7 +4,9 @@ import m from "mithril"
* Datenmodell für ein Projekt. * Datenmodell für ein Projekt.
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case). * Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* kunden_name ist keine ORM-Spalte, sondern wird serverseitig aus der * 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 { export interface iProjekt {
id: number id: number
@@ -14,6 +16,8 @@ export interface iProjekt {
zweck: string zweck: string
verantwortlicher: string verantwortlicher: string
startdatum: string startdatum: string
/** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */
is_erledigt: boolean
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
+35
View File
@@ -12,6 +12,12 @@ export interface iTag {
color: string color: string
parent_id: number | null parent_id: number | null
is_parent: boolean 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} params: {id: id}
}).then(() => undefined) }).then(() => undefined)
} }
/* ------------------------------------------------------------------ */
/* Fachliche Hilfsfunktionen */
/* ------------------------------------------------------------------ */
/**
* Berechnet das Wiedervorlage-Datum automatisch aus den ausgewählten
* Tags. Basis ist das übergebene Basisdatum (z.B. das Datum der Notiz
* oder heute). Aus allen ausgewählten Tags mit
* loest_wiedervorlage_aus=True wird das Minimum von
* wiedervorlage_abstand_tage ermittelt und zum Basisdatum addiert.
* Liefert einen leeren String, wenn kein Tag mit
* loest_wiedervorlage_aus=True ausgewählt ist.
*
* Wird von der Call_Log-Maske und der Tages-Workflow-Maske genutzt,
* damit die Berechnungslogik nur an einer Stelle gepflegt wird.
*/
export function berechneWiedervorlageDatum(tagIds: number[],
tagListe: iTag[],
basisdatum: string): string {
const wvTags = tagListe.filter((t: iTag) =>
tagIds.includes(t.id) && t.loest_wiedervorlage_aus && t.wiedervorlage_abstand_tage != null
)
if (wvTags.length === 0) return ""
const minAbstand = Math.min(...wvTags.map(t => t.wiedervorlage_abstand_tage as number))
const basisDate = new Date(basisdatum)
basisDate.setDate(basisDate.getDate() + minAbstand)
return basisDate.toISOString().split('T')[0]
}
+84
View File
@@ -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 &amp; 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 &amp; 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**. |