Compare commits

...
28 Commits
Author SHA1 Message Date
gg bc9b337696 feat(crm): zentrale TagBadge-Komponente und Ergänzungen zur Workflow-Maske
- Position des Ansprechpartners in der Kopfzeile der Tagesworkflow-Maske anzeigen
- Neue TagBadge-Komponente (Farbchip, Name, Workflow-Icons: erledigend/
  Wiedervorlage) ersetzt die duplizierte Badge-Darstellung in Notiz-Liste,
  Workflow-Liste, Historie und Tag-Liste
- MultiSelect: optionale content-Option rendert ein fertiges Badge des
  Aufrufers statt Farbpunkt + Label im Auswahl-Modal; Suchfeld wird beim
  Öffnen des Modals fokussiert
- TagBadge-Attribut heißt tagData statt tag, da Mithril attrs-Objekte mit
  tag-Property als Vnode-Kinder behandelt (führte zu 'color' undefined)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-27 18:37:49 +02:00
ggandMistral Vibe 2ff4272ecd docs: TODO zur Klärung des Verantwortlichen-Felds im Projekt ergänzen
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-27 17:15:35 +02:00
gg 70f6101761 feat(cli): init-db setzt CRM-Geschäftsdaten zurück und legt neue Musterdaten an
init-db löscht vor dem Seeding sämtliche CRM-Geschäftsdaten (Kunden,
Kontakte, Projekte, Kontaktadressen, Gesprächsnotizen, Projekt-Zuordnungen)
in FK-sicherer Reihenfolge und legt danach die Musterdaten komplett neu an.
Die Tag-Taxonomie bleibt als Stammdaten erhalten.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-27 17:15:31 +02:00
gg f6588dec79 feat(tags): erledigende Tags mit rotem Stop-Hand-Symbol anzeigen
Statt des grünen Häkchens (check_circle) wird für erledigende Tags nun
die Stopp-Hand (back_hand) in Rot angezeigt.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-27 17:15:28 +02:00
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
gg 1d8201f46a fix: Entferne Kommentar aus .gitignore für App_Server/data 2026-09-19 19:02:59 +02:00
gg d7e5c014a8 Merge branch 'main' of https://git.everdc.eu/chmln-app-dev/kbcrm 2026-09-19 18:59:14 +02:00
gg a67921ec74 feat(isdirty): Navigations-Guard gegen ungespeichertes Verlassen von Bearbeitungsmasken
Zentraler DirtyGuard (dirtyguard.ts) mit DirtyMaskHelper-Klasse und
3-Button-Bestätigungs-Modal (Speichern/Verwerden/Abbrechen). Form meldet
Dirty-State via onDirtyChange nach oben; input-Event ergänzt change für
frühzeitiges Dirty-Marking. Alle App-Navigationen (Breadcrumb, Menü,
In-Masken-Buttons, Abbrechen) laufen über guardedNavigate. Auto-Redraw
bei dirty per e.redraw=false unterbunden, damit DOM-Eingaben erhalten
bleiben (Input-Widget hat kein Live-Binding).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 18:21:58 +02:00
gg fd30049a32 feat(nav): Breadcrumb-Navigation für Hierarchie-Aufrufkette
Neue BreadcrumbBar-Widget-Komponente (breadcrumb.ts) zeigt die
Aufrufkette als anklickbare Leiste. Der Route-Param bc (JSON-String)
transportiert die Vorgänger-Ansichten durch alle Listen und Masken.
Jede Ansicht hängt beim Weiternavigieren ihren eigenen Crumb an die
Kette an; Masken reichen bc beim Speichern/Abbrechen zurück.
Erfasst: kunde, projekt, kontaktadresse, call_log, kontakt.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 16:16:49 +02:00
gg f1e5938ae1 fix: Rückkehr-Kontext für Gesprächsnotizenliste erhalten
Die Bearbeitungsmaske kehrt beim Speichern/Abbrechen nicht mehr mit dem
Projekt der bearbeiteten Notiz zurück, sondern mit dem Filter der Liste,
aus der sie geöffnet wurde. Liste reicht from_liste/return_projekt_id an
die Maske durch; Maske wertet diesen Kontext in goBack() aus.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 15:38:23 +02:00
gg d30adf1579 feat(calllog): Tags einer Gesprächsnotiz zuordnen (n:m)
Backend:
- Neues CallLogTag-Model (Tabelle call_logs_tags) mit Relationship
  auf CallLog (cascade delete-orphan); to_dict liefert 'tags'.
- Migration _db_V01_11 (idempotent via create_all).
- _set_call_log_tags in create/update_call_log: ersetzt
  Tag-Zuordnungen, filtert is_parent-Tags, eliminiert Duplikate.
- _parse_date wandelt ISO-Strings in date-Objekte (fixt autoflush-
  Crash bei Date-Spalten).

Frontend:
- iCallLog um tags (Lesen) und tag_ids (Schreiben) ergänzt.
- Neues generisches MultiSelect-Widget (widgets/select.ts):
  Badges + Edit-Button, inline Modal mit Suchfeld, Checkbox-Liste,
  OK-Button; konfigurierbare Texte (placeholder, modalTitle,
  editTooltip).
- Call_Log-Formular: Tag-Auswahl via MultiSelect, Submit via
  FormData.getAll; Tag-Badges in der Listenansicht hinter dem Betreff.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 15:12:36 +02:00
gg 5abcb2d96c feat(tags): Backend mit REST-API und Client-Anbindung fuer hierarchische Tags
- SQLAlchemy-Modell Tag mit Selbst-Referenz (parent_id), is_parent-Flag
- DB-Migration V01.10 (idempotent via create_all)
- Geschaeftslogik tag.py: CRUD, Hierarchie-Aufbau, kaskadierendes Loeschen,
  Zirkelbezug-Pruefung
- REST-Endpunkte /xhr/tag/*: Lesen fuer Mitarbeiter, Schreiben fuer AppAdmin
- Client-Modell tag.ts mit REST-Funktionen
- Frontend tags.ts auf REST-API umgestellt, hartcodierte Daten entfernt,
  Loesch-Dialog mit kaskadierender Bestaetigung

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 14:08:34 +02:00
gg 675d7775c1 feat: oninput-Callback zum Input-Widget hinzugefügt
Ermöglicht ein Live-Binding der Eingabewerte, damit Eingaben bei einem
Redraw (z.B. durch interaktive Elemente außerhalb des Inputs) nicht
verloren gehen.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 14:07:30 +02:00
gg b44ea806ef feat(tags): is_parent-Flag zur Unterscheidung von Gruppierungs-Tags und zuordenbaren Tags
Parent-Tags (is_parent=true) dienen als Gruppierungsknoten und können nicht
Gesprächsnotizen zugeordnet werden. Echte Tags (is_parent=false) sind
zuordenbar. Die Bearbeitungsmaske bietet eine Checkbox für das Flag, die
Eltern-Auswahl ist auf Parent-Tags beschränkt. Die Tag-Liste zeigt
Parent-Tags mit Folder-Icon, echte Tags mit Label-Icon. Die Suche filtert
den gesamten Teilbaum — nur Knoten mit Self-Match oder einem matchenden
Nachfahren werden angezeigt.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 10:40:39 +02:00
ggandMistral Vibe 3361ef16dd feat: Treetable filter auf Kind-Knoten anwenden
Der filter-Callback wird jetzt auch auf Kind-Knoten angewendet, nicht nur auf Top-Level-Nodes. Dadurch lässt sich bei hierarchischen Daten (z.B. Tag-Baum) die Suche auf den gesamten Teilbaum verfeinern — nur Knoten, die selbst matchen oder einen matchenden Nachfahren haben, werden gerendert.

Abwärtskompatibel: Aufrufer ohne filter-Attribut sind unaffected.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 10:39:11 +02:00
32 changed files with 5129 additions and 964 deletions

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ node_modules/
tsout/
kbp_crm*.js
kbcrm/App_Server/data
#App_Server/data/
App_Server/data/
oidc-test/
.vibe/
.vibe-global/
+8 -2
View File
@@ -9,7 +9,7 @@ import os
login_manager = LoginManager()
def init_app():
def init_app(debug: bool | None = None):
app = Flask(__name__)
config: Configuration = Configuration("/usr/opt/pythonapp/App_Server/root/config.ini")
@@ -20,7 +20,13 @@ def init_app():
"auth": "sqlite:////usr/var/pythonappdb/auth.db",
"appdb": "sqlite:////usr/var/pythonappdb/{}.db".format(_projektname)
}
app.config["DEBUG"] = os.environ.get("APP_DEBUG", "false").lower() in ("true", "1", "yes")
# Dev-Modus: CLI-Parameter -D (via runserver) oder APP_DEBUG-Env.
# debug=None = wie bisher allein über die Env-Variable entscheiden
# (unverändertes Verhalten für wsgi.py).
if debug is None:
app.config["DEBUG"] = os.environ.get("APP_DEBUG", "false").lower() in ("true", "1", "yes")
else:
app.config["DEBUG"] = debug
app.config["PROJEKTNAME"] = config.app_projectname
app.config["APPID"] = config.app_appid
app.config["APPVERSION"] = config.clientapp_version
+568 -167
View File
@@ -2,17 +2,23 @@
Flask CLI-Befehle zur Datenbank-Initialisierung und zum Reset.
Befehle (aufgerufen via Flask CLI):
flask --app wsgi:app init-db -- Schema + Musterdaten anlegen
flask --app wsgi:app init-db -- Schema anlegen/migrieren + Musterdaten
flask --app wsgi:app reset-db -- alles bis auf SysAdmin löschen
init-db ist idempotent: bereits vorhandene Rollen/Kunden werden nicht
doppelt angelegt. reset-db fragt standardmäßig zur Bestätigung; mit --yes
wird die Abfrage übersprungen.
init-db legt das Schema an bzw. migriert es und setzt danach die
CRM-Geschäftsdaten zurück: Sämtliche Kunden samt aller abhängigen Daten
(Kontakte, Projekte, Kontaktadressen, Gesprächsnotizen, Projekt-Zuordnungen)
werden gelöscht und die Musterdaten werden komplett neu angelegt. Die
Tag-Taxonomie (tags) bleibt erhalten, da es sich um Stammdaten handelt.
reset-db fragt standardmäßig zur Bestätigung; mit --yes wird die Abfrage
übersprungen.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-02
:version: 1.0
:version: 1.1
"""
from datetime import date, timedelta
import click
from flask import Flask
@@ -33,10 +39,10 @@ def register_cli_commands(app: Flask):
click.echo("Initialisiere kbp_crm.db ...")
from crm.model import checkdb as crm_checkdb
crm_checkdb.checkupdates()
_seed_muster_kunden()
_seed_muster_projekte()
_seed_muster_kontaktadressen()
_seed_muster_calllogs()
click.echo("Lösche vorhandene CRM-Geschäftsdaten ...")
_purge_crm_daten()
click.echo("Lege Musterdaten an ...")
_seed_musterdaten()
click.echo("Fertig.")
@@ -83,182 +89,577 @@ def _seed_app_roles():
session.commit()
def _seed_muster_kunden():
def _purge_crm_daten():
"""
Legt die drei Muster-Kunden an, falls die kunden-Tabelle noch leer ist.
Löscht sämtliche CRM-Geschäftsdaten vor dem Neuanlegen der Musterdaten.
Die Löschreihenfolge beachtet die Fremdschlüsselbeziehungen: erst die
n:m-Verknüpfungen der Gesprächsnotizen, dann die Notizen selbst, danach
die Kontaktadressen, die Projekt-Nutzer-Zuordnungen, die Projekte, die
Kontakte und zuletzt die Kunden. Die Tag-Taxonomie (tags) bleibt
erhalten, da Tags Stammdaten und keine Musterdaten sind.
"""
from crm.model import Kunde
from crm.model import (
CallLogTag, CallLog, Kontaktadresse, ProjektUser,
Projekt, Kontakt, Kunde,
)
session = dbapp.session
if session.query(Kunde).count() > 0:
return
muster = [
Kunde(name="Musterfirma GmbH", strasse="Musterstraße 1",
plz="12345", ort="Musterstadt", land="Deutschland"),
Kunde(name="Beispiel AG", strasse="Beispielweg 2",
plz="54321", ort="Beispielstadt", land="Deutschland"),
Kunde(name="Test Unternehmen", strasse="Testallee 3",
plz="67890", ort="Testdorf", land="Österreich"),
]
for kunde in muster:
session.add(kunde)
session.query(CallLogTag).delete(synchronize_session=False)
session.query(CallLog).delete(synchronize_session=False)
session.query(Kontaktadresse).delete(synchronize_session=False)
session.query(ProjektUser).delete(synchronize_session=False)
session.query(Projekt).delete(synchronize_session=False)
session.query(Kontakt).delete(synchronize_session=False)
session.query(Kunde).delete(synchronize_session=False)
session.commit()
def _seed_muster_projekte():
# ---------------------------------------------------------------------------
# Musterdaten: drei Kunden, jeder mit eigenen Kontakten und je drei
# Telefon-Akquise-Projekten. Die Daten sind bewusst so aufgebaut, dass
# jeder Kontakte- und Kontaktadressen-Name genau einmal in der gesamten
# Datenbank vorkommt und kein Projektname bei zwei Kunden existiert.
# Die Kontaktadressen sind die Akquise-Ziele (angerufene Firmen), die
# Gesprächsnotizen werden daraus automatisch abgeleitet.
# ---------------------------------------------------------------------------
_MUSTERDATEN = [
{
"name": "Nordwerk Metallbau GmbH & Co. KG",
"strasse": "Hamburger Str. 118",
"plz": "21079",
"ort": "Hamburg",
"land": "Deutschland",
"kontakte": [
{
"anrede": "Herr", "name": "Dipl.-Ing. Markus Haverkamp",
"position": "Einkaufsleiter",
"telefon": "+49 40 790 214 66",
"email": "haverkamp@nordwerk-metallbau.de",
"notiz": "Entscheider für Rahmenverträge; telefonisch am besten nach 15 Uhr erreichbar.",
},
{
"anrede": "Frau", "name": "Katrin Ohmsen",
"position": "Vertriebsleitung",
"telefon": "+49 40 790 214 12",
"email": "ohmsen@nordwerk-metallbau.de",
"notiz": "Steuert die laufenden Kampagnen mit uns; erwartet jeden Freitag ein kurzes Zwischen-Reporting.",
},
],
"projekte": [
{
"projektname": "Akquise Stahlträgerprogramm 2026 – Dachstühle und Hallenbau",
"zweck": "Telefonische Neukundenansprache bei Bauunternehmen in Norddeutschland, um das überarbeitete Stahlträgerprogramm zu positionieren. Ziel: 400 geführte Gespräche bis Jahresende, davon 60 Angebotsanfragen.",
"verantwortlicher": "Miriam Falkner",
"startdatum": date(2026, 8, 3),
"kontaktadressen": [
{
"anrede": "Herr", "name": "Torben Block",
"firma": "Block Bauunternehmung GmbH",
"position": "Einkaufsleiter",
"telefon": "+49 40 6899 2140",
"email": "t.block@block-bau-hamburg.de",
"strasse": "Wandsbeker Chaussee 210", "plz": "22089",
"ort": "Hamburg", "land": "Deutschland",
"notiz": "Tauscht die Rahmenverträge jährlich aus; entscheidet gemeinsam mit der Kalkulation.",
},
{
"anrede": "Frau", "name": "Silke Reimers",
"firma": "Reimers Dachbaustoffe Nord",
"position": "Leiterin Kalkulation",
"telefon": "+49 431 556 872",
"email": "s.reimers@reimers-dachbaustoffe.de",
"strasse": "Kieler Str. 31", "plz": "24116",
"ort": "Kiel", "land": "Deutschland",
"notiz": "Plant mehrere Hallenprojekte für das kommende Jahr.",
},
{
"anrede": "Herr", "name": "Gero Timmermann",
"firma": "Timmermann Hallenbau",
"position": "Geschäftsführer",
"telefon": "+49 511 987 4432",
"email": "g.timmermann@timmermann-hallenbau.de",
"strasse": "Am Lindener Hafen 12", "plz": "30453",
"ort": "Hannover", "land": "Deutschland",
"notiz": "Hat großes Interesse an Schnellmontage-Systemen signalisiert.",
},
],
},
{
"projektname": "Neukundengewinnung Zulieferer Ostösterreich",
"zweck": "Kaltakquise bei Stahl- und Metallzulieferern in Wien, Niederösterreich und Oberösterreich zur Erschließung neuer Lieferbeziehungen. Ziel: 250 Kontakte, davon 40 qualifizierte Terminvorschläge für den Außendienst.",
"verantwortlicher": "Dominik Reiss",
"startdatum": date(2026, 8, 17),
"kontaktadressen": [
{
"anrede": "Herr", "name": "Wolfgang Gruber",
"firma": "Gruber Stahlhandel",
"position": "Vertriebsleiter",
"telefon": "+43 1 982 76 11",
"email": "w.gruber@gruber-stahlhandel.at",
"strasse": "Laxenburger Str. 84", "plz": "1100",
"ort": "Wien", "land": "Österreich",
"notiz": "Bezieht Trägerprofile bislang aus Deutschland; offen für österreichische Bezugsquellen.",
},
{
"anrede": "Frau", "name": "Martina Ehgartner",
"firma": "Ehgartner Metallwerke",
"position": "Einkaufsleitung",
"telefon": "+43 732 455 210",
"email": "m.ehgartner@ehgartner-metallwerke.at",
"strasse": "Wiener Str. 145", "plz": "4020",
"ort": "Linz", "land": "Österreich",
"notiz": "Kauft halbjährlich größere Kontingente; Verträge laufen über die Zentrale.",
},
{
"anrede": "Herr", "name": "Kurt Strubreiter",
"firma": "Strubreiter Blechtechnik",
"position": "Inhaber",
"telefon": "+43 2628 741 33",
"email": "office@strubreiter-blechtechnik.at",
"strasse": "Betriebsstr. 7", "plz": "2700",
"ort": "Wiener Neustadt", "land": "Österreich",
"notiz": "Familienbetrieb; entscheidet kurzfristig, wenn die Konditionen stimmen.",
},
],
},
{
"projektname": "Cross-Selling Schutzgitter und Tore im Bestand",
"zweck": "Nachverkauf im Bestandskundenstamm: Kunden der Jahre 2022 bis 2025 werden telefonisch auf das neue Sortiment an Schutzgittern und Industrietoren angesprochen. Ziel: 300 Bestandskunden kontaktiert, Angebotsversand innerhalb von 48 Stunden.",
"verantwortlicher": "Aylin Kocabiyik",
"startdatum": date(2026, 9, 1),
"kontaktadressen": [
{
"anrede": "Frau", "name": "Petra Wilhelmi",
"firma": "Wilhelmi Logistikzentrum",
"position": "Technische Leiterin",
"telefon": "+49 421 338 7762",
"email": "p.wilhelmi@wilhelmi-logistik.de",
"strasse": "Universitätsallee 25", "plz": "28359",
"ort": "Bremen", "land": "Deutschland",
"notiz": "Sanierung der Tore für nächstes Jahr geplant; Budget muss noch beantragt werden.",
},
{
"anrede": "Herr", "name": "Heino Sievers",
"firma": "Sievers Agrartechnik",
"position": "Kundendienstleiter",
"telefon": "+49 441 763 519",
"email": "h.sievers@sievers-agrartechnik.de",
"strasse": "Boschdamm 18", "plz": "26122",
"ort": "Oldenburg", "land": "Deutschland",
"notiz": "Sucht Ersatzteile für ältere Hallentore; auch Interesse an Wartungsverträgen.",
},
{
"anrede": "Herr", "name": "Dietrich Voskamp",
"firma": "Voskamp Landbau",
"position": "Betriebsleiter",
"telefon": "+49 541 229 834",
"email": "d.voskamp@voskamp-landbau.de",
"strasse": "Natruper-Tor-Wall 70", "plz": "49074",
"ort": "Osnabrück", "land": "Deutschland",
"notiz": "Erweitert den Maschinenpark; über Neuanschaffungen entscheidet der Sohn mit.",
},
],
},
],
},
{
"name": "Vitalis Apothekenverbund eG",
"strasse": "Kaisersstr. 77",
"plz": "76133",
"ort": "Karlsruhe",
"land": "Deutschland",
"kontakte": [
{
"anrede": "Frau", "name": "Dr. Ingrid Halbach",
"position": "Mitglied der Geschäftsleitung",
"telefon": "+49 721 508 3390",
"email": "halbach@vitalis-apotheken.de",
"notiz": "Apothekerin; legt besonderen Wert auf seriös formulierte Gesprächsleitfäden.",
},
{
"anrede": "Herr", "name": "Jörn Kiebitz",
"position": "Leiter Filialmarketing",
"telefon": "+49 721 508 3377",
"email": "kiebitz@vitalis-apotheken.de",
"notiz": "Liefert die Filial- und Zielgruppenlisten für unsere Kampagnen; Ansprechpartner für Abstimmungstermine.",
},
],
"projekte": [
{
"projektname": "Terminierung Arzneimittelberatung – Vitalis Filialen Süd",
"zweck": "Telefonische Terminierung kostenpflichtiger Arzneimittelberatungen für Mitgliedsapotheken in Baden-Württemberg und Bayern. Ziel: 120 bestätigte Beratungstermine pro Monat bei maximal drei Absagen je Termin.",
"verantwortlicher": "Tobias Lenzen",
"startdatum": date(2026, 9, 7),
"kontaktadressen": [
{
"anrede": "Frau", "name": "Dr. Marlene Vogt",
"firma": "Vogt-Apotheke Stuttgart-West",
"position": "Inhaberin",
"telefon": "+49 711 563 448",
"email": "m.vogt@vogt-apotheke-stuttgart.de",
"strasse": "Kernerstr. 31", "plz": "70182",
"ort": "Stuttgart", "land": "Deutschland",
"notiz": "Führt die Beratungen selbst durch; Terminvorschläge möglichst vormittags.",
},
{
"anrede": "Herr", "name": "Ingo Pasternak",
"firma": "Pasternak Apotheke",
"position": "Filialleiter",
"telefon": "+49 89 448 2120",
"email": "i.pasternak@pasternak-apotheke.de",
"strasse": "Leopoldstr. 90", "plz": "80802",
"ort": "München", "land": "Deutschland",
"notiz": "Stellvertretung zu Servicezwecken; Zeitfenster dienstags und donnerstags.",
},
{
"anrede": "Frau", "name": "Regine Axt",
"firma": "Axt Rathaus-Apotheke",
"position": "Approbierte Apothekerin",
"telefon": "+49 721 976 512",
"email": "r.axt@axt-apotheke.de",
"strasse": "Am Stadtgarten 2", "plz": "76133",
"ort": "Karlsruhe", "land": "Deutschland",
"notiz": "Neu in der Mitgliedschaft; wünscht ein Einführungsgespräch.",
},
],
},
{
"projektname": "Rückgewinnung inaktiver Bonuskarten-Kunden",
"zweck": "Reaktivierung von rund 5.000 nicht mehr aktiven Bonuskarten-Kunden per Telefon mit anschließender Follow-up-Mail. Ziel: 8 Prozent Rückkehrquote im ersten Quartal, Nachverkauf über die Filialen.",
"verantwortlicher": "Camilla Brugger",
"startdatum": date(2026, 8, 24),
"kontaktadressen": [
{
"anrede": "Herr", "name": "Falk Rüdiger",
"firma": "Sonnen-Apotheke Freiburg",
"position": "Inhaber",
"telefon": "+49 761 388 224",
"email": "f.ruediger@sonnen-apotheke-freiburg.de",
"strasse": "Salzstr. 60", "plz": "79098",
"ort": "Freiburg", "land": "Deutschland",
"notiz": "Wertet die Rückkehrquote monatlich aus; erwartet Zahlen zum Quartalsende.",
},
{
"anrede": "Frau", "name": "Heidrun Clauß",
"firma": "Clauß Apotheke am Markt",
"position": "Inhaberin",
"telefon": "+49 351 469 887",
"email": "h.clauss@clauss-apotheke.de",
"strasse": "Wilsdruffer Str. 24", "plz": "01067",
"ort": "Dresden", "land": "Deutschland",
"notiz": "Ist mit der bisherigen Rücklaufquote unzufrieden und drängt auf eine neue Ansprache.",
},
{
"anrede": "Herr", "name": "Marek Sawatzki",
"firma": "Sawatzki Apotheke Leipzig-Süd",
"position": "Fachapotheker",
"telefon": "+49 341 225 904",
"email": "m.sawatzki@sawatzki-apotheke.de",
"strasse": "Karl-Liebknecht-Str. 105", "plz": "04275",
"ort": "Leipzig", "land": "Deutschland",
"notiz": "Möchte besonders die junge Zielgruppe wieder ansprechen.",
},
],
},
{
"projektname": "Up-Selling Homöopathie-Eigenmarke bei Mitgliedsapotheken",
"zweck": "Telefonische Empfehlung der Eigenmarke 'Vitalis Natur' an alle 180 Mitgliedsapotheken. Ziel: Listung in mindestens 60 Apotheken bis zum Ende des ersten Quartals, durchschnittliches Bestellvolumen von 1.200 Euro je Listung.",
"verantwortlicher": "Nils Gröber",
"startdatum": date(2026, 9, 14),
"kontaktadressen": [
{
"anrede": "Frau", "name": "Brunhilde Kessler",
"firma": "Kessler Engelpforte-Apotheke",
"position": "Inhaberin",
"telefon": "+49 611 344 210",
"email": "b.kessler@kessler-apotheke.de",
"strasse": "Kirchgasse 32", "plz": "65183",
"ort": "Wiesbaden", "land": "Deutschland",
"notiz": "Listet bislang nur zwei Eigenmarken-Produkte; offen für eine Sortimentserweiterung.",
},
{
"anrede": "Herr", "name": "Ali Demirci",
"firma": "Demirci Apotheke Köln",
"position": "Apotheker",
"telefon": "+49 221 559 731",
"email": "a.demirci@demirci-apotheke.de",
"strasse": "Neumarkt 3", "plz": "50667",
"ort": "Köln", "land": "Deutschland",
"notiz": "Fragt nach Verkaufsunterlagen für das Beratungsgespräch.",
},
{
"anrede": "Frau", "name": "Yasmin Trautmann",
"firma": "Trautmann Apotheke",
"position": "Fachapothekerin",
"telefon": "+49 69 664 128",
"email": "y.trautmann@trautmann-apotheke.de",
"strasse": "Berger Str. 175", "plz": "60385",
"ort": "Frankfurt", "land": "Deutschland",
"notiz": "Möchte erst die Ergebnisse einer Testlistung abwarten.",
},
],
},
],
},
{
"name": "Alpina Holzwerke Sepp Achmüller GmbH",
"strasse": "Sepp-Achmüller-Str. 4",
"plz": "6330",
"ort": "Kufstein",
"land": "Österreich",
"kontakte": [
{
"anrede": "Herr", "name": "Sepp Achmüller jun.",
"position": "Geschäftsführer",
"telefon": "+43 5372 6234 18",
"email": "achmiller@alpina-holzwerke.at",
"notiz": "Prüft jede Kampagne vor dem Start persönlich ab; Budgetfreigabe erst nach Vorlage einer Musterabrechnung.",
},
{
"anrede": "Frau", "name": "Elisabeth Steger",
"position": "Vertriebsleiterin",
"telefon": "+43 5372 6234 22",
"email": "steger@alpina-holzwerke.at",
"notiz": "Stimmt Zielgruppen und Anrufregionen mit uns ab; bevorzugt die Abstimmung per Mail.",
},
],
"projekte": [
{
"projektname": "Kampagne Thermofenster Linienserie Alpin",
"zweck": "Telefonische Kampagne bei Baufirmen, Architekten und Holzbaubetrieben für die neue Thermofenster-Serie 'Alpin'. Ziel: 300 geführte Gespräche und 45 Mess- beziehungsweise Angebotstermine.",
"verantwortlicher": "Sandra Wittke",
"startdatum": date(2026, 8, 10),
"kontaktadressen": [
{
"anrede": "Herr", "name": "Jakob Rainer",
"firma": "Rainer Holzbau",
"position": "Geschäftsführer",
"telefon": "+43 512 344 087",
"email": "j.rainer@rainer-holzbau.at",
"strasse": "Museumstr. 13", "plz": "6020",
"ort": "Innsbruck", "land": "Österreich",
"notiz": "Setzt auf Passivhaus-Komponenten; U-Werte müssen im Angebot ausgewiesen sein.",
},
{
"anrede": "Frau", "name": "Notburga Wörgötter",
"firma": "Wörgötter Zimmerei",
"position": "Kalkulation und Einkauf",
"telefon": "+43 662 834 556",
"email": "n.woergoetter@woergoetter-zimmerei.at",
"strasse": "Schallmooser Hauptstr. 44", "plz": "5020",
"ort": "Salzburg", "land": "Österreich",
"notiz": "Benötigt Muster für die Messepräsentation im November.",
},
{
"anrede": "Herr", "name": "Quirin Hofer",
"firma": "Hofer Bau & Planung",
"position": "Architekt",
"telefon": "+49 871 339 421",
"email": "q.hofer@hofer-bau-planung.de",
"strasse": "Ostengasse 15", "plz": "84028",
"ort": "Landshut", "land": "Deutschland",
"notiz": "Plant ein mehrgeschossiges Holzbauprojekt; Fensterelemente sind noch nicht vergeben.",
},
],
},
{
"projektname": "Wiedervorlage-Bestellungen Winterholz-Vorkauf",
"zweck": "Frühzeitige telefonische Vorkaufskampagne für Winterholz: Bestandskunden aus dem Bauhandel anrufen, Bedarf aufnehmen und Wiedervorlagen für die Bestellzeit setzen. Ziel: 65 Prozent Vorab-Buchung des Winterkontingents.",
"verantwortlicher": "Jonas Ade",
"startdatum": date(2026, 9, 21),
"kontaktadressen": [
{
"anrede": "Herr", "name": "Werner Prantner",
"firma": "Prantner Baustoffe",
"position": "Verkauf",
"telefon": "+49 8631 554 22",
"email": "w.prantner@prantner-baustoffe.de",
"strasse": "Simbacher Str. 14", "plz": "84137",
"ort": "Vilsbiburg", "land": "Deutschland",
"notiz": "Bestellt traditionell erst im November; testet dieses Jahr erstmals den Vorkauf.",
},
{
"anrede": "Frau", "name": "Cäcilia Pfeiff",
"firma": "Pfeiff Holzmarkt",
"position": "Inhaberin",
"telefon": "+43 732 915 447",
"email": "c.pfeiff@pfeiff-holzmarkt.at",
"strasse": "Rudolfstr. 12", "plz": "4020",
"ort": "Linz", "land": "Österreich",
"notiz": "Braucht eine verbindliche Lieferzusage bis Anfang Dezember.",
},
{
"anrede": "Herr", "name": "Ambros Hinterseer",
"firma": "Hinterseer Sägewerke",
"position": "Disposition",
"telefon": "+43 5356 622 18",
"email": "a.hinterseer@hinterseer-saegewerke.at",
"strasse": "Jochberger Str. 9", "plz": "6360",
"ort": "Kitzbühel", "land": "Österreich",
"notiz": "Koordiniert den Transport selbst; Übergabetermine müssen exakt stehen.",
},
],
},
{
"projektname": "Lead-Qualifizierung Sägewerksbetriebe Bayern/Tirol",
"zweck": "Anrufcampagne zur Lead-Qualifizierung bei mittelständischen Sägewerken in Bayern und Tirol: Recherche der Ansprechpartner und Bewertung nach Einkaufsvolumen. Ziel: 200 qualifizierte Leads bis Mitte des zweiten Quartals.",
"verantwortlicher": "Pia Merkert",
"startdatum": date(2026, 9, 28),
"kontaktadressen": [
{
"anrede": "Herr", "name": "Hubert Mühlbacher",
"firma": "Mühlbacher Holz",
"position": "Einkaufsleiter",
"telefon": "+49 851 987 350",
"email": "h.muehlbacher@muehlbacher-holz.de",
"strasse": "Innstr. 40", "plz": "94032",
"ort": "Passau", "land": "Deutschland",
"notiz": "Fragt nach Zertifikaten und Lieferkonditionen bereits im Erstgespräch.",
},
{
"anrede": "Frau", "name": "Resi Peringer",
"firma": "Peringer Säge",
"position": "Disposition",
"telefon": "+43 5285 644 21",
"email": "r.peringer@peringer-saege.at",
"strasse": "Dorfstr. 22", "plz": "6290",
"ort": "Mayrhofen", "land": "Österreich",
"notiz": "Familienbetrieb in dritter Generation; die Entscheidung liegt beim Senior.",
},
{
"anrede": "Herr", "name": "Konrad Eder",
"firma": "Eder Holzwerk",
"position": "Betriebsleiter",
"telefon": "+49 8031 554 67",
"email": "k.eder@eder-holzwerk.de",
"strasse": "Chiemseestr. 28", "plz": "83022",
"ort": "Rosenheim", "land": "Deutschland",
"notiz": "Erweitert die Produktionskapazität; sucht Lieferanten mit hoher Flexibilität.",
},
],
},
],
},
]
# Betreff-Vorlagen für die automatisch erzeugten Gesprächsnotizen. Passen
# zum Ablauf einer Telefon-Akquise (Erstkontakt -> Termin -> Angebot).
_BETREFFVORLAGEN = [
"Erstkontakt und Bedarfsaufnahme",
"Produktvorstellung am Telefon",
"Terminvorschlag Außendienst",
"Angebot nachreichen",
"Nachfassung zum Angebot",
"Wiedervorlage nach Sommerpause",
"Rückruf vereinbart",
]
# Notiz-Vorlagen für die automatisch erzeugten Gesprächsnotizen. Formuliert
# wie typische Ergebnisse eines Akquise-Telefonats; ein leerer Eintrag
# steht für ein Telefonat ohne Notiz.
_NOTIZVORLAGEN = [
"Gesprächspartner zeigt grundsätzliches Interesse und bittet um die Preisliste als PDF. Das Angebot geht per Mail raus.",
"Kein akuter Bedarf, aber Erweiterungsplanung für das kommende Jahr. Wiedervorlage im Frühjahr gesetzt.",
"Vor-Ort-Termin zur Besichtigung vereinbart; Unterlagen werden vorab per Mail gesendet.",
"Zentrale verweist auf die Beschaffungsabteilung, der konkrete Ansprechpartner wird noch erfragt.",
"Muster werden angefragt; Rückruf zugesagt, sobald die Kollegin aus dem Urlaub zurück ist.",
"Angebot wurde durchgesprochen, das Budget ist noch nicht freigegeben. Nächster Anruf nach der Freigabe.",
"",
]
def _seed_musterdaten():
"""
Legt für jeden Muster-Kunden 1 bis 3 Muster-Projekte an, falls die
projekte-Tabelle noch leer ist.
Legt die vollständigen Musterdaten an: drei Kunden mit kundenspezifischen
Kontakten, je drei Telefon-Akquise-Projekte pro Kunde, projektbezogene
Kontaktadressen (Akquise-Ziele) und daraus abgeleitete Gesprächsnotizen.
Aufruf nur nach _purge_crm_daten(), da die Funktion die Daten ohne
Existenzprüfung neu anlegt. Die Gesprächsnotizen verweisen auf die
Kontaktadresse, zu der sie gehören (kontaktadresse_id ist Pflichtfeld),
und übernehmen Name und Telefonnummer des Ziels.
"""
import random
from crm.model import Kunde, Projekt
from crm.model import Kunde, Kontakt, Projekt, Kontaktadresse, CallLog
session = dbapp.session
if session.query(Projekt).count() > 0:
return
# Fester Seed für reproduzierbare Musterdaten (Datum, Uhrzeit, Dauer).
rng = random.Random(456)
kunden = session.query(Kunde).order_by(Kunde.id).all()
vorlagen = [
("Website-Relaunch", "Neugestaltung der Unternehmenswebsite"),
("E-Commerce Plattform", "Online-Shop Entwicklung"),
("CRM-System", "Kundenbeziehungsmanagement"),
("Mobile App", "iOS- und Android-Entwicklung"),
("SEO-Optimierung", "Suchmaschinenoptimierung"),
]
verantwortliche = [
"Max Mustermann", "Anna Schmidt", "Peter Müller",
"Lisa Bauer", "Thomas Becker",
]
rng = random.Random(42)
vorlagen_idx = 0
for kunde in kunden:
anzahl = rng.randint(1, 3)
for _ in range(anzahl):
name, zweck = vorlagen[vorlagen_idx % len(vorlagen)]
vorlagen_idx += 1
projekt = Projekt()
projekt.kunden_id = kunde.id
projekt.projektname = name
projekt.zweck = zweck
projekt.verantwortlicher = rng.choice(verantwortliche)
projekt.startdatum = "2024-0{monat}-1{tag}".format(
monat=rng.randint(1, 9), tag=rng.randint(0, 9)
for kundendaten in _MUSTERDATEN:
kunde = Kunde(
name=kundendaten["name"],
strasse=kundendaten["strasse"],
plz=kundendaten["plz"],
ort=kundendaten["ort"],
land=kundendaten["land"],
)
session.add(kunde)
# flush liefert die Auto-Increment-ID für die abhängigen Sätze.
session.flush()
for kontaktdaten in kundendaten["kontakte"]:
kontakt = Kontakt(
kunden_id=kunde.id,
anrede=kontaktdaten["anrede"],
name=kontaktdaten["name"],
position=kontaktdaten["position"],
telefon=kontaktdaten["telefon"],
email=kontaktdaten["email"],
notiz=kontaktdaten["notiz"],
)
session.add(kontakt)
for projektdaten in kundendaten["projekte"]:
projekt = Projekt(
kunden_id=kunde.id,
projektname=projektdaten["projektname"],
zweck=projektdaten["zweck"],
verantwortlicher=projektdaten["verantwortlicher"],
startdatum=projektdaten["startdatum"],
)
session.add(projekt)
session.commit()
session.flush()
for adressdaten in projektdaten["kontaktadressen"]:
adresse = Kontaktadresse(
projekt_id=projekt.id,
anrede=adressdaten["anrede"],
name=adressdaten["name"],
firma=adressdaten["firma"],
position=adressdaten["position"],
telefon=adressdaten["telefon"],
email=adressdaten["email"],
strasse=adressdaten["strasse"],
plz=adressdaten["plz"],
ort=adressdaten["ort"],
land=adressdaten["land"],
notiz=adressdaten["notiz"],
)
session.add(adresse)
session.flush()
def _seed_muster_kontaktadressen():
"""
Legt für jedes Muster-Projekt 1 bis 3 Kontaktadressen an, falls die
kontaktadressen-Tabelle noch leer ist.
"""
import random
from crm.model import Projekt, Kontaktadresse
session = dbapp.session
if session.query(Kontaktadresse).count() > 0:
return
projekte = session.query(Projekt).order_by(Projekt.id).all()
vorlagen = [
("Herr", "Thomas Becker", "Becker Webdesign GmbH", "Geschäftsführer"),
("Frau", "Sandra Klein", "Klein Marketing", "Marketingleiterin"),
("Herr", "Michael Wagner", "Wagner Online Shop", "IT-Leiter"),
("Frau", "Julia Hoffmann", "Hoffmann Consulting", "Beraterin"),
("Herr", "Stefan Roth", "Roth IT Solutions", "Technischer Leiter"),
]
orte = [
("Industriestr. 12", "45131", "Essen", "Deutschland"),
("Königsallee 5", "40212", "Düsseldorf", "Deutschland"),
("Hauptstr. 88", "50667", "Köln", "Deutschland"),
("Marienplatz 3", "80331", "München", "Deutschland"),
]
rng = random.Random(123)
vorlagen_idx = 0
for projekt in projekte:
anzahl = rng.randint(1, 3)
for _ in range(anzahl):
anrede, name, firma, position = vorlagen[vorlagen_idx % len(vorlagen)]
vorlagen_idx += 1
strasse, plz, ort, land = rng.choice(orte)
ka = Kontaktadresse()
ka.projekt_id = projekt.id
ka.anrede = anrede
ka.name = name
ka.firma = firma
ka.position = position
ka.telefon = "+49 {vorwahl} {nummer}".format(
vorwahl=rng.randint(100, 999),
nummer=rng.randint(100000, 999999)
)
ka.email = name.lower().replace(" ", ".") + "@example.de"
ka.strasse = strasse
ka.plz = plz
ka.ort = ort
ka.land = land
ka.notiz = ""
session.add(ka)
session.commit()
def _seed_muster_calllogs():
"""
Legt für jedes Muster-Projekt 2 bis 5 Gesprächsnotizen an, falls die
call_logs-Tabelle noch leer ist.
"""
import random
from crm.model import Projekt, CallLog
session = dbapp.session
if session.query(CallLog).count() > 0:
return
projekte = session.query(Projekt).order_by(Projekt.id).all()
betreffvorlagen = [
"Anforderungen klären",
"Feedback zum Design",
"Technische Anforderungen",
"Schulungstermin",
"Feature-Request",
"Budgetgespräch",
"Projekt-Review",
]
notizvorlagen = [
"Kunde möchte responsive Design und neue Farbgestaltung. Budget noch offen.",
"Design-Entwürfe wurden positiv aufgenommen. Kleine Änderungen an der Navigation nötig.",
"Payment-Gateway und Versand-Schnittstellen müssen integriert werden.",
"Schulung für 10 Mitarbeiter geplant. Raum noch zu buchen.",
"Kunde wünscht Push-Benachrichtigungen und Offline-Modus.",
"",
]
ansprechpartner = ["Herr Schmidt", "Frau Müller", "Herr Weber", "Frau Bauer", "Herr König"]
rng = random.Random(456)
for projekt in projekte:
anzahl = rng.randint(2, 5)
for _ in range(anzahl):
log = CallLog()
log.projekt_id = projekt.id
monat = rng.randint(1, 9)
tag = rng.randint(1, 28)
log.datum = "2024-0{m}-{t}".format(m=monat, t=tag) if monat < 10 else "2024-{m}-{t}".format(m=monat, t=tag)
log.uhrzeit_beginn = "{h:02d}:{min:02d}".format(
h=rng.randint(8, 17), min=rng.randint(0, 59)
)
log.dauer = str(rng.randint(15, 90))
log.betreff = rng.choice(betreffvorlagen)
log.name_ansprechpartner = rng.choice(ansprechpartner)
log.telefon = "+49 {v} {n}".format(
v=rng.randint(100, 999), n=rng.randint(100000, 999999)
)
log.notiz = rng.choice(notizvorlagen)
wiedervorlage_monat = monat + 1 if monat < 12 else 1
log.wiedervorlage_datum = "2024-0{m}-{t}".format(m=wiedervorlage_monat, t=tag) if wiedervorlage_monat < 10 else "2024-{m}-{t}".format(m=wiedervorlage_monat, t=tag)
log.erledigt = rng.choice([True, False])
session.add(log)
# Je Kontaktadresse ein bis zwei Gesprächsnotizen, die das
# Akquise-Telefonat mit diesem Ziel dokumentieren.
for _ in range(rng.randint(1, 2)):
log = CallLog()
log.projekt_id = projekt.id
log.kontaktadresse_id = adresse.id
log.datum = date(2026, 9, rng.randint(1, 26))
log.uhrzeit_beginn = "{h:02d}:{min:02d}".format(
h=rng.randint(8, 17), min=rng.randint(0, 59)
)
log.dauer = str(rng.randint(5, 45))
log.betreff = rng.choice(_BETREFFVORLAGEN)
log.name_ansprechpartner = adresse.name
log.telefon = adresse.telefon
log.notiz = rng.choice(_NOTIZVORLAGEN)
log.wiedervorlage_datum = log.datum + timedelta(
days=rng.randint(7, 28)
)
session.add(log)
session.commit()
+154 -11
View File
@@ -148,15 +148,33 @@ class Projekt(dbapp.Model):
startdatum = Column(Date, nullable=True)
kunde = relationship('Kunde')
# Kontaktadressen dieses Projekts (Akquise-Ziele). Die Rückrichtung
# ist Kontaktadresse.projekt (back_populates).
kontaktadressen = relationship('Kontaktadresse', back_populates='projekt')
def is_erledigt(self):
"""
Prüft dynamisch, ob das Projekt als erledigt gilt.
True, wenn das Projekt Kontaktadressen hat und alle davon erledigt
sind. Ein Projekt ohne Kontaktadressen ist offen (False), da
Kontaktadressen erst nach dem Anlegen des Projekts erfasst werden
können — ein neues Projekt ist somit zunächst offen.
"""
if not self.kontaktadressen:
return False
return all(k.is_erledigt() for k in self.kontaktadressen)
def to_dict(self):
"""
Liefert die Projektdaten als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; kunden_name wird zusätzlich aus der
Kunde-Relationship eingefügt.
Kunde-Relationship eingefügt. is_erledigt wird dynamisch aus den
zugeordneten Kontaktadressen berechnet.
"""
result = columns2dict(self)
result['kunden_name'] = self.kunde.name if self.kunde else None
result['is_erledigt'] = self.is_erledigt()
return result
@@ -220,34 +238,75 @@ class Kontaktadresse(dbapp.Model):
land = Column(String(80), nullable=True)
notiz = Column(String(2000), nullable=True)
projekt = relationship('Projekt')
projekt = relationship('Projekt', back_populates='kontaktadressen')
# Gesprächsnotizen, die zu diesem Kontakt erfasst wurden. Die
# Rückrichtung ist CallLog.kontaktadresse (back_populates); die
# Join-Bedingung ergibt sich aus dem Fremdschlüssel
# call_logs.kontaktadresse_id -> kontaktadressen.id.
call_logs = relationship('CallLog', back_populates='kontaktadresse')
def is_erledigt(self):
"""
Prüft dynamisch, ob der Kontakt als erledigt gilt.
True, sobald mindestens eine Gesprächsnotiz des Kontakts einen Tag
mit is_erledigend=True hat. Spätere Notizen ohne erledigend-Tag
setzen den Status nicht zurück. False bei keinen Notizen oder wenn
keine Notiz einen erledigend-Tag hat.
"""
return any(
clt.tag.is_erledigend
for notiz in self.call_logs
for clt in notiz.tags_rel
)
def to_dict(self):
"""
Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; projekt_name wird zusätzlich aus der
Projekt-Relationship eingefügt.
über columns2dict serialisiert; projekt_name, projekt_zweck und
kunden_name werden zusätzlich aus der Projekt- bzw. Kunde-Relationship
eingefügt. is_erledigt wird dynamisch aus den Gesprächsnotizen des
Kontakts berechnet.
"""
result = columns2dict(self)
result['projekt_name'] = self.projekt.projektname if self.projekt else None
result['projekt_zweck'] = self.projekt.zweck if self.projekt else None
result['kunden_name'] = self.projekt.kunde.name if self.projekt and self.projekt.kunde else None
result['is_erledigt'] = self.is_erledigt()
return result
class CallLog(dbapp.Model):
"""
Gesprächsnotiz für ein Projekt. Jede Gesprächsnotiz ist genau einem
Projekt zugeordnet (projekt_id). Der Projektname (projekt_name) und
der Kundenname (kunden_name) sind keine eigenen Spalten, sondern
werden über die Projekt-Relationship abgeleitet und nur in to_dict()
für die JSON-Serialisierung ergänzt.
Gesprächsnotiz für einen Kontakt. Jede Gesprächsnotiz ist genau einem
Projekt (projekt_id) und genau einer Kontaktadresse (kontaktadresse_id)
zugeordnet. Der Projektname (projekt_name) und der Kundenname
(kunden_name) sind keine eigenen Spalten, sondern werden über die
Projekt-Relationship abgeleitet und nur in to_dict() für die
JSON-Serialisierung ergänzt.
"""
__tablename__ = 'call_logs'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_call_logs_projekt_id', 'projekt_id'),
Index('ix_call_logs_kontaktadresse_id', 'kontaktadresse_id'),
)
id = Column(Integer, primary_key=True)
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False)
# Jede Gesprächsnotiz gehört zu genau einer Kontaktadresse
# (Pflichtfeld). nullable=False im Modell; in bestehenden Datenbanken
# ist die physische Spalte nullable (SQLite kann kein nachträgliches
# NOT NULL) — die Pflicht wird über die Server-Validierung in
# crm.model.call_log durchgesetzt.
kontaktadresse_id = Column(Integer, ForeignKey('kontaktadressen.id'), nullable=False)
# Ersteller der Gesprächsnotiz (Mitarbeiter, der den Anruf getätigt
# hat). Referenziert logisch die 'id' in der Auth-Datenbanktabelle
# 'users'; ein physischer Fremdschlüssel ist aufgrund der zwei
# getrennten SQLite-Datenbanken nicht möglich (Muster wie
# projekt_users.user_id). nullable=True, da Alt-Notizen (angelegt
# vor der Spalte) keinen Ersteller erfassen konnten; die Auflösung
# auf den username erfolgt zur Laufzeit über eine separate Query.
user_id = Column(Integer, nullable=True)
datum = Column(Date, nullable=True)
uhrzeit_beginn = Column(String(10), nullable=True)
dauer = Column(String(10), nullable=True)
@@ -256,15 +315,24 @@ class CallLog(dbapp.Model):
telefon = Column(String(100), nullable=True)
notiz = Column(String(2000), nullable=True)
wiedervorlage_datum = Column(Date, nullable=True)
erledigt = Column(Boolean, nullable=False, default=False)
projekt = relationship('Projekt')
kontaktadresse = relationship('Kontaktadresse', back_populates='call_logs')
# n:m-Beziehung zu Tags über die Verknüpfungstabelle call_logs_tags.
# cascade='all, delete-orphan' stellt sicher, dass die Zuordnungen
# automatisch gelöscht werden, wenn die Gesprächsnotiz gelöscht wird.
tags_rel = relationship(
'CallLogTag', cascade='all, delete-orphan', back_populates='call_log'
)
def to_dict(self):
"""
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; projekt_name und kunden_name werden
zusätzlich aus der Projekt-Relationship eingefügt.
zusätzlich aus der Projekt-Relationship eingefügt. mitarbeiter_name
löst die user_id gegen die Auth-Datenbank auf (None bei Alt-Notizen
ohne erfassten Ersteller). Die zugeordneten Tags werden als Liste
von Tag-Dictionaries unter 'tags' ergänzt.
"""
result = columns2dict(self)
if self.projekt:
@@ -273,7 +341,82 @@ class CallLog(dbapp.Model):
else:
result['projekt_name'] = None
result['kunden_name'] = None
# Ersteller gegen die Auth-Datenbank auflösen (Muster wie
# crm.model.projekt_user). Alt-Notizen ohne user_id erhalten None.
result['mitarbeiter_name'] = None
if self.user_id is not None:
from auth.model import User
user = User.query.get(self.user_id)
if user is not None:
result['mitarbeiter_name'] = user.username
# Zugeordnete Tags serialisieren (nur is_parent=False sind zuordenbar).
result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel]
return result
class CallLogTag(dbapp.Model):
"""
Verknüpfungstabelle für die n:m-Beziehung zwischen Gesprächsnotizen
(CallLog) und Tags. Jede Zeile verknüpft eine Gesprächsnotiz mit einem
Tag. Der UniqueConstraint stellt sicher, dass jeder Tag nur einmal pro
Gesprächsnotiz zugeordnet werden kann.
"""
__tablename__ = 'call_logs_tags'
__bind_key__ = BINDKEY
__table_args__ = (
UniqueConstraint('call_log_id', 'tag_id', name='uq_calllog_tag'),
Index('ix_calllogtag_call_log_id', 'call_log_id'),
Index('ix_calllogtag_tag_id', 'tag_id'),
)
id = Column(Integer, primary_key=True)
call_log_id = Column(Integer, ForeignKey('call_logs.id'), nullable=False)
tag_id = Column(Integer, ForeignKey('tags.id'), nullable=False)
call_log = relationship('CallLog', back_populates='tags_rel')
tag = relationship('Tag')
class Tag(dbapp.Model):
"""
Hierarchischer Tag (Taxonomie). Tags koennen beliebig verschachtelt
werden ueber die Selbst-Referenz parent_id -> tags.id. Ein Tag mit
is_parent=True ist ein reiner Gruppierungsknoten (nicht zuordenbar);
is_parent=False kennzeichnet einen echten, zuordenbaren Tag.
"""
__tablename__ = 'tags'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_tags_parent_id', 'parent_id'),
)
id = Column(Integer, primary_key=True)
name = Column(String(200), nullable=False)
description = Column(String(2000), nullable=True)
color = Column(String(20), nullable=True)
parent_id = Column(Integer, ForeignKey('tags.id'), nullable=True)
is_parent = Column(Boolean, nullable=False, default=False)
# Felder für die Telefon-Akquise-Workflow-Logik:
# is_erledigend: True = Kontakt gilt als erledigt, wenn dieser Tag
# zugewiesen wird.
# loest_wiedervorlage_aus: True = Tag löst eine Wiedervorlage aus.
# wiedervorlage_abstand_tage: Tage bis zur Wiedervorlage (nur relevant,
# wenn loest_wiedervorlage_aus True ist).
is_erledigend = Column(Boolean, nullable=False, default=False)
loest_wiedervorlage_aus = Column(Boolean, nullable=False, default=False)
wiedervorlage_abstand_tage = Column(Integer, nullable=True)
# Selbst-Referenzierende Relationship: parent verweist auf den
# uebergeordneten Tag, children auf alle direkten Kinder. Die
# Sortierung der Kinder erfolgt alphabetisch nach name.
parent = relationship(
'Tag', remote_side='Tag.id', back_populates='children'
)
children = relationship(
'Tag', back_populates='parent', order_by='Tag.name'
)
def to_dict(self):
"""Liefert die Tag-Daten als Dictionary (fuer JSON-Serialisierung)."""
return columns2dict(self)
print("########## crm/model importiert")
+112 -14
View File
@@ -9,21 +9,102 @@ dort über den Decorator authorisation_required.
:date: 2026-09-04
:version: 1.0
"""
from datetime import date
from db import dbapp
from crm.model import CallLog, Projekt, ProjektUser
from crm.model import CallLog, CallLogTag, Kontaktadresse, Projekt, ProjektUser, Tag
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
def _parse_date(value: str | None) -> date | None:
"""
Wandelt einen ISO-Datum-String (yyyy-MM-dd) in ein date-Objekt um.
Leere Strings und None werden zu None konvertiert (Spalte ist nullable).
"""
if not value:
return None
value = value.strip()
if not value:
return None
try:
return date.fromisoformat(value)
except ValueError:
return None
def _set_call_log_tags(call_log: CallLog, tag_ids: list) -> None:
"""
Ersetzt die Tag-Zuordnungen einer Gesprächsnotiz durch die übergebene
Liste von Tag-IDs. Bestehende Zuordnungen werden gelöscht, danach werden
die neuen Zuordnungen angelegt.
Nur Tags, die existieren und nicht reine Gruppierungsknoten
(is_parent=True) sind, werden zugeordnet. Duplikate in der Liste
werden durch ein Set eliminiert.
"""
# Bestehende Zuordnungen entfernen.
for clt in list(call_log.tags_rel):
session.delete(clt)
call_log.tags_rel = []
# Neue Zuordnungen anlegen.
seen = set()
for tag_id in tag_ids or []:
try:
tag_id = int(tag_id)
except (TypeError, ValueError):
continue
if tag_id in seen:
continue
seen.add(tag_id)
tag = Tag.query.get(tag_id)
if tag is None or tag.is_parent:
# Nicht zuordenbar — überspringen.
continue
clt = CallLogTag()
clt.call_log = call_log
clt.tag = tag
session.add(clt)
def _get_validierte_kontaktadresse_id(data: dict, projekt_id) -> int:
"""
Liest kontaktadresse_id aus dem data-Dict und validiert sie:
- Pflichtfeld: fehlt sie oder ist sie leer, wird Bad Request geliefert.
- Die Kontaktadresse muss existieren und zum selben Projekt gehören.
Liefert die geprüfte ID als int zurück.
"""
kontaktadresse_id = data.get("kontaktadresse_id")
if not kontaktadresse_id:
raise ApplicationError(
"Es muss eine Kontaktadresse ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kontaktadresse = Kontaktadresse.query.get(int(kontaktadresse_id))
if kontaktadresse is None or kontaktadresse.projekt_id != int(projekt_id):
raise ApplicationError(
"Die ausgewählte Kontaktadresse existiert nicht oder gehört nicht zum Projekt.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
return kontaktadresse.id
def get_call_logs(projekt_id: int | None = None,
user_id: int | None = None,
kontaktadresse_id: int | None = None):
"""
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
Ist projekt_id gesetzt, werden nur die Notizen dieses Projekts
zurückgegeben (serverseitige Filterung). Ohne projekt_id werden
alle Notizen geliefert.
zurückgegeben (serverseitige Filterung). Ist kontaktadresse_id
gesetzt, werden nur die Notizen dieses Kontakts zurückgegeben —
beide Filter können kombiniert werden. Ohne Filter werden alle
Notizen geliefert.
Ist user_id gesetzt, werden nur Notizen von Projekten geliefert, denen
der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-Filterung).
@@ -32,6 +113,8 @@ def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
query = CallLog.query
if projekt_id is not None:
query = query.filter(CallLog.projekt_id == projekt_id)
if kontaktadresse_id is not None:
query = query.filter(CallLog.kontaktadresse_id == kontaktadresse_id)
if user_id is not None:
# Nur Notizen von Projekten, denen der Nutzer zugeordnet ist.
query = query.filter(
@@ -71,14 +154,19 @@ def get_call_log(call_log_id: int, user_id: int | None = None) -> CallLog:
return call_log
def create_call_log(data: dict) -> CallLog:
def create_call_log(data: dict, user_id: int | None = None) -> CallLog:
"""
Legt eine neue Gesprächsnotiz an. Die vom Client übergebene ID wird
ignoriert; die ID wird serverseitig per Auto-Increment vergeben.
- data: dict mit den Feldern projekt_id, datum, uhrzeit_beginn, dauer,
betreff, name_ansprechpartner, telefon, notiz,
wiedervorlage_datum, erledigt
- data: dict mit den Feldern projekt_id, kontaktadresse_id, datum,
uhrzeit_beginn, dauer, betreff, name_ansprechpartner, telefon,
notiz, wiedervorlage_datum, tag_ids
- user_id: ID des angemeldeten Mitarbeiters, der die Notiz erfasst
(Ersteller). Wird von der Route übergeben (current_user.id),
damit diese Funktion frei vom Flask-Request-Kontext bleibt.
Der Ersteller wird beim Anlegen gesetzt und beim späteren
Update nicht mehr verändert.
"""
projekt_id = data.get("projekt_id")
if projekt_id is None:
@@ -92,17 +180,25 @@ def create_call_log(data: dict) -> CallLog:
"Das ausgewählte Projekt existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kontaktadresse_id = _get_validierte_kontaktadresse_id(data, projekt_id)
call_log = CallLog()
call_log.projekt_id = int(projekt_id)
call_log.datum = (data.get("datum") or "").strip() or None
call_log.kontaktadresse_id = kontaktadresse_id
# Ersteller erfassen: welcher Mitarbeiter den Anruf getätigt hat.
# Alt-Notizen (vor der user_id-Spalte angelegt) haben NULL; neue
# Notizen ohne Angabe erhalten ebenfalls NULL, bleiben aber die
# Ausnahme, da die Route immer current_user.id übergibt.
if user_id is not None:
call_log.user_id = int(user_id)
call_log.datum = _parse_date(data.get("datum"))
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
call_log.dauer = (data.get("dauer") or "").strip() or None
call_log.betreff = (data.get("betreff") or "").strip() or None
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") 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.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
call_log.erledigt = bool(data.get("erledigt", False))
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
_set_call_log_tags(call_log, data.get("tag_ids"))
session.add(call_log)
session.commit()
return call_log
@@ -126,16 +222,18 @@ def update_call_log(data: dict) -> CallLog:
"Das ausgewählte Projekt existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kontaktadresse_id = _get_validierte_kontaktadresse_id(data, projekt_id)
call_log.projekt_id = int(projekt_id)
call_log.datum = (data.get("datum") or "").strip() or None
call_log.kontaktadresse_id = kontaktadresse_id
call_log.datum = _parse_date(data.get("datum"))
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
call_log.dauer = (data.get("dauer") or "").strip() or None
call_log.betreff = (data.get("betreff") or "").strip() or None
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") 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.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
call_log.erledigt = bool(data.get("erledigt", False))
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
_set_call_log_tags(call_log, data.get("tag_ids"))
session.commit()
return call_log
+179
View File
@@ -221,6 +221,185 @@ def _db_V01_09():
session.commit()
def _db_V01_10():
"""
Legt die Tabelle 'tags' an. Tags sind eine hierarchische Taxonomie
mit Selbst-Referenz (parent_id -> tags.id). create_all erzeugt nur
fehlende Tabellen, bestehende bleiben unangetastet.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.10')
session.commit()
def _db_V01_11():
"""
Legt die Verknüpfungstabelle 'call_logs_tags' für die n:m-Beziehung
zwischen Gesprächsnotizen und Tags an. create_all erzeugt nur fehlende
Tabellen, bestehende bleiben unangetastet (idempotent).
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.11')
session.commit()
def _db_V01_12():
"""
Fügt der Tabelle 'tags' die drei neuen Spalten für die
Telefon-Akquise-Workflow-Logik hinzu:
- is_erledigend (BOOLEAN NOT NULL DEFAULT 0)
- loest_wiedervorlage_aus (BOOLEAN NOT NULL DEFAULT 0)
- wiedervorlage_abstand_tage (INTEGER)
Idempotent: Bei Neuinstallation hat create_all die Spalten bereits
angelegt — in diesem Fall wird das jeweilige ALTER TABLE übersprungen.
"""
from sqlalchemy import text
result = session.execute(text("PRAGMA table_info(tags)"))
spalten = {row[1] for row in result}
if 'is_erledigend' not in spalten:
session.execute(text(
"ALTER TABLE tags ADD COLUMN is_erledigend BOOLEAN NOT NULL DEFAULT 0"
))
if 'loest_wiedervorlage_aus' not in spalten:
session.execute(text(
"ALTER TABLE tags ADD COLUMN loest_wiedervorlage_aus BOOLEAN NOT NULL DEFAULT 0"
))
if 'wiedervorlage_abstand_tage' not in spalten:
session.execute(text(
"ALTER TABLE tags ADD COLUMN wiedervorlage_abstand_tage INTEGER"
))
_updatesysteminfo('01.12')
session.commit()
def _db_V01_13():
"""
Fügt der Tabelle 'call_logs' die Spalte 'kontaktadresse_id' hinzu:
Jede Gesprächsnotiz gehört zu genau einer Kontaktadresse
(Fremdschlüssel -> kontaktadressen.id, Pflichtfeld).
Da SQLite kein nachträgliches NOT NULL auf einer bestehenden Spalte
setzen kann, wird die Spalte als nullable angelegt und anschließend
backgefüllt: Für bestehende CallLogs ohne Kontakt-Bezug wird die
erste Kontaktadresse des gleichen Projekts eingetragen. Das NOT NULL
ist im Modell deklariert und wird über die Server-Validierung in
crm.model.call_log durchgesetzt.
Idempotent: Bei Neuinstallation hat create_all die Spalte bereits
angelegt — in diesem Fall wird das ALTER TABLE übersprungen. Der
Backfill und der Index sind ebenfalls idempotent.
"""
from sqlalchemy import text
result = session.execute(text("PRAGMA table_info(call_logs)"))
spalten = {row[1] for row in result}
if 'kontaktadresse_id' not in spalten:
session.execute(text(
"ALTER TABLE call_logs ADD COLUMN kontaktadresse_id INTEGER"
))
# Backfill: erste Kontaktadresse des Projekts eintragen, falls
# noch keine kontaktadresse_id gesetzt ist.
session.execute(text(
"UPDATE call_logs SET kontaktadresse_id = ("
" SELECT ka.id FROM kontaktadressen ka"
" WHERE ka.projekt_id = call_logs.projekt_id"
" LIMIT 1"
") WHERE kontaktadresse_id IS NULL"
))
session.execute(text(
"CREATE INDEX IF NOT EXISTS ix_call_logs_kontaktadresse_id"
" ON call_logs (kontaktadresse_id)"
))
# Roh-SQL-Änderungen committen, BEVOR die Systeminfo aktualisiert wird
# (dasselbe Muster wie _db_V01_08): Das Backfill-UPDATE öffnet eine
# Schreib-Transaktion auf der Default-Bind-Verbindung —
# SQLALCHEMY_DATABASE_URI zeigt auf dieselbe Datei wie der appdb-Bind.
# Das ORM-Update der Systeminfo läuft über die appdb-Bind-Verbindung;
# ohne dieses Commit blockiert die offene Transaktion das UPDATE
# ('database is locked').
session.commit()
_updatesysteminfo('01.13')
session.commit()
def _db_V01_14():
"""
Fügt der Tabelle 'call_logs' die Spalte 'user_id' hinzu: Jede
Gesprächsnotiz erfasst, welcher Mitarbeiter den Anruf getätigt hat.
Die Spalte referenziert logisch die 'id' in der Auth-Datenbanktabelle
'users'; ein physischer Fremdschlüssel ist aufgrund der zwei
getrennten SQLite-Datenbanken nicht möglich (Muster wie
projekt_users.user_id).
Kein Backfill: Für bestehende CallLogs ist der Ersteller nicht mehr
ermittelbar — die Spalte bleibt dort NULL (Anzeige 'unbekannt').
Neue Notizen erhalten die user_id serverseitig über current_user.id
(siehe crm.routes.calllog_create).
Idempotent: Bei Neuinstallation hat create_all die Spalte bereits
angelegt — in diesem Fall wird das ALTER TABLE übersprungen. Der
Index ist über IF NOT EXISTS ebenfalls idempotent.
"""
from sqlalchemy import text
result = session.execute(text("PRAGMA table_info(call_logs)"))
spalten = {row[1] for row in result}
if 'user_id' not in spalten:
session.execute(text(
"ALTER TABLE call_logs ADD COLUMN user_id INTEGER"
))
session.execute(text(
"CREATE INDEX IF NOT EXISTS ix_call_logs_user_id"
" ON call_logs (user_id)"
))
# Roh-SQL-Änderungen committen, BEVOR die Systeminfo aktualisiert wird
# (dasselbe Muster wie _db_V01_08 und _db_V01_13): Die DDL-Anweisung
# läuft über die Default-Bind-Verbindung — SQLALCHEMY_DATABASE_URI
# zeigt auf dieselbe Datei wie der appdb-Bind. Das ORM-Update der
# Systeminfo läuft über die appdb-Bind-Verbindung; ohne dieses Commit
# blockiert die offene Transaktion das UPDATE ('database is locked').
session.commit()
_updatesysteminfo('01.14')
session.commit()
def _db_V01_15():
"""
Entfernt die Spalte 'erledigt' aus der Tabelle 'call_logs'. Der
Erledigt-Status einer Gesprächsnotiz ist redundante Information:
Er ergibt sich dynamisch daraus, ob einer der zugeordneten Tags
is_erledigend=True hat (Kontaktadresse.is_erledigt()). Das Feld
wurde aus Modell und Client entfernt.
SQLite unterstützt DROP COLUMN ab Version 3.35; die Laufzeitumgebung
liefert 3.40.1.
Idempotent: Bei Neuinstallation existiert die Spalte nicht mehr
(create_all legt sie nicht mehr an) — der PRAGMA-Check überspringt
das DROP COLUMN in diesem Fall.
"""
from sqlalchemy import text
result = session.execute(text("PRAGMA table_info(call_logs)"))
spalten = {row[1] for row in result}
if 'erledigt' in spalten:
session.execute(text(
"ALTER TABLE call_logs DROP COLUMN erledigt"
))
# Roh-SQL-Änderungen committen, BEVOR die Systeminfo aktualisiert wird
# (dasselbe Muster wie _db_V01_08 bis _db_V01_14): Die DDL-Anweisung
# läuft über die Default-Bind-Verbindung — SQLALCHEMY_DATABASE_URI
# zeigt auf dieselbe Datei wie der appdb-Bind. Das ORM-Update der
# Systeminfo läuft über die appdb-Bind-Verbindung; ohne dieses Commit
# blockiert die offene Transaktion das UPDATE ('database is locked').
session.commit()
_updatesysteminfo('01.15')
session.commit()
def checkupdates():
"""
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
+225
View File
@@ -0,0 +1,225 @@
"""
Geschäftslogik für die Entität Tag (hierarchische Taxonomie).
Tags koennen beliebig verschachtelt werden (parent_id -> tags.id).
Ein Tag mit is_parent=True ist ein Gruppierungsknoten und nicht
zuordenbar; is_parent=False kennzeichnet einen echten, zuordenbaren Tag.
Tags sind nicht projektbezogen — die projektbezogene Sichtbarkeits-
Filterung entfällt hier vollständig.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-19
:version: 1.0
"""
from db import dbapp
from crm.model import Tag
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def _apply_workflow_felder(tag: Tag, data: dict) -> None:
"""
Übernimmt die Wiedervorlage- und Erledigt-Felder aus dem data-Dict in
den Tag. Für Parent-Tags (is_parent=True) werden die Felder auf False
bzw. None gesetzt, da sie dort keine Wirkung haben (Parent-Tags sind
nicht zuordenbar).
Validierung: Ist loest_wiedervorlage_aus True, muss wiedervorlage_abstand_tage
einen gültigen positiven Integer-Wert haben; sonst wird ein
ApplicationError (Bad_Request) geworfen.
"""
is_parent = bool(data.get("is_parent", tag.is_parent))
if is_parent:
# Parent-Tags: Workflow-Felder sind nicht relevant.
tag.is_erledigend = False
tag.loest_wiedervorlage_aus = False
tag.wiedervorlage_abstand_tage = None
return
tag.is_erledigend = bool(data.get("is_erledigend", False))
loest = bool(data.get("loest_wiedervorlage_aus", False))
tag.loest_wiedervorlage_aus = loest
abstand_raw = data.get("wiedervorlage_abstand_tage")
abstand = None
if abstand_raw is not None and str(abstand_raw).strip() != "":
try:
abstand = int(abstand_raw)
except (TypeError, ValueError):
abstand = None
if loest:
if abstand is None or abstand < 0:
raise ApplicationError(
"Wiedervorlage-Abstand in Tagen ist erforderlich, wenn "
"Wiedervorlage ausgelöst wird.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
tag.wiedervorlage_abstand_tage = abstand
else:
tag.wiedervorlage_abstand_tage = None
def get_tags(parent_id: int | None = None) -> list[Tag]:
"""
Liefert Tags, sortiert nach name (alphabetisch).
Ist parent_id gesetzt, werden nur die direkten Kinder dieses
Tags geliefert. Ohne parent_id werden alle Tags geliefert.
"""
query = Tag.query
if parent_id is not None:
query = query.filter(Tag.parent_id == parent_id)
return query.order_by(Tag.name).all()
def get_tag(tag_id: int) -> Tag:
"""
Liefert einen einzelnen Tag anhand seiner ID.
Wirft ApplicationError (404), falls der Tag nicht existiert.
"""
tag = Tag.query.get(tag_id)
if tag is None:
raise ApplicationError(
"Tag mit der ID {} wurde nicht gefunden.".format(tag_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return tag
def create_tag(data: dict) -> Tag:
"""
Legt einen neuen Tag an. Die vom Client übergebene ID wird ignoriert;
die ID wird serverseitig per Auto-Increment vergeben.
- data: dict mit den Feldern name, description, color, parent_id,
is_parent
"""
if not data.get("name") or not str(data["name"]).strip():
raise ApplicationError(
"Der Tag-Name darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
parent_id = data.get("parent_id")
if parent_id is not None:
parent_id = int(parent_id)
# Prüfen, ob der Eltern-Tag existiert
parent = Tag.query.get(parent_id)
if parent is None:
raise ApplicationError(
"Der übergeordnete Tag existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
tag = Tag()
tag.name = data["name"].strip()
tag.description = (data.get("description") or "").strip() or None
tag.color = (data.get("color") or "").strip() or None
tag.parent_id = parent_id
tag.is_parent = bool(data.get("is_parent", False))
_apply_workflow_felder(tag, data)
session.add(tag)
session.commit()
return tag
def update_tag(data: dict) -> Tag:
"""
Aktualisiert einen bestehenden Tag. Die ID im data-Dict bestimmt
den zu ändernden Datensatz.
Verhindert Zirkelbezüge: Ein Tag kann nicht sein eigener Vorfahr
als Eltern-Tag gesetzt werden.
"""
tag = get_tag(data["id"])
if not data.get("name") or not str(data["name"]).strip():
raise ApplicationError(
"Der Tag-Name darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
parent_id = data.get("parent_id")
if parent_id is not None:
parent_id = int(parent_id)
# Prüfen, ob der Eltern-Tag existiert
parent = Tag.query.get(parent_id)
if parent is None:
raise ApplicationError(
"Der übergeordnete Tag existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
# Zirkelbezug-Prüfung: Der neue Eltern-Tag darf nicht ein
# Nachfahre des zu ändernden Tags sein (sonst entsteht ein Zyklus).
if parent_id == tag.id:
raise ApplicationError(
"Ein Tag kann nicht sich selbst als übergeordneten Tag haben.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
descendant_ids = {d.id for d in get_descendants(tag.id)}
if parent_id in descendant_ids:
raise ApplicationError(
"Der übergeordnete Tag ist ein Nachfahre dieses Tags. "
"Das würde einen Zirkelbezug erzeugen.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
tag.name = data["name"].strip()
tag.description = (data.get("description") or "").strip() or None
tag.color = (data.get("color") or "").strip() or None
tag.parent_id = parent_id
tag.is_parent = bool(data.get("is_parent", False))
_apply_workflow_felder(tag, data)
session.commit()
return tag
def delete_tag(tag_id: int):
"""
Löscht einen Tag inkl. aller Nachfahren (kaskadierend).
Die Nachfahren werden zuerst (bottom-up) gelöscht, damit der
Fremdschlüssel auf parent_id nicht verletzt wird. Zuletzt wird
der Tag selbst gelöscht.
Wirft ApplicationError (404), falls der Tag nicht existiert.
"""
tag = get_tag(tag_id)
# Alle Nachfahren ermitteln und bottom-up löschen (Kinder vor Eltern)
descendants = get_descendants(tag_id)
for descendant in reversed(descendants):
session.delete(descendant)
session.delete(tag)
session.commit()
def get_descendants(tag_id: int) -> list[Tag]:
"""
Liefert alle Nachfahren (Kinder, Enkel, ...) eines Tags als flache
Liste. Die Reihenfolge ist top-down (Eltern vor Kindern), was für
die kaskadierende Löschung bottom-up (reversed) verwendet wird.
"""
descendants: list[Tag] = []
children = Tag.query.filter(Tag.parent_id == tag_id).order_by(Tag.name).all()
for child in children:
descendants.append(child)
descendants.extend(get_descendants(child.id))
return descendants
def get_tag_hierarchy() -> list[dict]:
"""
Liefert die vollständige Tag-Hierarchie als verschachtelte Struktur.
Wurzel-Tags (parent_id=None) bilden die oberste Ebene. Jeder Tag
erhält ein 'children'-Array mit seinen rekursiv eingebetteten Kindern.
Die Sortierung erfolgt alphabetisch nach name (durch die Relationship-
Definition order_by='Tag.name').
"""
roots = Tag.query.filter(Tag.parent_id.is_(None)).order_by(Tag.name).all()
return [_tag_to_dict_with_children(t) for t in roots]
def _tag_to_dict_with_children(tag: Tag) -> dict:
"""Wandelt einen Tag inkl. Kinder in ein verschachteltes Dict um."""
result = tag.to_dict()
result['children'] = [_tag_to_dict_with_children(c) for c in tag.children]
return result
+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
+96 -5
View File
@@ -27,6 +27,8 @@ from crm.model import projekt as projekte
from crm.model import projekt_user as projekt_users
from crm.model import kontaktadresse as kontaktadressen
from crm.model import call_log as call_logs
from crm.model import tages_workflow
from crm.model import tag as tags
print("crm-routes")
@@ -331,11 +333,15 @@ def kontaktadresse_delete():
@app.get("/xhr/calllog/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def calllog_list():
"""Liefert Gesprächsnotizen als Liste. Optional nach projekt_id gefiltert.
Für Nicht-SysAdmin/Nicht-AppAdmin nur Notizen sichtbarer Projekte."""
"""Liefert Gesprächsnotizen als Liste. Optional nach projekt_id und/oder
kontaktadresse_id gefiltert. Für Nicht-SysAdmin/Nicht-AppAdmin nur
Notizen sichtbarer Projekte."""
projekt_id = request.args.get("projekt_id", type=int)
kontaktadresse_id = request.args.get("kontaktadresse_id", type=int)
user_id = _get_visible_user_id()
result = [l.to_dict() for l in call_logs.get_call_logs(projekt_id, user_id)]
result = [l.to_dict() for l in call_logs.get_call_logs(
projekt_id, user_id, kontaktadresse_id
)]
return jsonify(result), 200
@@ -353,9 +359,10 @@ def calllog_get():
@app.post("/xhr/calllog/create")
@authorisation_required([KUNDEN_MANAGER])
def calllog_create():
"""Legt eine neue Gesprächsnotiz an. ID wird serverseitig vergeben."""
"""Legt eine neue Gesprächsnotiz an. ID wird serverseitig vergeben.
current_user.id wird als Ersteller (user_id) erfasst."""
_require_projekt_access(int(request.json["projekt_id"]))
call_log = call_logs.create_call_log(request.json)
call_log = call_logs.create_call_log(request.json, current_user.id)
return jsonify(call_log.to_dict()), 200
@@ -380,4 +387,88 @@ def calllog_delete():
return jsonify({}), 200
# --------------------------------------------------------------------------- #
# Tages-Workflow #
# --------------------------------------------------------------------------- #
@app.get("/xhr/workflow/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def workflow_list():
"""Tages-Workflow-Liste: Kontaktadressen mit letzter Notiz, Wiedervorlage
und dynamisch berechnetem Status. Optional gefiltert nach kunden_id,
status ('offen'/'erledigt') und wiedervorlage ('heute'/'3tage'/
'ueberfaellig'/'woche'). Für Nicht-SysAdmin/Nicht-AppAdmin nur
Kontaktadressen sichtbarer Projekte. Die Sortierung ist priorisiert:
überfällige Wiedervorlagen, dann Kontakte ohne Notizen, dann der Rest."""
user_id = _get_visible_user_id()
result = tages_workflow.get_tages_workflow_list(
user_id,
request.args.get("kunden_id", type=int),
request.args.get("status"),
request.args.get("wiedervorlage"),
)
return jsonify(result), 200
# --------------------------------------------------------------------------- #
# Tag (hierarchische Taxonomie) #
# --------------------------------------------------------------------------- #
# Tags sind nicht projektbezogen — die projektbezogene Sichtbarkeits-
# Filterung entfaellt. Lesen: alle Mitarbeiter. Schreiben: nur AppAdmin/SysAdmin.
# --------------------------------------------------------------------------- #
@app.get("/xhr/tag/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def tag_list():
"""Liefert alle Tags als flache Liste. Optional nach parent_id gefiltert."""
parent_id = request.args.get("parent_id", type=int)
result = [t.to_dict() for t in tags.get_tags(parent_id)]
return jsonify(result), 200
@app.get("/xhr/tag/hierarchy")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def tag_hierarchy():
"""Liefert die vollstaendige Tag-Hierarchie als verschachtelte Struktur."""
result = tags.get_tag_hierarchy()
return jsonify(result), 200
@app.get("/xhr/tag/get")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def tag_get():
"""Liefert einen einzelnen Tag anhand seiner ID (Query-Param id)."""
tag = tags.get_tag(int(request.args.get("id")))
return jsonify(tag.to_dict()), 200
@app.post("/xhr/tag/create")
# Leeres allowed_roles-Array: Nur AppAdmin/SysAdmin (vom Decorator
# implizit ergaenzt). Tags sind eine globale Taxonomie — die Anlage
# und Aenderung ist ausschliesslich den Administratoren vorbehalten.
@authorisation_required([])
def tag_create():
"""Legt einen neuen Tag an. ID wird serverseitig vergeben."""
tag = tags.create_tag(request.json)
return jsonify(tag.to_dict()), 200
@app.put("/xhr/tag/update")
@authorisation_required([])
def tag_update():
"""Aktualisiert einen bestehenden Tag."""
tag = tags.update_tag(request.json)
return jsonify(tag.to_dict()), 200
@app.delete("/xhr/tag/delete")
@authorisation_required([])
def tag_delete():
"""Loescht einen Tag inkl. aller Nachfahren (kaskadierend) anhand seiner ID."""
tags.delete_tag(int(request.args.get("id")))
return jsonify({}), 200
print("########## crm.routes importiert")
+94 -19
View File
@@ -1,23 +1,98 @@
from waitress import serve
from werkzeug import serving
from approot import init_app
import os
import signal
import subprocess
import sys
import time
def run_server():
try:
if app.config["DEBUG"] is False:
_server = "waitress"
# serve(app, listen='nginx-proxy:8080') # Funktioniert aus unbekannten Gründen nicht. Der Hostname wird im Docker Container nicht korrekt aufgelöst
print(f"Server startet mit Server type: {_server}", flush=True)
serve(app, listen='*:8080')
else:
_server = "werkzeug"
print(f"Server startet mit Server type: {_server}", flush=True)
serving.run_simple("0.0.0.0", 8080, app, use_reloader=True)
except Exception as ex:
print(str(ex))
print(f"Server gestartet. Server type: {_server}", flush=True)
def _is_debug_requested() -> bool:
"""
True, wenn -D übergeben wurde oder APP_DEBUG gesetzt ist.
-D erlaubt den Dev-Start direkt von der Befehlszeile — ohne Devcontainer
und ohne Env-Variable setzen zu müssen. APP_DEBUG steuert den
Devcontainer-Betrieb (docker-compose.dev.yaml).
"""
if "-D" in sys.argv[1:]:
return True
return os.environ.get("APP_DEBUG", "false").lower() in ("true", "1", "yes")
def run_server(debug: bool):
"""
Startet den Server. Prod: Waitress. Dev: Werkzeug mit Reloader.
Der Aufruf erfolgt entweder direkt (Prod) oder als supervidierter
Subprozess (Dev, siehe run_dev_supervised).
debug wird an init_app() durchgereicht, damit app.config["DEBUG"]
konsistent ist — davon hängt außer dem Server-Typ auch die Auswahl
des JS-Bundles ab (routes/general.py: .js im Dev, .min.js in Prod).
"""
from waitress import serve
from werkzeug import serving
from approot import init_app
app = init_app(debug=debug)
if not debug:
print("Server startet mit Server type: waitress", flush=True)
serve(app, listen='*:8080')
else:
print("Server startet mit Server type: werkzeug", flush=True)
serving.run_simple("0.0.0.0", 8080, app, use_reloader=True)
def run_dev_supervised():
"""
Crash-toleranter Dev-Betrieb.
Der Werkzeug-Reloader beendet sich komplett, wenn der App-Prozess beim
Start crasht (z.B. SQLAlchemy-Fehler beim Import der Modelle). Wäre der
Server direkt der Hauptprozess des Containers, würde damit der gesamte
Dev-Container stoppen. Der Supervisor startet den Server daher als
Subprozess und nach einem Abbruch einfach neu — sobald die fehlerhafte
Datei korrigiert ist, läuft der Server automatisch wieder an.
Der Supervisor importiert die Anwendung absichtlich nicht
(run_server importiert approot erst im Subprozess): Ein defekter
Anwendungscode kann ihn nicht mitreißen.
"""
child = None
def _forward_signal(signum, frame):
# SIGTERM/SIGINT an den Subprozess weiterleiten, damit der
# Container sauber heruntergefahren werden kann.
if child is not None and child.poll() is None:
child.terminate()
sys.exit(0)
signal.signal(signal.SIGTERM, _forward_signal)
signal.signal(signal.SIGINT, _forward_signal)
env = dict(os.environ)
env["KBP_DEV_SUPERVISED"] = "true"
while True:
print("=== Supervisor: starte Dev-Server ===", flush=True)
# sys.argv[1:] durchreichen, damit -D (und künftige Parameter)
# im Subprozess ebenfalls wirksam sind.
proc = subprocess.Popen(
[sys.executable, os.path.abspath(__file__)] + sys.argv[1:],
env=env,
)
child = proc
code = proc.wait()
print(f"=== Dev-Server beendet (Exit-Code {code}) — Neustart in 2 Sekunden ===",
flush=True)
time.sleep(2)
if __name__ == '__main__':
app = init_app()
run_server()
_debug = _is_debug_requested()
if _debug and os.environ.get("KBP_DEV_SUPERVISED") != "true":
# Dev-Modus und noch nicht beaufsichtigt: Supervisor werden
# (im Devcontainer ist das PID 1 und hält den Container am Leben)
run_dev_supervised()
else:
# Prod-Modus, oder wir sind bereits der beaufsichtigte
# Dev-Subprozess (der Supervisor setzt KBP_DEV_SUPERVISED):
# als eigentlicher Server starten.
run_server(_debug)
+224 -52
View File
@@ -1,13 +1,17 @@
import m from "mithril"
import {Form, iForm} from "./widgets/form"
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
import {Select, iSelect, iSelectOption} from "./widgets/select"
import {Select, iSelect, MultiSelect, iMultiSelect, iSelectOption} from "./widgets/select"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCallLogREST} from "../models/call_log"
import {iKunde, fetchKunden} from "../models/kunde"
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
import {iTag, fetchTags, berechneWiedervorlageDatum} from "../models/tag"
import {TagBadge} from "./tagbadge"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
const DAUER_UPDATE_INTERVALL = 1000
@@ -21,11 +25,20 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
// da es nicht in den Route-Params enthalten ist.
let projektName: string = ""
let kundenName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
// Eigener Listen-Crumb für die Breadcrumb-Kette.
const ownCrumb = (): iBreadcrumb => ({
route: "/calllogliste",
params: filterProjektId !== null ? {projekt_id: filterProjektId} : {},
label: filterProjektId !== null ? "Notizen: " + projektName : "Gesprächsnotizen"
})
const addCallLogRoute = () => {
const now = new Date()
const datum = now.toISOString().split('T')[0]
@@ -40,32 +53,47 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
telefon: "",
notiz: "",
wiedervorlage_datum: "",
erledigt: false
from_liste: "1"
}
if (filterProjektId !== null) {
params.projekt_id = filterProjektId
params.projekt_name = projektName
params.kunden_name = kundenName
params.return_projekt_id = filterProjektId
params.return_projekt_name = projektName
}
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/call_log", params)
}
const editCallLog = (log: iCallLog, e: Event) => {
e.stopPropagation()
m.route.set("/call_log", {id: log.id})
const params: any = {id: log.id, from_liste: "1"}
if (filterProjektId !== null) {
params.return_projekt_id = filterProjektId
params.return_projekt_name = projektName
}
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/call_log", params)
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let log: iCallLog = node.nodevalue
// Zugewiesene Tags als farbige Badges hinter dem Betreff darstellen.
const tagBadges = (log.tags || []).map((t: iTag) =>
m(TagBadge, {tagData: t, compact: true})
)
return [
m("div", m("strong", log.kunden_name + ": " + log.projekt_name)),
m("span", log.datum),
m("span", log.uhrzeit_beginn),
m("span", log.dauer + " min"),
m("span", log.betreff),
m("span", log.betreff, ...tagBadges),
m("span", log.wiedervorlage_datum),
m("span", log.erledigt ? "Erledigt" : "Offen"),
m("button.w3-button.w3-small", {title: "Gesprächsnotiz bearbeiten", style: {padding: '0 2px'}, onclick: (e: Event) => editCallLog(log, e)}, m("i.material-icons", "edit"))
]
}
@@ -93,6 +121,8 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
filterProjektId = null
projektName = ""
}
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
// Gesprächsnotizen asynchron vom Server laden. Die Filterung
// nach projekt_id erfolgt serverseitig, sofern ein Filter
// gesetzt ist.
@@ -118,6 +148,10 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", projektName ? "Gesprächsnotizen für " + projektName : "Gesprächsnotizen"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: filterProjektId !== null ? "Notizen: " + projektName : "Gesprächsnotizen"
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
@@ -143,6 +177,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
let currentLog: {
id: number
projekt_id: number
kontaktadresse_id: number
projekt_name: string
kunden_name: string
datum: string
@@ -153,10 +188,10 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
telefon: string
notiz: string
wiedervorlage_datum: string
erledigt: boolean
} = {
id: -1,
projekt_id: -1,
kontaktadresse_id: -1,
projekt_name: "",
kunden_name: "",
datum: "",
@@ -166,8 +201,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
name_ansprechpartner: "",
telefon: "",
notiz: "",
wiedervorlage_datum: "",
erledigt: false
wiedervorlage_datum: ""
}
// Ist das Projekt voreingestellt (Liste wurde projektgefiltert)?
let projektVoreingestellt: boolean = false
@@ -183,6 +217,42 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
let kundenListe: iKunde[] = []
let projektListe: iProjekt[] = []
let kontaktadressenListe: iKontaktadresse[] = []
// Im Auswahl-Modus (nicht vorbelegt): gewählte Kunden-ID für die
// abhängige Projekt-Filterung.
let selectedKundenId: number = -1
// Asynchron geladene Tag-Liste (nur zuordenbare, nicht is_parent).
let tagListe: iTag[] = []
// Aktuell ausgewählte Tag-IDs im Formular.
let selectedTagIds: number[] = []
// Wurde die Maske aus der Gesprächsnotizenliste geöffnet? Wenn ja,
// erfolgt die Rückkehr dorthin mit dem ursprünglichen Listenfilter.
let fromListe: boolean = false
// Filter-Kontext der Liste, aus der die Maske geöffnet wurde.
// null bedeutet ungefiltert; ein Wert bedeutet projektgefiltert.
let returnProjektId: number | null = null
let returnProjektName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
// Route-Params für die Rückkehr zur Notizliste; als Closure an den
// DirtyMaskHelper übergeben (greift auf fromListe, returnProjektId,
// currentLog, breadcrumbs zu).
const backParams = (): any => {
const params: any = {}
if (fromListe) {
if (returnProjektId !== null) {
params.projekt_id = returnProjektId
params.projekt_name = returnProjektName
}
} else if (currentLog.projekt_id !== -1) {
params.projekt_id = currentLog.projekt_id
params.projekt_name = currentLog.projekt_name
}
const listBc = serializeBc(breadcrumbs.slice(0, -1))
if (listBc) params.bc = listBc
return params
}
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/calllogliste", backParams)
/**
* Formatiert verstrichene Sekunden als mm:ss.
@@ -197,12 +267,23 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
oninit: (vnode) => {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Rückkehr-Kontext: wurde die Maske aus der Liste geöffnet und
// mit welchem Filter soll zurückgekehrt werden?
fromListe = !!(routeParams && routeParams.from_liste)
if (routeParams && routeParams.return_projekt_id) {
returnProjektId = parseInt(routeParams.return_projekt_id)
returnProjektName = routeParams.return_projekt_name || ""
}
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) {
// Gesprächsnotiz frisch vom Server holen.
fetchCallLogById(id).then((result) => {
currentLog = result
projektVoreingestellt = true
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
ansprechpartnerVoreingestellt = currentLog.kontaktadresse_id > 0
selectedTagIds = (result.tags || []).map((t: iTag) => t.id)
}).catch((e) => showServerError(e))
} else {
projektVoreingestellt = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) !== -1 : false
@@ -217,8 +298,10 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
currentLog.telefon = routeParams && routeParams.telefon ? routeParams.telefon : ""
currentLog.notiz = routeParams && routeParams.notiz ? routeParams.notiz : ""
currentLog.wiedervorlage_datum = routeParams && routeParams.wiedervorlage_datum ? routeParams.wiedervorlage_datum : ""
currentLog.erledigt = routeParams && (routeParams.erledigt === true || routeParams.erledigt === "true")
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
// Kontaktadresse-ID aus den Route-Params übernehmen, wenn die
// Maske aus der Kontaktadressenliste geöffnet wurde.
currentLog.kontaktadresse_id = routeParams && routeParams.kontaktadresse_id ? parseInt(routeParams.kontaktadresse_id) : -1
ansprechpartnerVoreingestellt = currentLog.kontaktadresse_id > 0
// Bei Neuanlage Startzeit merken und Dauer-Timer starten.
startZeit = new Date()
timerId = window.setInterval(() => {
@@ -234,28 +317,20 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
fetchKunden().then((result) => { kundenListe = result }).catch((e) => showServerError(e))
fetchProjekte().then((result) => { projektListe = result }).catch((e) => showServerError(e))
fetchKontaktadressen().then((result) => { kontaktadressenListe = result }).catch((e) => showServerError(e))
// Tags laden — nur zuordenbare (is_parent=false) Tags.
fetchTags().then((result) => { tagListe = result.filter((t: iTag) => !t.is_parent) }).catch((e) => showServerError(e))
},
onremove: (vnode) => {
if (timerId != null) { window.clearInterval(timerId) }
guard.cleanup()
},
view: (vnode) => {
// kunden_id aus dem geladenen Projekt ableiten (fuer abhaengiges
// Projekt-Select). Wird nur bei nicht voreingestelltem Projekt
// benoetigt.
const internKundenId = (() => {
if (currentLog.projekt_id !== -1) {
const p = projektListe.find((p: iProjekt) => p.id === currentLog.projekt_id)
return p ? p.kunden_id : -1
}
return -1
})()
const kundenOptionen: iSelectOption[] = kundenListe.map((k: iKunde) => {
return {value: k.id, label: k.name}
})
// Projekt-Optionen sind vom gewählten Kunden abhängig.
const projektOptionen: iSelectOption[] = projektListe
.filter((p: iProjekt) => internKundenId === -1 || p.kunden_id === internKundenId)
.filter((p: iProjekt) => selectedKundenId === -1 || p.kunden_id === selectedKundenId)
.map((p: iProjekt) => {
return {value: p.id, label: p.projektname}
})
@@ -267,58 +342,124 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
})
: []
// Tag-Optionen und Farb-Map für die Mehrfachauswahl. Als content
// wird das fertige TagBadge übergeben — das Auswahl-Modal zeigt
// damit den Farbchip mit Namen und die Workflow-Icons.
const tagOptionen: iSelectOption[] = tagListe.map((t: iTag) => {
return {value: t.id, label: t.name, content: m(TagBadge, {tagData: t})}
})
const tagColors: Map<number, string> = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string]))
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
const gespeichertesLog: iCallLog = {
id: currentLog.id,
projekt_id: currentLog.projekt_id,
projekt_id: projektVoreingestellt
? currentLog.projekt_id
: parseInt((formData.get("projekt_id_select") as string) || "-1"),
// Pflichtfeld: voreingestellt aus dem currentLog, sonst
// aus dem Select-Widget (kontaktadresse_id ist im Server
// ein Pflichtfeld, ohne wird Bad Request geliefert).
kontaktadresse_id: ansprechpartnerVoreingestellt
? currentLog.kontaktadresse_id
: parseInt((formData.get("kontaktadresse_id") as string) || "-1"),
// Ersteller wird serverseitig aus current_user.id
// gesetzt — hier nur Platzhalter, mitarbeiter_name
// wird ausschließlich vom Server geliefert.
user_id: null,
mitarbeiter_name: null,
projekt_name: "",
kunden_name: "",
datum: (formData.get("datum") as string) || "",
datum: currentLog.datum || "",
uhrzeit_beginn: (formData.get("uhrzeit_beginn") as string) || "",
dauer: (formData.get("dauer") as string) || "",
betreff: (formData.get("betreff") as string) || "",
name_ansprechpartner: (formData.get("name_ansprechpartner") as string) || "",
telefon: (formData.get("telefon") as string) || "",
notiz: (formData.get("notiz") as string) || "",
wiedervorlage_datum: (formData.get("wiedervorlage_datum") as string) || "",
erledigt: formData.get("erledigt") === "on"
wiedervorlage_datum: currentLog.wiedervorlage_datum || "",
tag_ids: formData.getAll("tag_ids").map((v) => parseInt(v as string)).filter((n) => !isNaN(n))
}
if (currentLog.id === -1) {
createCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})).catch((e) => showServerError(e))
createCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})).catch((e) => showServerError(e))
updateCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
const cancel = (e: Event) => {
m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})
}
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: currentLog.id === -1 ? "Neue Gesprächsnotiz" : "Gesprächsnotiz",
onnavigate: guardedNavigate
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}),
// Kunde und Projekt als Read-Only-Text, da voreingestellt.
m("label", "Kunde",
m("input.w3-input.w3-border", {
type: "text",
value: currentLog.kunden_name,
readonly: true,
style: {'margin-bottom': '5px'}
})),
m("label", "Projekt",
m("input.w3-input.w3-border", {
type: "text",
value: currentLog.projekt_name,
readonly: true,
style: {'margin-bottom': '5px'}
})),
m(Form, guard.formAttrs(submit) as iForm, [
projektVoreingestellt
? m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id})
: null,
// Kunde und Projekt: vorbelegt als Read-Only-Text,
// nicht vorbelegt als auswählbare Select-Widgets.
projektVoreingestellt
? [
m("label", "Kunde",
m("input.w3-input.w3-border", {
type: "text",
value: currentLog.kunden_name,
readonly: true,
style: {'margin-bottom': '5px'}
})),
m("label", "Projekt",
m("input.w3-input.w3-border", {
type: "text",
value: currentLog.projekt_name,
readonly: true,
style: {'margin-bottom': '5px'}
}))
]
: [
m("label", "Kunde", m(Select, {
name: "kunden_id",
value: selectedKundenId,
options: kundenOptionen,
required: true,
onchanged: (kundenId: number) => {
selectedKundenId = kundenId
// Projekt zurücksetzen, da es zum
// alten Kunden gehörte.
currentLog.projekt_id = -1
currentLog.projekt_name = ""
currentLog.kunden_name = kundenListe.find(k => k.id === kundenId)?.name || ""
guard.markDirty()
}
} as iSelect)),
m("label", "Projekt", m(Select, {
name: "projekt_id_select",
value: currentLog.projekt_id,
options: projektOptionen,
required: true,
onchanged: (projektId: number) => {
const p = projektListe.find(pr => pr.id === projektId)
currentLog.projekt_id = projektId
currentLog.projekt_name = p?.projektname || ""
currentLog.kunden_name = p?.kunden_name || ""
guard.markDirty()
}
} as iSelect))
],
// Datum, Startzeit und Dauer in einer Zeile.
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
m("label", {style: {flex: '1'}}, "Datum",
m(Input, {name: "datum", value: currentLog.datum, type: "date"} as iInput)),
m(Input, {
name: "datum",
value: currentLog.datum,
type: "date",
oninput: (e: Event) => {
currentLog.datum = (e.target as HTMLInputElement).value
guard.markDirty()
}
} as iInput)),
m("label", {style: {flex: '1'}}, "Uhrzeit Beginn",
m(Input, {name: "uhrzeit_beginn", value: currentLog.uhrzeit_beginn, type: "time"} as iInput)),
m("label", {style: {flex: '1'}}, "Dauer (mm:ss)",
@@ -341,11 +482,13 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
name: "kontaktadresse_id",
value: -1,
options: kontaktadressenOptionen,
required: true,
onchanged: (kontaktadresseId: number) => {
const ka = kontaktadressenListe.find((k: iKontaktadresse) => k.id === kontaktadresseId)
if (ka) {
currentLog.name_ansprechpartner = ka.name
currentLog.telefon = ka.telefon
guard.markDirty()
}
}
} as iSelect)),
@@ -353,8 +496,37 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
m(Input, {name: "telefon", value: currentLog.telefon, type: "tel"} as iInput))
),
m("label", "Notiz", m(Textarea, {name: "notiz", value: currentLog.notiz, rows: 4} as iTextarea)),
m("label", "Wiedervorlage Datum", m(Input, {name: "wiedervorlage_datum", value: currentLog.wiedervorlage_datum, type: "date"} as iInput)),
m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt")
m("label", "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.splice(0, selectedTagIds.length)
Array.prototype.push.apply(selectedTagIds, newSelection)
const vorschlag = berechneWiedervorlageDatum(
newSelection,
tagListe,
currentLog.datum || new Date().toISOString().split('T')[0]
)
if (vorschlag) {
currentLog.wiedervorlage_datum = vorschlag
}
guard.markDirty()
}
} as iMultiSelect)),
m("label", "Wiedervorlage Datum", m(Input, {
name: "wiedervorlage_datum",
value: currentLog.wiedervorlage_datum,
type: "date",
oninput: (e: Event) => {
currentLog.wiedervorlage_datum = (e.target as HTMLInputElement).value
guard.markDirty()
}
} as iInput))
])
)
)
+48 -7
View File
@@ -2,9 +2,11 @@ import m from "mithril"
import {Form, iForm} from "./widgets/form"
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
import {iKontakt, fetchKontakte, fetchKontaktById, createKontaktREST, updateKontaktREST} from "../models/kontakt"
import {iKunde, fetchKundeById} from "../models/kunde"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const kontaktliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = []
@@ -13,11 +15,20 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
// Kundenname für die Überschrift, asynchron per fetchKundeById
// aufgelöst, falls nicht in den Route-Params enthalten.
let kundenName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
// Eigener Listen-Crumb für die Breadcrumb-Kette.
const ownCrumb = (): iBreadcrumb => ({
route: "/kontaktliste",
params: filterKundenId !== null ? {kunden_id: filterKundenId} : {},
label: filterKundenId !== null ? "Kontakte: " + kundenName : "Kontakte"
})
const addKontaktRoute = () => {
const params: any = {id: -1}
// Nur bei kundengefilterter Liste den Kunden vorbelegen.
@@ -25,12 +36,15 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
params.kunden_id = filterKundenId
params.kunden_name = kundenName
}
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/kontakt", params)
}
const editKontakt = (kontakt: iKontakt, e: Event) => {
e.stopPropagation()
m.route.set("/kontakt", {id: kontakt.id})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/kontakt", {id: kontakt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
@@ -66,6 +80,8 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
filterKundenId = null
kundenName = ""
}
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
// Kontakte asynchron vom Server laden. Die Filterung nach
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
fetchKontakte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
@@ -86,6 +102,10 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", kundenName ? "Kontaktliste für " + kundenName : "Kontaktliste"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: filterKundenId !== null ? "Kontakte: " + kundenName : "Kontakte"
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
@@ -129,11 +149,27 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
email: "",
notiz: ""
}
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
// Route-Params für die Rückkehr zur Kontaktliste; als Closure an den
// DirtyMaskHelper übergeben, da dieser die maskenspezifischen Variablen
// (currentKontakt, breadcrumbs) nicht kennt.
const backParams = (): any => {
const listBc = serializeBc(breadcrumbs.slice(0, -1))
const params: any = {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name}
if (listBc) params.bc = listBc
return params
}
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/kontaktliste", backParams)
return {
oninit: (vnode) => {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) {
// Kontaktdaten frisch vom Server holen, um zwischenzeitliche
// Änderungen anderer Nutzer nicht zu überschreiben.
@@ -147,6 +183,9 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
currentKontakt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
}
},
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
@@ -162,21 +201,23 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
notiz: (formData.get("notiz") as string) || ""
}
if (currentKontakt.id === -1) {
createKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})).catch((e) => showServerError(e))
createKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})).catch((e) => showServerError(e))
updateKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
const cancel = (e: Event) => {
m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})
}
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentKontakt.id === -1
? "Neuer Kontakt für " + currentKontakt.kunden_name
: "Kontakt-Stammblatt"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: currentKontakt.id === -1 ? "Neuer Kontakt" : "Kontakt-Stammblatt",
onnavigate: guardedNavigate
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m(Form, guard.formAttrs(submit) as iForm, [
m("input", {type: "hidden", name: "kunden_id", value: currentKontakt.kunden_id}),
m("label", "Anrede", m(Input, {name: "anrede", value: currentKontakt.anrede, type: "text"} as iInput)),
m("label", "Name", m(Input, {name: "name", value: currentKontakt.name, type: "text", required: true} as iInput)),
+67 -9
View File
@@ -2,9 +2,11 @@ import m from "mithril"
import {Form, iForm} from "./widgets/form"
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
import {iKontaktadresse, fetchKontaktadressen, fetchKontaktadresseById, createKontaktadresseREST, updateKontaktadresseREST} from "../models/kontaktadresse"
import {iProjekt, fetchProjektById} from "../models/projekt"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = []
@@ -13,23 +15,35 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
// Projektname für die Überschrift, asynchron per fetchProjektById
// aufgelöst, falls nicht in den Route-Params enthalten.
let projektName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
// Eigener Listen-Crumb für die Breadcrumb-Kette.
const ownCrumb = (): iBreadcrumb => ({
route: "/kontaktadresseliste",
params: filterProjektId !== null ? {projekt_id: filterProjektId} : {},
label: filterProjektId !== null ? "Kontaktadressen: " + projektName : "Kontaktadressen"
})
const addKontaktadresseRoute = () => {
const params: any = {id: -1}
if (filterProjektId !== null) {
params.projekt_id = filterProjektId
params.projekt_name = projektName
}
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/kontaktadresse", params)
}
const editKontaktadresse = (kontaktadresse: iKontaktadresse, e: Event) => {
e.stopPropagation()
m.route.set("/kontaktadresse", {id: kontaktadresse.id})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/kontaktadresse", {id: kontaktadresse.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const addCallLog = (kontaktadresse: iKontaktadresse, e: Event) => {
@@ -37,10 +51,20 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
const now = new Date()
const datum = now.toISOString().split('T')[0]
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
// Breadcrumb-Kette um Kontaktadressenliste und Notizliste
// erweitern, da die Maske nach Speichern/Abbrechen in die
// projektgefilterte Notizliste zurückkehrt.
const chain = [...breadcrumbs, ownCrumb(), {
route: "/calllogliste",
params: {projekt_id: kontaktadresse.projekt_id},
label: "Notizen: " + kontaktadresse.projekt_name
} as iBreadcrumb]
m.route.set("/call_log", {
id: -1,
projekt_id: kontaktadresse.projekt_id,
projekt_name: kontaktadresse.projekt_name,
// Kontaktadresse als Pflichtfeld an die Notizmaske durchreichen
kontaktadresse_id: kontaktadresse.id,
datum: datum,
uhrzeit_beginn: uhrzeit,
dauer: "",
@@ -49,7 +73,7 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
telefon: kontaktadresse.telefon,
notiz: "",
wiedervorlage_datum: "",
erledigt: false
bc: serializeBc(chain)
})
}
@@ -92,6 +116,8 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
filterProjektId = null
projektName = ""
}
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
// Kontaktadressen asynchron vom Server laden. Die Filterung
// nach projekt_id erfolgt serverseitig, sofern ein Filter
// gesetzt ist.
@@ -113,6 +139,10 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", projektName ? "Kontaktadressen für " + projektName : "Kontaktadressen"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: filterProjektId !== null ? "Kontaktadressen: " + projektName : "Kontaktadressen"
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
@@ -166,11 +196,26 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
land: "",
notiz: ""
}
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
// Route-Params für die Rückkehr zur Kontaktadressenliste; als Closure
// an den DirtyMaskHelper übergeben.
const backParams = (): any => {
const listBc = serializeBc(breadcrumbs.slice(0, -1))
const params: any = {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name}
if (listBc) params.bc = listBc
return params
}
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/kontaktadresseliste", backParams)
return {
oninit: (vnode) => {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) {
// Kontaktadresse frisch vom Server holen.
fetchKontaktadresseById(id).then((result) => {
@@ -182,6 +227,9 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
currentKontaktadresse.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
}
},
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
@@ -189,6 +237,10 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
id: currentKontaktadresse.id,
projekt_id: currentKontaktadresse.projekt_id,
projekt_name: "",
projekt_zweck: "",
// Server-seitig aus den Relationships abgeleitet;
// Platzhalter beim Senden.
kunden_name: "",
anrede: (formData.get("anrede") as string) || "",
name: (formData.get("name") as string) || "",
firma: (formData.get("firma") as string) || "",
@@ -199,24 +251,30 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
plz: (formData.get("plz") as string) || "",
ort: (formData.get("ort") as string) || "",
land: (formData.get("land") as string) || "",
notiz: (formData.get("notiz") as string) || ""
notiz: (formData.get("notiz") as string) || "",
// Server-seitig dynamisch berechnet; Platzhalter beim Senden.
is_erledigt: false
}
if (currentKontaktadresse.id === -1) {
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})).catch((e) => showServerError(e))
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})).catch((e) => showServerError(e))
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
const cancel = (e: Event) => {
m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})
}
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentKontaktadresse.id === -1
? "Neue Kontaktadresse für " + currentKontaktadresse.projekt_name
: "Kontaktadresse-Stammblatt"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: currentKontaktadresse.id === -1
? "Neue Kontaktadresse"
: "Kontaktadresse-Stammblatt",
onnavigate: guardedNavigate
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m(Form, guard.formAttrs(submit) as iForm, [
m("input", {type: "hidden", name: "projekt_id", value: currentKontaktadresse.projekt_id}),
m("label", "Anrede", m(Input, {name: "anrede", value: currentKontaktadresse.anrede, type: "text"} as iInput)),
m("label", "Name", m(Input, {name: "name", value: currentKontaktadresse.name, type: "text", required: true} as iInput)),
+46 -11
View File
@@ -2,8 +2,10 @@ import m from "mithril"
import {Form, iForm} from "./widgets/form"
import { Input, iInput} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
import {iKunde, fetchKunden, fetchKundeById, createKundeREST, updateKundeREST} from "../models/kunde"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const kunde: m.ClosureComponent = (vnode) => {
let currentKunde: iKunde = {
@@ -14,11 +16,18 @@ export const kunde: m.ClosureComponent = (vnode) => {
ort: "",
land: ""
}
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/kundenliste")
return {
oninit: (vnode) => {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) {
// Kundendaten frisch vom Server holen, um zwischenzeitliche
// Änderungen anderer Nutzer nicht zu überschreiben.
@@ -27,6 +36,9 @@ export const kunde: m.ClosureComponent = (vnode) => {
}).catch((e) => showServerError(e))
}
},
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
@@ -39,26 +51,28 @@ export const kunde: m.ClosureComponent = (vnode) => {
land: (formData.get("land") as string) || ""
}
if (currentKunde.id === -1) {
createKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e))
createKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e))
updateKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
const cancel = (e: Event) => {
m.route.set("/kundenliste")
}
return m("div.w3-container.w3-auto.form-view-container",
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
m("h1", currentKunde.id === -1 ? "Neuen Kunden anlegen" : "Kundenstammblatt"),
currentKunde.id !== -1
? m("button.w3-button.w3-theme",
{onclick: () => m.route.set("/kontaktliste", {kunden_id: currentKunde.id})},
{onclick: (e: MouseEvent) => guardedNavigate("/kontaktliste", {kunden_id: currentKunde.id}, e)},
m("i.material-icons", "contact_page"), " Kontakte")
: null
),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: currentKunde.id === -1 ? "Neuer Kunde" : "Kundenstammblatt",
onnavigate: guardedNavigate
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m(Form, guard.formAttrs(submit) as iForm, [
m("label", "Kundenname", m(Input, {name: "kundenname", value: currentKunde.name, type: "text", required: true} as iInput)),
m("label", "Straße", m(Input, {name: "strasse", value: currentKunde.strasse, type: "text"} as iInput)),
m("label", "PLZ", m(Input, {name: "plz", value: currentKunde.plz, type: "text"} as iInput)),
@@ -74,28 +88,43 @@ export const kunde: m.ClosureComponent = (vnode) => {
export const kundenliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
// Eigener Listen-Crumb für die Breadcrumb-Kette.
const ownCrumb = (): iBreadcrumb => ({
route: "/kundenliste",
params: {},
label: "Kunden"
})
const editKunde = (kunde: iKunde, e: Event) => {
e.stopPropagation()
m.route.set("/kunde", {id: kunde.id})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/kunde", {id: kunde.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const showProjekte = (kunde: iKunde, e: Event) => {
e.stopPropagation()
m.route.set("/projektliste", {kunden_id: kunde.id})
// Breadcrumb-Kette: Kundenliste als Vorgänger der Projektliste.
const bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/projektliste", {kunden_id: kunde.id, bc})
}
const showKontakte = (kunde: iKunde, e: Event) => {
e.stopPropagation()
m.route.set("/kontaktliste", {kunden_id: kunde.id})
// Breadcrumb-Kette: Kundenliste als Vorgänger der Kontaktliste.
const bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/kontaktliste", {kunden_id: kunde.id, bc})
}
const addKundeRoute = () => {
m.route.set("/kunde", {id: -1})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/kunde", {id: -1, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
@@ -127,6 +156,8 @@ export const kundenliste: m.ClosureComponent = (vnode) => {
return {
oninit: (vnode) => {
// Breadcrumb-Kette aus Route-Param parsen (bei Top-Level leer).
breadcrumbs = parseBc(m.route.param() && m.route.param().bc)
// Kundenliste asynchron vom Server laden. m.request löst nach
// Promise-Auflösung automatisch ein Redraw aus.
fetchKunden().then((kunden: iKunde[]) => {
@@ -141,6 +172,10 @@ export const kundenliste: m.ClosureComponent = (vnode) => {
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", "Kundenliste"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: "Kunden"
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
+67 -15
View File
@@ -1,10 +1,12 @@
import m from "mithril"
import {Form, iForm} from "./widgets/form"
import { Input, iInput} from "./widgets/inputs"
import {Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt"
import {iKunde, fetchKundeById} from "../models/kunde"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const projektliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = []
@@ -13,11 +15,20 @@ export const projektliste: m.ClosureComponent = (vnode) => {
// Kundenname für die Überschrift, asynchron per fetchKundeById
// aufgelöst, falls nicht in den Route-Params enthalten.
let kundenName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
// Eigener Listen-Crumb für die Breadcrumb-Kette.
const ownCrumb = (): iBreadcrumb => ({
route: "/projektliste",
params: filterKundenId !== null ? {kunden_id: filterKundenId, kunden_name: kundenName} : {},
label: filterKundenId !== null ? "Projekte: " + kundenName : "Projekte"
})
const addProjektRoute = () => {
const params: any = {id: -1}
// Nur bei kundengefilterter Liste den Kunden vorbelegen.
@@ -25,22 +36,27 @@ export const projektliste: m.ClosureComponent = (vnode) => {
params.kunden_id = filterKundenId
params.kunden_name = kundenName
}
// Breadcrumb-Kette um eigene Position erweitern.
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
m.route.set("/projekt", params)
}
const editProjekt = (projekt: iProjekt, e: Event) => {
e.stopPropagation()
m.route.set("/projekt", {id: projekt.id})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/projekt", {id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const showCallLogs = (projekt: iProjekt, e: Event) => {
e.stopPropagation()
m.route.set("/calllogliste", {projekt_id: projekt.id})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/calllogliste", {projekt_id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const showKontaktadressen = (projekt: iProjekt, e: Event) => {
e.stopPropagation()
m.route.set("/kontaktadresseliste", {projekt_id: projekt.id})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/kontaktadresseliste", {projekt_id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const addCallLog = (projekt: iProjekt, e: Event) => {
@@ -48,6 +64,14 @@ export const projektliste: m.ClosureComponent = (vnode) => {
const now = new Date()
const datum = now.toISOString().split('T')[0]
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
// Breadcrumb-Kette um Projektliste und Notizliste erweitern, da
// die Maske nach Speichern/Abbrechen in die projektgefilterte
// Notizliste zurückkehrt.
const chain = [...breadcrumbs, ownCrumb(), {
route: "/calllogliste",
params: {projekt_id: projekt.id},
label: "Notizen: " + projekt.projektname
} as iBreadcrumb]
m.route.set("/call_log", {
id: -1,
kunden_name: projekt.kunden_name,
@@ -61,7 +85,7 @@ export const projektliste: m.ClosureComponent = (vnode) => {
telefon: "",
notiz: "",
wiedervorlage_datum: "",
erledigt: false
bc: serializeBc(chain)
})
}
@@ -103,6 +127,8 @@ export const projektliste: m.ClosureComponent = (vnode) => {
filterKundenId = null
kundenName = ""
}
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
// Projekte asynchron vom Server laden. Die Filterung nach
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
fetchProjekte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
@@ -123,6 +149,10 @@ export const projektliste: m.ClosureComponent = (vnode) => {
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", kundenName ? "Projektliste für " + kundenName : "Projektliste"),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: kundenName ? "Projekte: " + kundenName : "Projekte"
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
@@ -162,11 +192,26 @@ export const Projekt: m.ClosureComponent = (vnode) => {
verantwortlicher: "",
startdatum: ""
}
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
// Route-Params für die Rückkehr zur Projektliste; als Closure an den
// DirtyMaskHelper übergeben.
const backParams = (): any => {
const listBc = serializeBc(breadcrumbs.slice(0, -1))
const params: any = {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name}
if (listBc) params.bc = listBc
return params
}
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/projektliste", backParams)
return {
oninit: (vnode) => {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) {
// Projektdaten frisch vom Server holen, um zwischenzeitliche
// Änderungen anderer Nutzer nicht zu überschreiben.
@@ -180,6 +225,9 @@ export const Projekt: m.ClosureComponent = (vnode) => {
currentProjekt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
}
},
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
@@ -190,18 +238,17 @@ export const Projekt: m.ClosureComponent = (vnode) => {
projektname: (formData.get("projektname") as string) || "",
zweck: (formData.get("zweck") as string) || "",
verantwortlicher: (formData.get("verantwortlicher") as string) || "",
startdatum: (formData.get("startdatum") as string) || ""
startdatum: (formData.get("startdatum") as string) || "",
// Server-seitig dynamisch berechnet; Platzhalter beim Senden.
is_erledigt: false
}
if (currentProjekt.id === -1) {
createProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})).catch((e) => showServerError(e))
createProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})).catch((e) => showServerError(e))
updateProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
const cancel = (e: Event) => {
m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})
}
return m("div.w3-container.w3-auto.form-view-container",
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
m("h1", currentProjekt.id === -1
@@ -210,20 +257,25 @@ export const Projekt: m.ClosureComponent = (vnode) => {
currentProjekt.id !== -1
? [
m("button.w3-button.w3-theme",
{onclick: () => m.route.set("/kontaktadresseliste", {projekt_id: currentProjekt.id})},
{onclick: (e: MouseEvent) => guardedNavigate("/kontaktadresseliste", {projekt_id: currentProjekt.id}, e)},
m("i.material-icons", "contact_page"), " Kontaktadressen"),
m("button.w3-button.w3-theme",
{style: {'margin-left': '4px'},
onclick: () => m.route.set("/projektuserliste", {projekt_id: currentProjekt.id})},
onclick: (e: MouseEvent) => guardedNavigate("/projektuserliste", {projekt_id: currentProjekt.id}, e)},
m("i.material-icons", "group"), " Nutzerberechtigungen")
]
: null
),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: currentProjekt.id === -1 ? "Neues Projekt" : "Projekt-Stammblatt",
onnavigate: guardedNavigate
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m(Form, guard.formAttrs(submit) as iForm, [
m("input", {type: "hidden", name: "kunden_id", value: currentProjekt.kunden_id}),
m("label", "Projektname", m(Input, {name: "projektname", value: currentProjekt.projektname, type: "text", required: true} as iInput)),
m("label", "Zweck", m(Input, {name: "zweck", value: currentProjekt.zweck, type: "text"} as iInput)),
m("label", "Aufgabenbeschreibung", m(Textarea, {name: "zweck", value: currentProjekt.zweck, rows: 4} as iTextarea)),
m("label", "Verantwortlicher", m(Input, {name: "verantwortlicher", value: currentProjekt.verantwortlicher, type: "text"} as iInput)),
m("label", "Startdatum", m(Input, {name: "startdatum", value: currentProjekt.startdatum, type: "date"} as iInput))
])
+97
View File
@@ -0,0 +1,97 @@
import m from "mithril"
import {iTag} from "../models/tag"
/**
* Der Vnode der Komponente 'TagBadge' akzeptiert die folgenden Parameter:
* @property {iTag} tagData - Der darzustellende Tag. Achtung: Das Attribut
* darf nicht 'tag' heißen — Mithril behandelt ein attrs-
* Objekt mit einer 'tag'-Property als Vnode und verschiebt
* es in die children (siehe hyperscriptVnode.js).
* @property {boolean} compact (optional) - kompakte Darstellung für dichte
* Listen/Tabellen (kleinere Schrift, engeres Padding);
* Default ist die Standarddarstellung (fett)
* @property {string} title (optional) - Tooltip des Farbchips
* (Default: Tag-Name)
*/
export interface iTagBadge {
tagData: iTag
compact?: boolean
title?: string
}
/**
* Farbige Badge-Komponente für einen Tag: rendert den Tag-Namen als Chip in
* der Tag-Farbe und dahinter automatisch die Workflow-Zusatzicons —
* back_hand (rot) für erledigende Tags, schedule + Tagesabstand (blau) für
* Tags, die eine Wiedervorlage auslösen (jeweils mit Tooltip). Parent-Tags
* erhalten keine Zusatzicons.
*
* Die Komponente kapselt die Darstellung zentral, damit alle Stellen
* (Notiz-Listen, Workflow-Liste, Historie, Auswahl-Modals, Tag-Liste)
* einheitlich aussehen. Aufrufer geben nur den Tag an; Name, Farbe und
* Zusatzicons ergeben sich aus den Tag-Daten.
*/
export const TagBadge: m.ClosureComponent<iTagBadge> = () => {
return {
view: (vnode) => {
const tag = vnode.attrs.tagData
const compact = vnode.attrs.compact === true
// Farbiger Chip mit dem Tag-Namen. Bei fehlender Farbe grau.
const chip = m("span", {
style: {
backgroundColor: tag.color || '#999',
color: 'white',
padding: compact ? '1px 5px' : '2px 6px',
'border-radius': '3px',
'font-size': compact ? '11px' : undefined,
fontWeight: compact ? undefined : 'bold',
display: 'inline-block',
marginRight: '4px'
},
title: vnode.attrs.title || tag.name
}, tag.name)
// Workflow-Zusatzicons hinter dem Chip — fest programmiert aus
// den Tag-Flags, nicht per Attribut steuerbar. Parent-Tags sind
// nicht zuordenbar und erhalten daher keine Icons.
const icons: m.Child[] = []
if (!tag.is_parent) {
if (tag.is_erledigend) {
icons.push(m("i.material-icons", {
title: "Erledigend: Kontakt gilt als erledigt",
style: {
verticalAlign: 'middle',
fontSize: '16px',
color: '#d32f2f'
}
}, "back_hand"))
}
if (tag.loest_wiedervorlage_aus) {
icons.push(m("span", {
title: "Löst Wiedervorlage aus: " + (tag.wiedervorlage_abstand_tage ?? 0) + " Tage",
style: {
verticalAlign: 'middle',
fontSize: '12px',
color: '#1565c0'
}
},
m("i.material-icons", {
style: {
verticalAlign: 'middle',
marginRight: '2px',
fontSize: '16px'
}
}, "schedule"),
tag.wiedervorlage_abstand_tage != null ? tag.wiedervorlage_abstand_tage + "d" : ""))
}
}
return m("span", {
style: {
display: 'inline-flex',
alignItems: 'center',
marginLeft: '4px',
whiteSpace: 'nowrap'
}
}, chip, icons)
}
}
}
+960
View File
@@ -0,0 +1,960 @@
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"
import {TagBadge} from "./tagbadge"
// ============================================================================
// 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(TagBadge, {tagData: t, compact: true})
)
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. Als content
// wird das fertige TagBadge übergeben — das Auswahl-Modal zeigt
// damit den Farbchip mit Namen und automatisch die Workflow-Icons
// (back_hand = erledigend, schedule + Tagesabstand = löst
// Wiedervorlage aus). label bleibt als Suchbasis gesetzt.
const tagOptionen: iSelectOption[] = tagListe.map((t: iTag) => {
return {value: t.id, label: t.name, content: m(TagBadge, {tagData: t})}
})
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 : "",
// Position des Ansprechpartners in Klammern
// hinter dem Namen (nur wenn gesetzt).
kontaktadresse && kontaktadresse.position
? " (" + kontaktadresse.position + ")" : "",
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(TagBadge, {tagData: t, compact: true}))),
m("td", {style: {'white-space': 'nowrap'}}, log.wiedervorlage_datum || ""))
)
)
)
)
])
)
)
}
}
}
+314 -570
View File
@@ -1,7 +1,13 @@
import m from "mithril"
import {Form, iForm} from "./widgets/form"
import { Input, iInput} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable} from "./widgets/Treetable_V2"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {ModalType, ModalView, iModalView} from "./widgets/Modal"
import {gridMountpoints} from "./mainlayout"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
import {iTag, iTagWithChildren, fetchTags, fetchTagHierarchy, fetchTagById, createTagREST, updateTagREST, deleteTagREST} from "../models/tag"
import {TagBadge} from "./tagbadge"
const colorPalette: string[] = [
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
@@ -10,543 +16,241 @@ const colorPalette: string[] = [
]
// ============================================================================
// INTERFACES FÜR HIERARCHISCHE TAGS
// HILFSFUNKTIONEN
// ============================================================================
/**
* Basis-Tag-Interface für die hierarchische Struktur.
* Erzeugt Treenodes aus der vom Server geladenen Hierarchie.
* Beruecksichtigt die Verschachtelung der Tags.
*/
export interface iTag {
id: number; // Numerische ID (für Kompatibilität mit bestehendem Code)
name: string; // Anzeigename des Tags
description: string; // Beschreibung
color: string; // Farbcode (z. B. "#ff0000")
parentId: number | null; // Referenz auf den Eltern-Tag (null = Wurzelebene)
icon?: string; // Optional: Icon-Name oder URL
order?: number; // Optional: Sortierreihenfolge innerhalb der Ebene
}
/**
* Tag mit verschachtelten Kindern für die Hierarchie-Darstellung.
*/
interface iTagWithChildren extends iTag {
children: iTagWithChildren[];
}
// ============================================================================
// BEISPIELDATEN (HIERARCHISCH)
// ============================================================================
/**
* Flache Liste aller Tags (für Datenbank-ähnliche Speicherung).
* Hierarchie wird über parentId abgebildet.
*/
const tagDaten: iTag[] = [
// ========== WURZEL-TAGS (Top-Level) ==========
{
id: 1,
name: "Wichtig",
description: "Hochpriorisierte Aufgaben",
color: "#ff0000",
parentId: null,
icon: "priority_high",
order: 1,
},
{
id: 2,
name: "Kunde",
description: "Kundenbezogene Tags",
color: "#00ff00",
parentId: null,
icon: "person",
order: 2,
},
{
id: 3,
name: "Projekt",
description: "Projektbezogene Tags",
color: "#0000ff",
parentId: null,
icon: "folder",
order: 3,
},
{
id: 4,
name: "Follow-up",
description: "Nachverfolgung erforderlich",
color: "#ffff00",
parentId: null,
icon: "schedule",
order: 4,
},
{
id: 5,
name: "Archiv",
description: "Archivierte Einträge",
color: "#808080",
parentId: null,
icon: "archive",
order: 5,
},
// ========== TECHNISCHE KATEGORIEN (neu) ==========
{
id: 100,
name: "Backend",
description: "Server-seitige Technologien und Frameworks",
color: "#4285F4",
parentId: null,
icon: "server",
order: 6,
},
{
id: 101,
name: "Frontend",
description: "Client-seitige Technologien und Frameworks",
color: "#34A853",
parentId: null,
icon: "browser",
order: 7,
},
{
id: 102,
name: "DevOps",
description: "Infrastruktur, Deployment und CI/CD",
color: "#EA4335",
parentId: null,
icon: "cog",
order: 8,
},
{
id: 103,
name: "Daten",
description: "Datenbanken, Analyse und KI",
color: "#FBBC04",
parentId: null,
icon: "database",
order: 9,
},
// Backend -> Programmiersprachen
{
id: 200,
name: "Python",
description: "Allgemeine Python-Entwicklung",
color: "#3776AB",
parentId: 100,
icon: "python",
order: 1,
},
{
id: 201,
name: "Java",
description: "Java und JVM-Ökosystem",
color: "#007396",
parentId: 100,
icon: "java",
order: 2,
},
{
id: 202,
name: "Node.js",
description: "JavaScript auf dem Server",
color: "#339933",
parentId: 100,
icon: "nodejs",
order: 3,
},
// Backend -> Python -> Frameworks
{
id: 300,
name: "Flask",
description: "Mikro-Webframework für Python",
color: "#000000",
parentId: 200,
icon: "flask",
order: 1,
},
{
id: 301,
name: "Django",
description: "Full-Stack Webframework für Python",
color: "#092E20",
parentId: 200,
icon: "django",
order: 2,
},
{
id: 302,
name: "FastAPI",
description: "Moderne API-Entwicklung mit Python",
color: "#005577",
parentId: 200,
icon: "fastapi",
order: 3,
},
// Backend -> Node.js -> Frameworks
{
id: 310,
name: "Express",
description: "Minimalistisches Webframework für Node.js",
color: "#000000",
parentId: 202,
icon: "express",
order: 1,
},
{
id: 311,
name: "NestJS",
description: "Progressives Node.js-Framework für Enterprise-Anwendungen",
color: "#E0234E",
parentId: 202,
icon: "nestjs",
order: 2,
},
// Backend -> Datenbanken
{
id: 210,
name: "Datenbanken",
description: "Backend-Datenbank-Systeme",
color: "#336791",
parentId: 100,
icon: "database",
order: 4,
},
{
id: 211,
name: "PostgreSQL",
description: "Relationale Datenbank mit erweiterten Features",
color: "#336791",
parentId: 210,
icon: "postgres",
order: 1,
},
{
id: 212,
name: "MongoDB",
description: "Dokumentenorientierte NoSQL-Datenbank",
color: "#47A248",
parentId: 210,
icon: "mongodb",
order: 2,
},
// Frontend -> JavaScript
{
id: 220,
name: "JavaScript",
description: "Die Hauptsprache des Web",
color: "#F7DF1E",
parentId: 101,
icon: "js",
order: 1,
},
{
id: 221,
name: "TypeScript",
description: "Typsicheres JavaScript",
color: "#3178C6",
parentId: 101,
icon: "ts",
order: 2,
},
// Frontend -> JavaScript -> Frameworks
{
id: 320,
name: "React",
description: "Komponentenbasiertes UI-Framework",
color: "#61DAFB",
parentId: 220,
icon: "react",
order: 1,
},
{
id: 321,
name: "Vue.js",
description: "Progressives JavaScript-Framework",
color: "#4FC08D",
parentId: 220,
icon: "vue",
order: 2,
},
{
id: 322,
name: "Angular",
description: "Full-Stack Framework von Google",
color: "#DD1B16",
parentId: 220,
icon: "angular",
order: 3,
},
// Frontend -> CSS
{
id: 230,
name: "CSS",
description: "Stildefinition für Webseiten",
color: "#1572B6",
parentId: 101,
icon: "css",
order: 3,
},
{
id: 330,
name: "Tailwind CSS",
description: "Utility-first CSS-Framework",
color: "#06B6D4",
parentId: 230,
icon: "tailwind",
order: 1,
},
// DevOps
{
id: 240,
name: "Docker",
description: "Containerisierung von Anwendungen",
color: "#2496ED",
parentId: 102,
icon: "docker",
order: 1,
},
{
id: 241,
name: "Kubernetes",
description: "Orchestrierung von Containern",
color: "#326CE5",
parentId: 102,
icon: "kubernetes",
order: 2,
},
{
id: 242,
name: "CI/CD",
description: "Continuous Integration und Deployment",
color: "#FFA500",
parentId: 102,
icon: "cicd",
order: 3,
},
// Daten
{
id: 250,
name: "Künstliche Intelligenz",
description: "Machine Learning und KI-Technologien",
color: "#8B00FF",
parentId: 103,
icon: "ai",
order: 1,
},
{
id: 251,
name: "Big Data",
description: "Verarbeitung großer Datenmengen",
color: "#FF6B6B",
parentId: 103,
icon: "bigdata",
order: 2,
},
];
// ============================================================================
// HILFSFUNKTIONEN FÜR HIERARCHISCHE TAGS
// ============================================================================
/**
* Gibt alle Wurzel-Tags (ohne Eltern) zurück.
*/
function getRootTags(): iTag[] {
return tagDaten.filter((tag) => tag.parentId === null);
}
/**
* Baut aus der flachen Liste eine hierarchische Struktur mit Kindern.
* @param parentId - ID des Eltern-Tags (null für Wurzelebene)
* @returns Array von Tags mit verschachtelten Kindern
*/
function buildHierarchy(parentId: number | null = null): iTagWithChildren[] {
const children = tagDaten.filter((tag) => tag.parentId === parentId);
// Sortieren nach order-Feld (falls vorhanden)
children.sort((a, b) => (a.order || 0) - (b.order || 0));
return children.map((tag) => ({
...tag,
children: buildHierarchy(tag.id),
}));
}
/**
* Gibt die vollständige Hierarchie als verschachteltes Objekt zurück.
*/
function getFullHierarchy(): iTagWithChildren[] {
return buildHierarchy(null);
}
/**
* Sucht einen Tag nach ID.
*/
function findTagById(id: number): iTag | undefined {
return tagDaten.find((tag) => tag.id === id);
}
/**
* Gibt den vollständigen Pfad eines Tags als String zurück (z. B. "/Backend/Python/Flask").
*/
function getTagPath(tagId: number): string {
const tag = findTagById(tagId);
if (!tag) return "";
if (tag.parentId === null) return `/${tag.name}`;
const parentPath = getTagPath(tag.parentId);
return `${parentPath}/${tag.name}`;
}
/**
* Gibt alle Nachfahren (Kinder, Enkel, ...) eines Tags zurück (flache Liste).
*/
function getDescendants(tagId: number): iTag[] {
const descendants: iTag[] = [];
const children = tagDaten.filter((tag) => tag.parentId === tagId);
for (const child of children) {
descendants.push(child);
descendants.push(...getDescendants(child.id));
}
return descendants;
}
/**
* Gibt alle Vorfahren (Eltern, Großeltern, ...) eines Tags zurück.
*/
function getAncestors(tagId: number): iTag[] {
const ancestors: iTag[] = [];
const tag = findTagById(tagId);
if (!tag || tag.parentId === null) return ancestors;
const parent = findTagById(tag.parentId);
if (parent) {
ancestors.push(parent);
ancestors.push(...getAncestors(parent.id));
}
return ancestors;
}
/**
* Sucht Tags nach Namen (case-insensitive, partiell).
*/
function searchTagsByName(query: string): iTag[] {
const lowerQuery = query.toLowerCase();
return tagDaten.filter((tag) => tag.name.toLowerCase().includes(lowerQuery));
}
/**
* Fügt einen neuen Tag hinzu (mit automatischer ID-Vergabe).
*/
function addTag(name: string, parentId: number | null = null, description: string = "", color: string = "#808080", icon?: string, order?: number): iTag {
const newId = tagDaten.length > 0 ? Math.max(...tagDaten.map(t => t.id)) + 1 : 1;
const tag: iTag = {
id: newId,
name,
description,
color,
parentId,
icon,
order,
};
tagDaten.push(tag);
return tag;
}
/**
* Ersetzt einen bestehenden Tag (z. B. nach Bearbeitung).
*/
function updateTag(updatedTag: iTag): void {
const index = tagDaten.findIndex(t => t.id === updatedTag.id);
if (index !== -1) {
tagDaten[index] = updatedTag;
}
}
// ============================================================================
// TREENODE-ERZEUGUNG (FÜR DIE TREETABLE)
// ============================================================================
/**
* Erzeugt Treenodes aus der hierarchischen Tag-Struktur.
* Berücksichtigt die Verschachtelung der Tags.
*/
function createTagTreenodes(): iTreenode[] {
const hierarchy = getFullHierarchy();
return hierarchy.map((tag) => createTreenodeFromTag(tag));
function createTagTreenodes(hierarchy: iTagWithChildren[]): iTreenode[] {
return hierarchy.map((tag) => createTreenodeFromTag(tag))
}
/**
* Rekursive Funktion zum Erstellen eines Treenode aus einem Tag mit Kindern.
*/
function createTreenodeFromTag(tag: iTagWithChildren): iTreenode {
const children = tag.children.map(child => createTreenodeFromTag(child));
return new Treenode(tag, true, children);
const children = tag.children.map(child => createTreenodeFromTag(child))
return new Treenode(tag, true, children)
}
/**
* Zaehlt alle Nachfahren eines Treenodes rekursiv (fuer die
* Loesch-Bestaetigungsmeldung).
*/
function countDescendants(node: iTreenode): number {
let count = 0
for (const child of node.children) {
count++
count += countDescendants(child)
}
return count
}
/**
* Prueft rekursiv, ob irgendein Nachfahre eines Treenodes auf den
* Suchbegriff passt (Name oder Beschreibung, case-insensitive).
*/
function descendantMatches(node: iTreenode, searchLower: string): boolean {
for (const child of node.children) {
const tag: iTag = child.nodevalue
if (tag.name.toLowerCase().includes(searchLower) ||
(tag.description || "").toLowerCase().includes(searchLower)) {
return true
}
if (descendantMatches(child, searchLower)) {
return true
}
}
return false
}
// ============================================================================
// KOMPONENTEN
// ============================================================================
export const Tag: m.ClosureComponent = (vnode) => {
export const Tag: m.ClosureComponent = () => {
let currentTag: iTag = {
id: -1,
name: "",
description: "",
color: "",
parentId: null,
};
parent_id: null,
is_parent: false,
is_erledigend: false,
loest_wiedervorlage_aus: false,
wiedervorlage_abstand_tage: null,
}
// Liste aller Parent-Tags fuer die Eltern-Auswahl (nur Parent-Tags
// koennen uebergeordnet sein)
let allParentTags: iTag[] = []
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/taglist")
const setColor = (color: string) => {
currentTag.color = color;
};
// Liste aller Tags für die Eltern-Auswahl
const allTags = tagDaten.filter(t => t.id !== currentTag.id); // Ausschließen des aktuellen Tags
currentTag.color = color
guard.markDirty()
}
return {
oninit: (vnode) => {
const routeParams = m.route.param();
if (routeParams) {
const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
guard.registerGuard()
if (id !== -1) {
// Bestehenden Tag frisch vom Server laden
fetchTagById(id).then((result) => {
currentTag = result
}).catch((e) => showServerError(e))
} else if (routeParams) {
// Neuer Tag: vorbelegte Werte aus Route-Params uebernehmen
currentTag = {
id: routeParams.id ? parseInt(routeParams.id) : -1,
id: -1,
name: routeParams.name || "",
description: routeParams.description || "",
color: routeParams.color || "",
parentId: routeParams.parentId ? parseInt(routeParams.parentId) : null,
icon: routeParams.icon || undefined,
order: routeParams.order ? parseInt(routeParams.order) : undefined,
};
parent_id: routeParams.parent_id ? parseInt(routeParams.parent_id) : null,
is_parent: routeParams.is_parent ? routeParams.is_parent === "true" : false,
is_erledigend: routeParams.is_erledigend ? routeParams.is_erledigend === "true" : false,
loest_wiedervorlage_aus: routeParams.loest_wiedervorlage_aus ? routeParams.loest_wiedervorlage_aus === "true" : false,
wiedervorlage_abstand_tage: routeParams.wiedervorlage_abstand_tage ? parseInt(routeParams.wiedervorlage_abstand_tage) : null,
}
}
// Parent-Tag-Liste vom Server laden (nur is_parent-Tags sind
// als uebergeordnete Tags zulaessig). Der aktuelle Tag wird
// ausgeschlossen, damit er nicht sein eigener Parent wird.
fetchTags().then((result) => {
allParentTags = result.filter(t => t.id !== currentTag.id && t.is_parent)
}).catch((e) => showServerError(e))
},
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
const gespeicherterTag: iTag = {
id: currentTag.id,
name: (formData.get("name") as string) || "",
description: (formData.get("description") as string) || "",
color: currentTag.color || "",
parent_id: currentTag.parent_id,
is_parent: currentTag.is_parent,
is_erledigend: currentTag.is_erledigend,
loest_wiedervorlage_aus: currentTag.loest_wiedervorlage_aus,
wiedervorlage_abstand_tage: currentTag.wiedervorlage_abstand_tage,
}
if (currentTag.id === -1) {
createTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [
m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text"} as iInput)),
m("label", "Beschreibung", m(Input, {name: "description", value: currentTag.description, type: "text"} as iInput)),
m(Form, guard.formAttrs(submit) as iForm, [
m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text", required: true, oninput: (e: Event) => { currentTag.name = (e.target as HTMLInputElement).value }} as iInput)),
m("label", "Beschreibung", m(Input, {name: "description", value: currentTag.description, type: "text", oninput: (e: Event) => { currentTag.description = (e.target as HTMLInputElement).value }} as iInput)),
// Eltern-Tag-Auswahl
m("label", "Übergeordneter Tag (optional)",
m("select.w3-select", {name: "parentId", value: currentTag.parentId || ""},
m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
allTags.map(tag =>
m("option", {value: tag.id, selected: currentTag.parentId === tag.id}, tag.name)
)
// Parent-Tag-Umschalter: true = Gruppierungs-Knoten (nicht zuordenbar)
m("div", {style: {'margin-bottom': '5px'}},
m("label",
m("input.w3-check", {
type: "checkbox",
name: "is_parent",
checked: currentTag.is_parent,
style: {'margin-right': '8px'},
onclick: (e: Event) => {
currentTag.is_parent = (e.target as HTMLInputElement).checked
guard.markDirty()
}
}),
"Nur zur Gruppierung, kann nicht zugeordnet werden"
)
),
// Eltern-Tag-Auswahl
m("label", "Übergeordneter Tag (optional)"),
m("select.w3-select", {
name: "parent_id",
value: currentTag.parent_id || "",
onchange: (e: Event) => {
const val = (e.target as HTMLSelectElement).value
currentTag.parent_id = val ? parseInt(val) : null
guard.markDirty()
}
},
m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
allParentTags.map(tag =>
m("option", {value: tag.id, selected: currentTag.parent_id === tag.id}, tag.name)
)
),
// Workflow-Felder (nur für echte, zuordenbare Tags sichtbar).
// Parent-Tags sind Gruppierungsknoten und nicht zuordenbar,
// daher sind diese Felder für sie irrelevant.
currentTag.is_parent ? null : [
m("div", {style: {'margin-top': '16px', 'margin-bottom': '5px'}},
m("label",
m("input.w3-check", {
type: "checkbox",
name: "is_erledigend",
checked: currentTag.is_erledigend,
style: {'margin-right': '8px'},
onclick: (e: Event) => {
currentTag.is_erledigend = (e.target as HTMLInputElement).checked
guard.markDirty()
}
}),
"Kontakt als erledigt markieren"
)
),
m("div", {style: {'margin-bottom': '5px'}},
m("label",
m("input.w3-check", {
type: "checkbox",
name: "loest_wiedervorlage_aus",
checked: currentTag.loest_wiedervorlage_aus,
style: {'margin-right': '8px'},
onclick: (e: Event) => {
currentTag.loest_wiedervorlage_aus = (e.target as HTMLInputElement).checked
if (!currentTag.loest_wiedervorlage_aus) {
currentTag.wiedervorlage_abstand_tage = null
}
guard.markDirty()
}
}),
"Löst Wiedervorlage aus"
)
),
m("label", "Wiedervorlage-Abstand (Tage)"),
m("input.w3-input", {
type: "number",
name: "wiedervorlage_abstand_tage",
value: currentTag.wiedervorlage_abstand_tage ?? "",
min: 0,
disabled: !currentTag.loest_wiedervorlage_aus,
style: {
'margin-bottom': '12px',
opacity: currentTag.loest_wiedervorlage_aus ? '1' : '0.5'
},
oninput: (e: Event) => {
const val = (e.target as HTMLInputElement).value
currentTag.wiedervorlage_abstand_tage = val === "" ? null : parseInt(val)
guard.markDirty()
}
})
],
m("label", "Farbe"),
m("div", {style: {display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px'}},
colorPalette.map(color =>
m("button", {
type: "button",
style: {
width: '32px',
height: '32px',
@@ -559,91 +263,145 @@ export const Tag: m.ClosureComponent = (vnode) => {
})
)
),
m(Input, {name: "color", value: currentTag.color, type: "text"} as iInput),
m("label", "Icon (optional)", m(Input, {name: "icon", value: currentTag.icon || "", type: "text"} as iInput)),
m("label", "Sortierreihenfolge (optional)", m(Input, {name: "order", value: currentTag.order?.toString() || "", type: "number"} as iInput)),
])
)
);
)
}
};
};
}
}
export const TagList: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = createTagTreenodes();
let selectedNode: iTreenode | null = null;
export const TagList: m.ClosureComponent = () => {
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
// Aktualisiere treenodes, wenn sich die Daten ändern
const updateTreenodes = () => {
treenodes = createTagTreenodes();
selectedNode = null;
};
// Laedt die Hierarchie vom Server und erzeugt Treenodes
const loadTreenodes = () => {
fetchTagHierarchy().then((hierarchy) => {
treenodes = createTagTreenodes(hierarchy)
selectedNode = null
}).catch((e) => showServerError(e))
}
const handleRowMarked = (node: iTreenode) => {
selectedNode = node;
};
selectedNode = node
}
const editTag = (tag: iTag, e: Event) => {
e.stopPropagation();
m.route.set("/tag", {
id: tag.id,
name: tag.name,
description: tag.description,
color: tag.color,
parentId: tag.parentId,
icon: tag.icon,
order: tag.order
});
};
e.stopPropagation()
m.route.set("/tag", {id: tag.id})
}
const deleteTag = (tag: iTag, node: iTreenode, e: Event) => {
e.stopPropagation()
const childCount = countDescendants(node)
if (childCount === 0) {
// Keine Kinder: einfache Bestaetigung
if (window.confirm("Tag '" + tag.name + "' wirklich löschen?")) {
deleteTagREST(tag.id).then(() => {
loadTreenodes()
}).catch((e) => showServerError(e))
}
} else {
// Mit Kindern: Modal mit Hinweis auf kaskadierende Loeschung
m.mount(gridMountpoints.modalhook, {
view: () => {
return m(ModalView,
{
title: "Tag löschen",
type: ModalType.MESSAGE,
width: '40%'
} as iModalView,
m("div.w3-panel",
m("p", "Der Tag '" + tag.name + "' hat " + childCount + " untergeordnete Tags."),
m("p", "Soll er inkl. aller untergeordneten Tags gelöscht werden?")
),
m("div.w3-bar.w3-padding-16",
m("button.w3-button.w3-red.w3-left",
{
onclick: () => {
deleteTagREST(tag.id).then(() => {
m.mount(gridMountpoints.modalhook, null)
loadTreenodes()
}).catch((e) => showServerError(e))
}
},
"Inkl. aller löschen"
),
m("button.w3-button.w3-theme.w3-right",
{onclick: () => {m.mount(gridMountpoints.modalhook, null)}},
"Abbrechen"
)
)
)
}
})
}
}
const addTagRoute = () => {
m.route.set("/tag", {id: -1})
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let tag: iTag = node.nodevalue;
let tag: iTag = node.nodevalue
// Parent-Tags erhalten ein "folder"-Icon, echte Tags ein "label"-Icon
const tagIcon = tag.is_parent ? "folder" : "label"
const tagTitle = tag.is_parent ? "Parent-Tag (nicht zuordenbar)" : ""
return [
m("span",
m("span", {
m("i.material-icons", {
style: {
backgroundColor: tag.color,
color: 'white',
padding: '2px 6px',
borderRadius: '3px',
fontWeight: 'bold',
display: 'inline-block',
marginRight: '8px'
}
}, tag.name)
),
verticalAlign: 'middle',
marginRight: '6px',
fontSize: '18px'
},
title: tagTitle
}, tagIcon),
// Farbchip mit Name und Workflow-Icons (erledigend /
// Wiedervorlage) aus der zentralen TagBadge-Komponente.
m(TagBadge, {tagData: tag})),
m("span", tag.description),
m("div",
m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editTag(tag, e)},
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}},
m("button.w3-button.w3-small", {title: "Tag bearbeiten", style: {padding: '0 2px'}, onclick: (e: Event) => editTag(tag, e)},
m("i.material-icons", "edit")
),
m("button.w3-button.w3-small", {title: "Tag löschen", style: {padding: '0 2px'}, onclick: (e: Event) => deleteTag(tag, node, e)},
m("i.material-icons", "delete")
)
)
];
};
]
}
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let tag: iTag = node.nodevalue;
let searchLower = searchvalue.toLowerCase();
return tag.name.toLowerCase().includes(searchLower) ||
tag.description.toLowerCase().includes(searchLower) ||
tag.color.toLowerCase().includes(searchLower) ||
(tag.icon && tag.icon.toLowerCase().includes(searchLower));
};
let tag: iTag = node.nodevalue
let searchLower = searchvalue.toLowerCase()
// Prueft, ob der Tag selbst auf den Suchbegriff passt (nur Name und Beschreibung)
const selfMatch = tag.name.toLowerCase().includes(searchLower) ||
(tag.description || "").toLowerCase().includes(searchLower)
// Wenn der Tag selbst passt, anzeigen
if (selfMatch) return true
// Wenn der Tag Kinder hat, pruefen, ob ein Nachfahre passt
if (searchLower === "") return true
return descendantMatches(node, searchLower)
}
return {
oninit: (vnode) => {
loadTreenodes()
},
view: (vnode) => {
return m("div.w3-container", {
return m("div.w3-container.w3-auto", {
style: {
'max-width': '80%',
'margin-left': 'auto',
'margin-right': 'auto',
display: 'flex',
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", "Tags"),
m("p.w3-text-grey", "Hierarchie: Tags können beliebig verschachtelt werden (z. B. Backend → Python → Flask)."),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
@@ -652,27 +410,13 @@ export const TagList: m.ClosureComponent = (vnode) => {
filter: filterFunction,
onRowMarked: handleRowMarked,
isSelected: (node: iTreenode) => node === selectedNode
} as iTreetable
} as iTreetable,
m(TopbarButton, {onclick: addTagRoute, tooltip: "Neuen Tag anlegen"} as iTopbarButton,
m("i.material-icons", "add")
)
)
)
);
)
}
};
};
// ============================================================================
// EXPORTE FÜR EXTERNE NUTZUNG
// ============================================================================
export {
tagDaten as tags, // Flache Liste aller Tags
getFullHierarchy as hierarchy, // Vollständige hierarchische Struktur
findTagById,
getTagPath,
getDescendants,
getAncestors,
searchTagsByName,
addTag,
updateTag,
getRootTags,
};
}
}
+94 -21
View File
@@ -39,6 +39,20 @@ export interface iTreetable {
* <tr>-Zeile; die columnmapper-Inhalte brauchen dann kein eigenes
* Selektions-Styling mehr. */
isSelected?: (node: iTreenode) => boolean
/** Optional: Spaltenüberschriften (ein Titel je columnmapper-Spalte,
* 1:1 von links nach rechts). Wenn gesetzt, rendert das Widget eine
* Kopfzeile über den Datenzeilen. Hinweis zur ersten Spalte: Die
* Einrückung/der Expand-Pfeil der Baumdarstellung liegt im selben
* td wie die ERSTE columnmapper-Zelle — der erste Titel steht
* daher direkt über der ersten Datenspalte. Die Kopfzeile bleibt
* beim Scrollen sticky sichtbar. */
header?: string[]
/** Optional: Ein-/Ausklappen-Buttons (unfold_more/unfold_less) in der
* Topbar anzeigen. Default true. Für flache Listen ohne Hierarchie
* auf false setzen — die Topbar entfällt dann ganz, sofern auch
* keine Children (z.B. TopbarButtons) übergeben und kein filter
* gesetzt ist. */
showExpandButtons?: boolean
}
export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
@@ -58,26 +72,73 @@ export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
const topHasSiblingsWithChildren = vnode.attrs.treenodes.some(
(tn: iTreenode) => tn.hasChildren
)
// Topbar (Expand-Buttons, Children, Suchfeld) nur rendern, wenn
// tatsächlich Inhalt vorhanden ist — bei flachen Listen ohne
// Expand-Buttons, Children und filter bleibt sie komplett weg.
const showExpand = vnode.attrs.showExpandButtons !== false
// vnode.children kann ein Array oder ein Primitiv sein
// (Mithril-Typ ChildArrayOrPrimitive).
const hatChildren = Array.isArray(vnode.children)
? vnode.children.length > 0
: vnode.children != null
const hatTopbar = showExpand || hatChildren || vnode.attrs.filter != undefined
return m("div.w3-border-theme", {style: {height: '99%', overflow: 'hidden', 'border-style': 'solid', 'border-width': '1px'}},
m("div.w3-bar.w3-theme",
m(TopbarButton, {
onclick: () => {
expandtoggle = true; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
}},
m("i.material-icons", "unfold_more")
),
m(TopbarButton, {
onclick: () => {
expandtoggle = false; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
}},
m("i.material-icons", "unfold_less")
),
vnode.children,
(vnode.attrs.filter != undefined) ? m(SearchField, {setSearchvalue: setSearchvalue} as iSearchfield) : null
),
hatTopbar
? m("div.w3-bar.w3-theme",
showExpand
? [
m(TopbarButton, {
onclick: () => {
expandtoggle = true; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
}},
m("i.material-icons", "unfold_more")
),
m(TopbarButton, {
onclick: () => {
expandtoggle = false; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
}},
m("i.material-icons", "unfold_less")
)
]
: null,
vnode.children,
(vnode.attrs.filter != undefined) ? m(SearchField, {setSearchvalue: setSearchvalue} as iSearchfield) : null
)
: null,
m("div", {style: {height: '100%'}},
m("div", {style: {height: 'calc(100% - 32px', overflow: 'auto', width: '100%'}},
m("table.w3-table", {style: {'overflow-y': 'auto'}},
// Ohne Topbar nutzt der scrollbare Bereich die volle Höhe.
// Wichtig für den Sticky-Header: Dieser div muss selbst der
// Scroll-Container sein (feste Höhe + overflow), damit sich
// position:sticky der th-Zellen auf ihn bezieht.
m("div", {style: {height: hatTopbar ? 'calc(100% - 32px)' : '100%', overflow: 'auto', width: '100%'}},
// overflow darf hier NICHT auf dem table-Element gesetzt
// werden — sonst wird die Tabelle selbst zum Scroll-Container
// und der Sticky-Header bricht.
m("table.w3-table",
// Optionale Spaltenüberschriften: Ein Titel je
// columnmapper-Spalte, 1:1 von links nach rechts
// (die Einrückung teilt sich das td mit der
// ersten Datenspalte, keine Führungs-Spalte).
// Die Kopfzeile bleibt beim Scrollen sticky.
vnode.attrs.header
? m("thead",
m("tr.w3-theme",
vnode.attrs.header.map((titel: string) =>
m("th.w3-theme", {
// Sticky-Header: Eigener Hintergrund
// nötig, da der tr-Hintergrund beim
// Scrollen mit den Daten wegwandert
// und sonst Zeilen durchscheinen.
style: {
position: 'sticky',
top: '0',
'z-index': '1',
'text-align': 'left',
'white-space': 'nowrap'
},
title: titel
}, titel))))
: null,
vnode.attrs.treenodes.map((tn:iTreenode, z) => {
if (vnode.attrs.filter == undefined || (vnode.attrs.filter != undefined && vnode.attrs.filter(tn, searchvalue) == true)) {
return m(Treerow, {
@@ -88,7 +149,9 @@ export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
onRowMarked: onRowMarked,
isSelected: isSelected,
isEven: ((z % 2) == 0),
hasSiblingsWithChildren: topHasSiblingsWithChildren} as iTreerow)
hasSiblingsWithChildren: topHasSiblingsWithChildren,
filter: vnode.attrs.filter,
searchvalue: searchvalue} as iTreerow)
}
else {
return null
@@ -114,6 +177,12 @@ interface iTreerow {
* den Platzhalter-Pfeil nur dann zu rendern, wenn es Nodes mit
* Pfeil gibt, an denen der Text ausgerichtet werden kann. */
hasSiblingsWithChildren?: boolean
/** Optional: Filter-Funktion, die auch auf Kind-Knoten angewendet wird.
* Wenn gesetzt, werden Kind-Knoten nur gerendert, wenn der Filter
* für sie true zurückgibt. */
filter?: (row: iTreenode, searchvalue: string) => boolean
/** Aktueller Suchwert, der an den Filter weitergereicht wird. */
searchvalue?: string
}
let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
@@ -178,7 +247,9 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
// Falls der Trennode Kinder hat und expandiert werden soll, wird rekursiv die Trennode-Komponente für
// die Child-Nodes aufgerufen, ansonsten wir null zurückgegeben und die Einrückung hört auf
vnode.attrs.treenode.hasChildren && vnode.attrs.treenode.expand
? vnode.attrs.treenode.children.map((tn: iTreenode, z) => {
? vnode.attrs.treenode.children
.filter(tn => !vnode.attrs.filter || vnode.attrs.filter(tn, vnode.attrs.searchvalue || ""))
.map((tn: iTreenode, z) => {
return m(Treerow, {
treenode: tn,
level: vnode.attrs.level + 1,
@@ -186,7 +257,9 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
onRowMarked: vnode.attrs.onRowMarked,
isSelected: vnode.attrs.isSelected,
isEven: ((z % 2) == 0),
hasSiblingsWithChildren: childHasSiblingsWithChildren
hasSiblingsWithChildren: childHasSiblingsWithChildren,
filter: vnode.attrs.filter,
searchvalue: vnode.attrs.searchvalue
} as iTreerow)
})
: null
+111
View File
@@ -0,0 +1,111 @@
import m from "mithril"
/** Ein Breadcrumb-Glied: Route, Filter-Params und Anzeige-Label einer
* Vorgänger-Ansicht in der Aufrufkette. */
export interface iBreadcrumb {
route: string
params: any
label: string
}
/** Attribute der BreadcrumbBar: die Vorgänger-Kette (ancestors) und das
* Label der aktuellen Ansicht (wird als letztes, nicht klickbares Glied
* angezeigt). Ist breadcrumbs leer, wird keine Leiste gerendert.
* onnavigate (optional): Navigations-Callback, der statt m.route.set
* verwendet wird — typischerweise guardedNavigate aus dirtyguard.ts,
* so dass bei ungespeicherten Änderungen das Bestätigungs-Modal erscheint. */
export interface iBreadcrumbBar {
breadcrumbs: iBreadcrumb[]
current: string
onnavigate?: (route: string, params: any, e?: Event) => void
}
/** Parst den Route-Param `bc` (JSON-String) in ein Breadcrumb-Array.
* Liefert bei fehlendem/ungültigem `bc` ein leeres Array. */
export function parseBc(bc: string | undefined): iBreadcrumb[] {
if (!bc) return []
try { return JSON.parse(bc) } catch { return [] }
}
/** Serialisiert ein Breadcrumb-Array in den Route-Param `bc`.
* Liefert undefined bei leerer Kette (kein bc in der Route). */
export function serializeBc(crumbs: iBreadcrumb[]): string | undefined {
return crumbs.length > 0 ? JSON.stringify(crumbs) : undefined
}
export const BreadcrumbBar: m.ClosureComponent<iBreadcrumbBar> = () => {
return {
view: (vnode) => {
const crumbs = vnode.attrs.breadcrumbs || []
const current = vnode.attrs.current
// Ohne Vorgänger und ohne aktuelles Label keine Leiste rendern
// (Top-Level-Ansichten ohne Hierarchie-Kontext).
if (crumbs.length === 0 && !current) return null
const items: any[] = []
crumbs.forEach((crumb: iBreadcrumb, i: number) => {
if (i > 0) {
items.push(m("span.w3-small", {
style: {margin: '0 2px', opacity: '0.6'}
}, "›"))
}
// Vorgänger sind klickbar: Navigation zur jeweiligen Ansicht
// mit deren Filter-Params und der auf dieses Glied gekürzten
// Kette als neuem bc.
items.push(m("a.w3-button.w3-small.w3-theme-l2", {
style: {
padding: '2px 8px',
'border-radius': '4px',
'text-decoration': 'none',
'white-space': 'nowrap'
},
title: "Zurück zu " + crumb.label,
onclick: (e: MouseEvent) => {
const subChain = crumbs.slice(0, i)
const params: any = {...crumb.params}
const bc = serializeBc(subChain)
if (bc) params.bc = bc
// Navigation über den Guard, so dass bei dirty-Maske
// das Bestätigungs-Modal erscheint. Event wird
// durchgereicht, damit guardedNavigate den auto-redraw
// unterbinden kann.
if (vnode.attrs.onnavigate) {
vnode.attrs.onnavigate(crumb.route, params, e)
} else {
m.route.set(crumb.route, params)
}
}
}, crumb.label))
})
// Aktuelle Ansicht als letztes, nicht klickbares Glied (fett).
if (current) {
if (crumbs.length > 0) {
items.push(m("span.w3-small", {
style: {margin: '0 2px', opacity: '0.6'}
}, "›"))
}
items.push(m("span.w3-small.w3-theme-d3", {
style: {
padding: '2px 8px',
'border-radius': '4px',
'font-weight': 'bold',
'white-space': 'nowrap'
}
}, current))
}
return m("div.w3-theme-l1", {
style: {
display: 'flex',
'flex-wrap': 'nowrap',
'align-items': 'center',
padding: '4px 8px',
'border-radius': '4px',
'margin-bottom': '8px',
'overflow-x': 'auto',
'overflow-y': 'hidden'
}
}, items)
}
}
}
+267
View File
@@ -0,0 +1,267 @@
import m from "mithril"
import { gridMountpoints } from "../mainlayout"
import type { iForm } from "./form"
/** Registrierungsschnittstelle für eine Bearbeitungsmaske. Die Maske
* registriert in oninit einen Guard und entfernt ihn in onremove wieder.
* So weiß der zentrale Navigations-Guard, ob die aktuelle Ansicht ungespeicherte
* Änderungen enthält und wie das Speichern auszulösen ist. */
export interface DirtyGuard {
/** Liefert true, wenn die Maske ungespeicherte Änderungen enthält. */
isDirty: () => boolean
/** Triggert das Speichern des Formulars (form.requestSubmit()). Bei
* Angabe einer Ziel-Route navigiert die Maske nach erfolgreichem Speichern
* dorthin statt zum Standard-Ziel. Die Maske muss dafür die Ziel-Route
* zwischenspeichern und im Erfolgs-.then der REST-Aufrufs auswerten. */
save: (targetRoute?: string, targetParams?: any) => void
}
// Aktiv registrierter Guard (max. einer, da nur eine Maske sichtbar ist).
let guard: DirtyGuard | null = null
/** Wird von einer Bearbeitungsmaske in oninit aufgerufen, um den Guard
* zu aktivieren. */
export function registerDirtyGuard(opts: DirtyGuard): void {
guard = opts
}
/** Wird von einer Bearbeitungsmaske in onremove aufgerufen, um den Guard
* zu deaktivieren. Ein evtl. noch offenes Bestätigungs-Modal wird geschlossen. */
export function clearDirtyGuard(): void {
guard = null
m.mount(gridMountpoints.modalhook, null)
}
/** Liefert true, wenn aktuell ein Guard registriert ist. */
export function hasDirtyGuard(): boolean {
return guard != null
}
/** Zentraler Einstieg für alle App-internen Navigationen. Ist die aktive
* Maske dirty, wird das 3-Button-Bestätigungs-Modal geöffnet; andernfalls
* wird direkt navigiert. Alle m.route.set-Aufrufe, die aus einer
* Bearbeitungsmaske heraus führen, sollten diese Funktion verwenden.
*
* Der optionale Event-Parameter e wird bei dirty auf e.redraw = false
* gesetzt, damit Mithril keinen auto-redraw der Maske ausführt und die
* eingegebenen Formularwerte im DOM erhalten bleiben — das Input-Widget
* hat kein Live-Binding, ein Redraw würde die Felder mit den (noch
* unveränderten) Model-Werten überschreiben. */
export function guardedNavigate(route: string, params?: any, e?: Event): void {
if (guard && guard.isDirty()) {
if (e) e['redraw'] = false
showDirtyConfirmModal(route, params)
return
}
m.route.set(route, params)
}
/** Hilfsfunktion für Masken: triggert das Formular-Submit. Die Maske übergibt
* ihre Form-Referenz (über onformcreate) und einen Callback, der die
* Ziel-Route für die Navigation nach erfolgreichem Speichern zwischenspeichert. */
export function submitForm(form: HTMLFormElement | null): void {
if (!form) return
if (typeof form.requestSubmit === "function") {
form.requestSubmit()
} else {
// Fallback für ältere Browser.
form.dispatchEvent(new Event("submit", {cancelable: true, bubbles: true}))
}
}
// ---- 3-Button-Bestätigungs-Modal ----------------------------------------
interface iDirtyConfirmModal {
route: string
params: any
}
const DirtyConfirmModal: m.Component<iDirtyConfirmModal> = {
view: (vnode) => {
const close = () => { m.mount(gridMountpoints.modalhook, null) }
const onSave = (e: MouseEvent) => {
// Auto-Redraw unterbinden, damit die darunter liegende Maske
// nicht neu gerendert wird und die eingegebenen Formularwerte
// im DOM erhalten bleiben — das Input-Widget hat kein
// Live-Binding, ein Redraw würde die Felder mit den (noch
// unveränderten) Model-Werten überschreiben. Das Modal wird
// durch close() direkt per m.mount entfernt.
e['redraw'] = false
close()
if (guard) {
guard.save(vnode.attrs.route, vnode.attrs.params)
}
}
const onDiscard = (e: MouseEvent) => {
// Redraw unterbinden; m.route.set löst den Komponentenwechsel
// ohnehin aus.
e['redraw'] = false
close()
m.route.set(vnode.attrs.route, vnode.attrs.params)
}
const onCancel = (e: MouseEvent) => {
// Redraw unterbinden — die Maske bleibt unverändert, die
// Eingaben des Nutzers dürfen nicht überschrieben werden.
e['redraw'] = false
close()
}
return m("div.w3-modal", {style: {display: 'block'}},
m("div.w3-modal-content.w3-card-4", {style: {width: '440px', top: '20%'}},
m("header.w3-container.w3-theme",
m("h3", "Ungespeicherte Änderungen")),
m("div.w3-container.w3-theme-l1",
m("p", "Die Maske enthält ungespeicherte Änderungen."),
m("p", "Wie möchten Sie verfahren?")),
m("footer.w3-container.w3-theme-l1.w3-padding-16",
m("div.w3-bar",
m("button.w3-button.w3-theme.w3-right",
{style: {'margin-left': '4px'}, onclick: onDiscard},
"Verwerfen"),
m("button.w3-button.w3-theme.w3-right",
{style: {'margin-left': '4px'}, onclick: onSave},
"Speichern"),
m("button.w3-button.w3-theme-l2.w3-right",
{style: {'margin-left': '4px'}, onclick: onCancel},
"Abbrechen")
)
)
)
)
}
}
function showDirtyConfirmModal(route: string, params: any): void {
m.mount(gridMountpoints.modalhook, {
view: () => { return m(DirtyConfirmModal, {route: route, params: params} as iDirtyConfirmModal) }
})
}
// ---- DirtyMaskHelper: kapselt den isDirty/Guard-Boilerplate ---------------
/** Hilfsklasse für Bearbeitungsmasken. Kapselt den gesamten isDirty/Guard-
* Boilerplate, der sonst in jeder Maske identisch wiederkehrt: Dirty-State,
* Form-Referenz, pendingTarget (Ziel-Route nach Speichern aus dem Modal),
* Guard-Registrierung/-Cleanup, navigateAfterSave, cancel und die
* Form-Attribute.
*
* Maskenspezifisch bleiben: die DTO-Erzeugung und create/update-Aufrufe im
* submit (die Maske ruft danach guard.navigateAfterSave() bzw.
* guard.markDirty() auf), die backParams()-Closure, In-Masken-Buttons und
* der oninit-Daten-Fetch.
*
* Verwendung in einer ClosureComponent:
* ```ts
* export const maske: m.ClosureComponent = (vnode) => {
* const guard = new DirtyMaskHelper("/zielroute", () => ({...backParams}))
* return {
* oninit: (v) => { guard.registerGuard(); // fetch ...
* },
* onremove: (v) => { guard.cleanup() },
* view: (v) => {
* const submit = (form) => {
* // DTO bauen, createX, guard.navigateAfterSave, guard.markDirty
* return true
* }
* return m(Form, guard.formAttrs(submit) as iForm, [...])
* }
* }
* }
* ``` */
export class DirtyMaskHelper {
private isDirty = false
private formRef: HTMLFormElement | null = null
private pendingTarget: {route: string, params?: any} | null = null
private readonly defaultRoute: string
private readonly backParams: () => any
/** @param defaultRoute Standard-Ziel nach Save/Cancel (ohne pendingTarget).
* @param backParams liefert die Route-Params für das Standard-Ziel;
* optional, da einige Masken (z.B. kunde, tag) keine Params benötigen. */
constructor(defaultRoute: string, backParams?: () => any) {
this.defaultRoute = defaultRoute
this.backParams = backParams || (() => ({}))
}
/** In oninit aufrufen. Weist diese DirtyMaskHelper-Instanz der
* zentralen statischen Modul-Variablen `guard` (siehe
* registerDirtyGuard) zu, so dass alle Konsumenten — guardedNavigate,
* das 3-Button-Modal, hasDirtyGuard — über diesen einen Slot auf den
* Guard zugreifen, der zur gerade aktiven Maske "gehört". Die
* übergebenen Closures binden this lexikalisch an diese Instanz, so
* dass isDirty() und save() live auf den Instanz-State zugreifen.
*
* Registrierung bewusst nicht im Konstruktor: der Konstruktor läuft
* zum Erzeugungszeitpunkt der ClosureComponent, nicht zum
* Lebenszyklus-Start (oninit). Würde er registrieren, könnte eine
* neue Instanz den zentralen Slot überschreiben, bevor onremove der
* alten Maske aufgeräumt hat — die alte würde dann den Guard der
* neuen Instanz löschen. Mit registerGuard in oninit gilt:
* erst räumt die alte Maske auf (cleanup in onremove), dann
* registriert die neue. */
registerGuard(): void {
registerDirtyGuard({isDirty: () => this.isDirty, save: this.save})
}
/** In onremove aufrufen — deregistriert den Guard und schließt ein
* evtl. offenes Bestätigungs-Modal. */
cleanup(): void {
clearDirtyGuard()
}
/** Als onDirtyChange an die Form übergeben (wird von der Form bei
* Eingabe-Änderungen aufgerufen). */
onDirtyChange = (dirty: boolean) => { this.isDirty = dirty }
/** Bei Custom-Widget-Änderungen aufrufen (Select onchanged etc.), die
* kein change/input-Event auf der Form auslösen. */
markDirty = () => { this.isDirty = true }
/** Als onformcreate an die Form übergeben (speichert die Form-Referenz
* für das programmtische Submit aus dem Bestätigungs-Modal). */
setForm = (f: HTMLFormElement) => { this.formRef = f }
/** Nach erfolgreichem Speichern aufrufen (im .then der REST-Calls).
* Gibt es ein pendingTarget (Speichern aus dem Bestätigungs-Modal),
* wird dorthin navigiert; sonst zum Standard-Ziel. */
navigateAfterSave = () => {
if (this.pendingTarget) {
const t = this.pendingTarget
this.pendingTarget = null
m.route.set(t.route, t.params)
} else {
m.route.set(this.defaultRoute, this.backParams())
}
}
/** Als oncancel an die Form übergeben. Navigiert über den Guard,
* so dass bei dirty die Bestätigungs-Modal erscheint. Das Event
* wird durchgereicht, damit guardedNavigate den auto-redraw
* unterbinden kann. */
cancel = (e?: Event) => {
guardedNavigate(this.defaultRoute, this.backParams(), e)
}
/** Vom Navigations-Guard aufgerufen, wenn der Nutzer im Bestätigungs-
* Modal "Speichern" wählt. Speichert die Ziel-Route und triggert das
* Form-Submit; die Maske navigiert nach erfolgreichem Speichern über
* navigateAfterSave() dorthin. */
private save = (targetRoute?: string, targetParams?: any) => {
this.pendingTarget = targetRoute ? {route: targetRoute, params: targetParams} : null
submitForm(this.formRef)
}
/** Liefert das Form-Attribut-Objekt für m(Form, ...). Der onsubmit-
* Callback ist maskenspezifisch (DTO-Erzeugung + create/update) und
* muss von der Maske übergeben werden; alle anderen Attribute
* (showButtons, oncancel, onDirtyChange, onformcreate) werden von
* der Hilfsklasse gesetzt. */
formAttrs(onsubmit: (form: HTMLFormElement) => boolean): Partial<iForm> {
return {
showbuttons: true,
onsubmit: onsubmit,
oncancel: this.cancel,
onDirtyChange: this.onDirtyChange,
onformcreate: this.setForm
}
}
}
+61 -10
View File
@@ -12,6 +12,28 @@ export interface iForm {
stickyButtons?: boolean
/** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */
onformcreate?: (form:HTMLFormElement) => void
/** Callback bei Änderung des Dirty-State. Wird mit true aufgerufen,
* sobald der Nutzer Eingaben vornimmt, und mit false, nachdem das
* Formular erfolgreich gespeichert oder zurückgesetzt wurde. Die
* umgebende Maske nutzt dies, um ihren eigenen isDirty-State mit dem
* der Form synchron zu halten und so den Navigations-Guard
* (dirtyguard.ts) zu speisen. */
onDirtyChange?: (dirty: boolean) => void
/** Zusätzliche Buttons, die in die Standard-Button-Leiste der Form
* eingereiht werden. Sie werden im DOM vor den Standard-Buttons
* gesetzt und floaten daher (mit w3-right) rechts außen — die
* Standard-Buttons (Zurücksetzen, Abbrechen, Speichern) behalten
* ihre gewohnte Position. Die Buttons sind selbst als m(...) zu
* übergeben; die aufrufende Maske ist für Styling und Callbacks
* verantwortlich. */
extrabuttons?: m.Child[]
/** Wenn true, wird der scrollbare Inhaltsbereich der Form als
* Flex-Spalte gerendert. Masken können dann einzelne Kinder mit
* flex-Styles ausstatten, z.B. einen Bereich, der den verbleibenden
* Platz füllt (flex: '01 1 auto' + 'min-height': '0') und in sich
* scrollt, statt die gesamte Maske scrollbar zu machen. Wirkt nur
* zusammen mit stickyButtons (Default true). */
flexContent?: boolean
}
export const Form:m.ClosureComponent<iForm> = (vnode) => {
@@ -29,6 +51,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
}
function onchanged (e:Event) {
isDirty = true
if (vnode.attrs.onDirtyChange != undefined) {
vnode.attrs.onDirtyChange(true)
}
if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(e)
}
@@ -37,6 +62,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
e.preventDefault()
if (vnode.attrs.onsubmit(form) == true) {
isDirty = false
if (vnode.attrs.onDirtyChange != undefined) {
vnode.attrs.onDirtyChange(false)
}
}
}
function registerFormElement (f:HTMLFormElement) {
@@ -46,22 +74,24 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
}
function cancel(e:Event) {
e.preventDefault()
if (canCloseForm() == true) {
isDirty = false
if (vnode.attrs.oncancel != undefined) {
vnode.attrs.oncancel(e)
}
else {
history.back()
}
// Der Dirty-Check beim Abbrechen erfolgt jetzt durch den
// Navigations-Guard der Maske (dirtyguard.ts), da oncancel die
// Navigation auslöst. Die Form selbst fragt hier nicht mehr nach,
// um eine doppelte Bestätigung zu vermeiden. isDirty wird nicht
// zurückgesetzt, damit der Guard den aktuellen State sieht.
if (vnode.attrs.oncancel != undefined) {
vnode.attrs.oncancel(e)
}
else {
e['redraw'] = false
history.back()
}
}
function reset(e:Event) {
if (canCloseForm("reset")) {
isDirty = false
if (vnode.attrs.onDirtyChange != undefined) {
vnode.attrs.onDirtyChange(false)
}
// Hier passiert nichts.
// Da mithril nach jedem User-Event (wie z.B. einem 'Click') ein redraw() ausführt, wird hier die Form
// neu gezeichnet und zwar mit den ursprünglichen Daten aus den zu grundeliegenden Datenmodel, das ja
@@ -88,6 +118,11 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
: {}
},
[
// Zusätzliche Buttons der Maske zuerst ins DOM:
// mit w3-right landen sie optisch rechts außen,
// die Standard-Buttons bleiben links davon in
// ihrer gewohnten Reihenfolge.
...(vnode.attrs.extrabuttons || []),
m("button.w3-button.w3-theme.w3-right.w3-mobile", {style: {margin: '0px 0px 2px 4px'}, type: "submit"}, "Speichern"),
m("button.w3-button.w3-theme.w3-right.w3-mobile", {style: {margin: '0px 0px 2px 4px'}, onclick: cancel, type: "button"}, "Abbrechen"),
m("button.w3-button.w3-theme.w3-right.w3-mobile", {onclick: reset, type: "button"}, "Zurücksetzen")
@@ -98,6 +133,14 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
// Container, die Buttons bleiben am unteren Rand fixiert. Das
// umschließende form-Element wird zum flex-Container, damit das
// Layout auch bei Fenstergrößenänderung stabil bleibt.
// Stil des scrollbaren Inhaltsbereichs; optional als Flex-Spalte
// (flexContent), damit Masken-Kinder den verbleibenden Platz
// füllen können.
const contentStyle: any = {flex: '1', 'overflow-y': 'auto', 'padding-right': '8px'}
if (vnode.attrs.flexContent === true) {
contentStyle.display = 'flex'
contentStyle['flex-direction'] = 'column'
}
if (stickyButtons) {
return m("form", {
style: {display: 'flex', 'flex-direction': 'column', height: '100%'},
@@ -109,12 +152,16 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
for (let index = 0; index < elements.length; index++) {
const element = elements[index];
element.addEventListener("change", onchanged)
// 'input' feuert schon während des Tippens (vor
// Blur), so dass die Maske dirty wird, auch wenn
// der Nutzer sofort eine Breadcrumb klickt.
element.addEventListener("input", onchanged)
}
registerFormElement(form)
}
},
// Scrollbarer Formular-Inhalt.
m("div", {style: {flex: '1', 'overflow-y': 'auto', 'padding-right': '8px'}},
m("div", {style: contentStyle},
vnode.children
),
// Fixierter Button-Bereich.
@@ -131,6 +178,10 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
for (let index = 0; index < elements.length; index++) {
const element = elements[index];
element.addEventListener("change", onchanged)
// 'input' feuert schon während des Tippens (vor
// Blur), so dass die Maske dirty wird, auch wenn
// der Nutzer sofort eine Breadcrumb klickt.
element.addEventListener("input", onchanged)
}
registerFormElement(form)
}
+6 -1
View File
@@ -23,11 +23,13 @@ export interface iInput {
readonly?: boolean
required?:boolean
onchanged?: Function
oninput?: Function
}
/**
* Diese Komponente stellt ein Html-Input Element dar. Der darzustellende Wert wird einfach mit dem Attribut
* 'value' übergeben. Der eingegebene Wert muss dann später explizit aus Value abgefragt werden. Es findet also kein
* sutomatisches Binding statt.
* sutomatisches Binding statt. Über den oninput-Callback kann jedoch ein Live-Binding realisiert werden,
* damit Eingaben bei einem Redraw (z.B. durch interaktive Elemente außerhalb des Inputs) nicht verloren gehen.
*/
export const Input:m.Component<iInput> = {
onupdate: (vnode) => {
@@ -71,6 +73,9 @@ export const Input:m.Component<iInput> = {
if (vnode.attrs.onchanged != undefined) {
attributes['onchange'] = vnode.attrs.onchanged
}
if (vnode.attrs.oninput != undefined) {
attributes['oninput'] = vnode.attrs.oninput
}
return m("input.w3-input.w3-border", attributes)
+408 -33
View File
@@ -8,6 +8,11 @@ export interface iSelectOption {
value: any
/** Anzeige im Feld bzw. in der Dropdown-Liste (z.B. Kundenname). */
label: string
/** Optionaler fertiger Darstellungs-Content (z.B. eine Badge-Komponente),
* der im Auswahl-Modal des MultiSelect statt Farbpunkt und Label
* angezeigt wird. Das label bleibt davon unberührt — es ist weiterhin
* die Basis für die Suche im Modal. */
content?: m.Child
}
/**
@@ -47,6 +52,20 @@ export const Select: m.ClosureComponent<iSelect> = () => {
let highlightIndex: number = -1
// Referenz auf das Eingabefeld für Fokus nach Clear.
let inputfield: HTMLInputElement | null = null
// Zuletzt im Suchtext dargestellter value. Dient der Synchronisation:
// Der Anzeigetext wird beim Initialisieren und bei der Optionsauswahl
// gesetzt; ändert sich der attrs.value von außen (z.B. Zurücksetzen
// eines Filters im Eltern-View), wird der Text im view nachgezogen.
let lastSyncedValue: any = undefined
// True, solange der attrs.value noch keinem label zugeordnet werden
// konnte — typischerweise weil die Optionen asynchron geladen werden
// und beim oninit noch leer waren. Der view wiederholt die Synchro-
// nisierung dann, bis eine passende Option existiert.
let syncPending: boolean = false
// Zeigt die Optionliste ungefiltert an, unabhängig vom Suchtext.
// Wird vom Öffnen-Button gesetzt (neben dem Clear-Button) und beim
// Tippen, bei der Auswahl und mit Escape zurückgesetzt.
let showAll: boolean = false
/**
* Liefert die gefilterten Optionen abhängig vom aktuellen Suchtext.
@@ -59,13 +78,29 @@ export const Select: m.ClosureComponent<iSelect> = () => {
return options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower))
}
/**
* Setzt den Suchtext auf das label des übergebenen Werts und merkt
* sich diesen als synchronisiert. Wird beim Initialisieren, bei der
* Optionsauswahl und bei externen value-Änderungen aufgerufen.
*/
const syncSearchZumValue = (value: any, options: iSelectOption[]) => {
const option = options.find((o: iSelectOption) => o.value === value)
search = option ? option.label : ""
lastSyncedValue = value
// Konnte der value keinem label zugeordnet werden, bleibt die
// Synchronisierung offen und wird im view wiederholt, sobald
// die Optionen (asynchron) nachgeladen wurden.
syncPending = !option
}
/**
* Wählt eine Option aus: value setzen, Suchtext = label, Dropdown zu.
*/
const selectOption = (option: iSelectOption, vnode: m.Vnode<iSelect>) => {
vnode.attrs.value = option.value
search = option.label
syncSearchZumValue(option.value, vnode.attrs.options)
isOpen = false
showAll = false
highlightIndex = -1
if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(option.value)
@@ -86,22 +121,36 @@ export const Select: m.ClosureComponent<iSelect> = () => {
return {
oninit: (vnode) => {
// Aktuelles label zum value ermitteln (für die Anzeige im Feld).
const initialOption = vnode.attrs.options.find((o: iSelectOption) => o.value === vnode.attrs.value)
search = initialOption ? initialOption.label : ""
syncSearchZumValue(vnode.attrs.value, vnode.attrs.options)
},
view: (vnode) => {
// Extern geänderte value nachziehen: Der Suchtext wird nur beim
// Initialisieren und bei der Optionsauswahl gesetzt. Ändert der
// Eltern-View den attrs.value (z.B. Zurücksetzen eines Filters),
// wird der angezeigte Text hier auf das neue label synchronisiert.
if (vnode.attrs.value !== lastSyncedValue || syncPending) {
syncSearchZumValue(vnode.attrs.value, vnode.attrs.options)
}
const isReadonly = vnode.attrs.readonly === true
const filteredOptions = getFilteredOptions(vnode.attrs.options)
// Angezeigt wird die ungefilterte Liste, wenn der Öffnen-Button
// genutzt wurde, sonst die vom Suchtext gefilterte.
const angezeigteOptionen = showAll ? vnode.attrs.options : filteredOptions
const inputAttrs: any = {
type: "text",
value: search,
style: {'margin-bottom': '5px', width: '100%', 'padding-right': '32px'},
style: {'margin-bottom': '5px', width: '100%', 'padding-right': '58px'},
oncreate: (vnode: m.VnodeDOM) => {
inputfield = vnode.dom as HTMLInputElement
},
oninput: (e: Event) => {
if (isReadonly) { return }
search = (e.target as HTMLInputElement).value
// Der Nutzer tippt selbst — eine offene Synchronisierung
// (asynchron nachgeladene Optionen) nicht mehr nachziehen.
syncPending = false
// Tippen sucht wieder im Suchtext-Basis.
showAll = false
isOpen = true
highlightIndex = filteredOptions.length > 0 ? 0 : -1
},
@@ -115,22 +164,23 @@ export const Select: m.ClosureComponent<iSelect> = () => {
},
onkeydown: (e: KeyboardEvent) => {
if (isReadonly) { return }
if (filteredOptions.length === 0) { return }
if (angezeigteOptionen.length === 0) { return }
if (e.key === "ArrowDown") {
e.preventDefault()
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
highlightIndex = (highlightIndex + 1) % filteredOptions.length
highlightIndex = (highlightIndex + 1) % angezeigteOptionen.length
} else if (e.key === "ArrowUp") {
e.preventDefault()
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
highlightIndex = (highlightIndex - 1 + filteredOptions.length) % filteredOptions.length
highlightIndex = (highlightIndex - 1 + angezeigteOptionen.length) % angezeigteOptionen.length
} else if (e.key === "Enter") {
if (isOpen && highlightIndex >= 0 && highlightIndex < filteredOptions.length) {
if (isOpen && highlightIndex >= 0 && highlightIndex < angezeigteOptionen.length) {
e.preventDefault()
selectOption(filteredOptions[highlightIndex], vnode)
selectOption(angezeigteOptionen[highlightIndex], vnode)
}
} else if (e.key === "Escape") {
isOpen = false
showAll = false
highlightIndex = -1
}
}
@@ -145,31 +195,68 @@ export const Select: m.ClosureComponent<iSelect> = () => {
return m("div", {style: {width: '100%', position: 'relative'}},
// Verstecktes Feld für die Formularübergabe (echter value).
m("input", {type: "hidden", name: vnode.attrs.name, value: vnode.attrs.value}),
// Sichtbares Such-/Anzeigefeld mit Clear-Button darin.
// Sichtbares Such-/Anzeigefeld mit Öffnen- und Clear-Button darin.
m("div", {style: {position: 'relative', width: '100%'}},
m("input.w3-input.w3-border", inputAttrs),
// Clear-Button: leert das Suchfeld (nur wenn nicht readonly).
// Absolut positioniert, sodass der Button optisch im
// Eingabefeld liegt und vertikal zentriert ist.
!isReadonly
? m("button.w3-button", {
style: {
position: 'absolute',
right: '4px',
top: '50%',
transform: 'translateY(-50%)',
'min-width': '24px',
height: '24px',
padding: '2px',
'line-height': '1'
},
onclick: () => {
search = ""
isOpen = true
highlightIndex = filteredOptions.length > 0 ? 0 : -1
if (inputfield) { inputfield.focus() }
}
}, m("i.material-icons", {style: {'font-size': '20px'}}, "clear"))
? [
// Öffnen-Button: blendet die Optionliste ungefiltert
// ein, ohne das Feld zu leeren — der aktuelle Wert
// bleibt stehen, bis eine neue Option gewählt wird.
m("button.w3-button", {
type: "button",
title: "Liste öffnen (alle Optionen anzeigen)",
style: {
position: 'absolute',
right: '28px',
top: '50%',
transform: 'translateY(-50%)',
'min-width': '24px',
height: '24px',
padding: '2px',
'line-height': '1'
},
// mousedown mit preventDefault: hält den Fokus
// auf dem Eingabefeld (kein Blur-Rennen mit dem
// verzögerten Schließen) und öffnet/schließt
// die ungefilterte Liste. Klick bei offener
// Gesamtliste schließt sie wieder (Toggle).
onmousedown: (e: Event) => {
e.preventDefault()
if (isOpen && showAll) {
isOpen = false
showAll = false
highlightIndex = -1
} else {
showAll = true
isOpen = true
highlightIndex = 0
}
}
}, m("i.material-icons", {style: {'font-size': '20px'}}, "arrow_drop_down")),
// Clear-Button: leert das Suchfeld.
// Absolut positioniert, sodass der Button optisch im
// Eingabefeld liegt und vertikal zentriert ist.
m("button.w3-button", {
type: "button",
style: {
position: 'absolute',
right: '4px',
top: '50%',
transform: 'translateY(-50%)',
'min-width': '24px',
height: '24px',
padding: '2px',
'line-height': '1'
},
onclick: () => {
search = ""
isOpen = true
highlightIndex = filteredOptions.length > 0 ? 0 : -1
if (inputfield) { inputfield.focus() }
}
}, m("i.material-icons", {style: {'font-size': '20px'}}, "clear"))
]
: null
),
// Gefilterte, aufklappbare Optionliste. Eigene Positionierung,
@@ -186,9 +273,9 @@ export const Select: m.ClosureComponent<iSelect> = () => {
'box-shadow': '2px 2px 4px darkgrey'
}
},
filteredOptions.length === 0
angezeigteOptionen.length === 0
? m("div.w3-padding-small", "Keine Treffer")
: filteredOptions.map((option: iSelectOption, index: number) =>
: angezeigteOptionen.map((option: iSelectOption, index: number) =>
m("a.w3-bar-item.w3-button.w3-text-left", {
style: {whiteSpace: 'normal'},
class: index === highlightIndex ? "w3-theme" : "w3-hover-light-grey",
@@ -207,3 +294,291 @@ export const Select: m.ClosureComponent<iSelect> = () => {
}
}
}
/**
* Der Vnode der Komponente 'MultiSelect' akzeptiert die folgenden Parameter:
* @property {string} name - Der Name des Feldes (für FormData; pro gewähltem
* Wert wird ein hidden input dieses Namens erzeugt)
* @property {any[]} selected - Die aktuell gewählten Werte (values der Optionen)
* @property {iSelectOption[]} options - Die zur Auswahl stehenden Optionen
* @property {boolean} readonly (optional) - Auswahl gesperrt
* @property {Map<any, string>} colors (optional) - Wert -> Hexfarbe für
* Farbpunkte/Chips (z.B. Tag-Farben)
* @property {string} placeholder (optional) - Platzhalter-Badge-Text bei
* leerer Auswahl (Default: "kein Wert")
* @property {string} modalTitle (optional) - Titel des Auswahl-Modals
* (Default: "Auswahl")
* @property {string} editTooltip (optional) - Tooltip des Edit-Buttons
* (Default: "Bearbeiten")
* @property {Function} onchanged (optional) - Callback bei Änderung der Auswahl
*/
export interface iMultiSelect {
name: string
selected: any[]
options: iSelectOption[]
readonly?: boolean
colors?: Map<any, string>
placeholder?: string
modalTitle?: string
editTooltip?: string
onchanged?: Function
}
/**
* Mehrfachauswahl-Widget mit Modal-Dialog. Im Formular werden nur die
* ausgewählten Werte als farbige Badges (bzw. ein grauer Platzhalter bei
* leerer Auswahl) zusammen mit einem Edit-Button angezeigt. Beim Klick
* auf den Edit-Button öffnet sich ein Modal mit einem Suchfeld, der
* durchsuchbaren Checkbox-Liste aller Optionen und einem OK-Button zum
* Schließen.
*
* Das Widget ist generisch und nicht auf Tags beschränkt — es arbeitet
* mit beliebigen iSelectOption-Listen. Tag-spezifische Texte (Platzhalter,
* Modal-Titel, Tooltip) sind über die entsprechenden Attribute
* konfigurierbar.
*
* Für die Formularübergabe wird pro gewähltem Wert ein verstecktes
* input-Element erzeugt, sodass FormData.getAll(name) alle IDs liefert.
*/
export const MultiSelect: m.ClosureComponent<iMultiSelect> = () => {
// Suchtext und Modal-Sichtbarkeit.
let search: string = ""
let isModalOpen: boolean = false
// Lokale Arbeitskopie der Auswahl während das Modal geöffnet ist.
let draft: any[] = []
const getFilteredOptions = (options: iSelectOption[], searchText: string): iSelectOption[] => {
const searchLower = searchText.toLowerCase()
if (searchLower === "") {
return options
}
return options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower))
}
return {
oninit: (vnode) => {
search = ""
isModalOpen = false
draft = []
},
view: (vnode) => {
const isReadonly = vnode.attrs.readonly === true
const colors = vnode.attrs.colors
const placeholder = vnode.attrs.placeholder || "kein Wert"
const modalTitle = vnode.attrs.modalTitle || "Auswahl"
const editTooltip = vnode.attrs.editTooltip || "Bearbeiten"
const selected: any[] = Array.isArray(vnode.attrs.selected) ? vnode.attrs.selected : []
const filteredOptions = getFilteredOptions(vnode.attrs.options, search)
const isDraftSelected = (value: any) => draft.includes(value)
const toggleDraft = (option: iSelectOption) => {
if (isDraftSelected(option.value)) {
const idx = draft.indexOf(option.value)
if (idx >= 0) { draft.splice(idx, 1) }
} else {
draft.push(option.value)
}
}
const removeChip = (value: any, e: Event) => {
e.stopPropagation()
e.preventDefault()
const idx = vnode.attrs.selected.indexOf(value)
if (idx >= 0) { vnode.attrs.selected.splice(idx, 1) }
if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(vnode.attrs.selected.slice())
}
}
const openEditModal = (e: Event) => {
e.preventDefault()
// draft als Kopie der aktuellen Auswahl initialisieren.
draft = selected.slice()
search = ""
isModalOpen = true
}
const closeModal = () => {
// Auswahl aus draft in selected übernehmen.
vnode.attrs.selected.splice(0, vnode.attrs.selected.length)
Array.prototype.push.apply(vnode.attrs.selected, draft)
if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(vnode.attrs.selected.slice())
}
isModalOpen = false
search = ""
}
// Badges für ausgewählte Werte. Bei leerer Auswahl wird ein
// graues Platzhalter-Badge "kein Tag" angezeigt.
const badges = m("div", {
style: {
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
'margin-bottom': '5px',
'min-height': '20px',
alignItems: 'center'
}
},
selected.length > 0
? selected.map((value: any) => {
const opt = vnode.attrs.options.find((o: iSelectOption) => o.value === value)
const label = opt ? opt.label : String(value)
const color = colors ? colors.get(value) : undefined
return m("span.w3-tag.w3-small", {
style: {
backgroundColor: color || '#999',
color: 'white',
borderRadius: '3px',
padding: '2px 4px',
display: 'inline-flex',
alignItems: 'center',
gap: '2px',
cursor: isReadonly ? 'default' : 'pointer'
}
},
m("span", label),
!isReadonly
? m("button.w3-button", {
style: {
'min-width': '16px',
height: '16px',
padding: '0',
'line-height': '1',
color: 'white',
background: 'transparent'
},
onclick: (e: Event) => removeChip(value, e)
}, "×")
: null
)
})
: m("span.w3-tag.w3-small", {
style: {
backgroundColor: '#bbb',
color: 'white',
borderRadius: '3px',
padding: '2px 4px',
display: 'inline-flex',
alignItems: 'center'
}
}, placeholder)
)
return m("div", {style: {width: '100%', position: 'relative'}},
// Versteckte Felder für die Formularübergabe (ein pro gewähltem Wert).
selected.map((value: any) =>
m("input", {type: "hidden", name: vnode.attrs.name, value: value})
),
m("div", {style: {display: 'flex', alignItems: 'center', gap: '8px'}},
badges,
!isReadonly
? m("button.w3-button.w3-small", {
title: editTooltip,
style: {
'min-width': '32px',
height: '32px',
padding: '0 4px',
'line-height': '1'
},
onclick: openEditModal
}, m("i.material-icons", {style: {'font-size': '20px'}}, "edit"))
: null
),
// Inline gerendertes Modal für die Tag-Auswahl.
isModalOpen
? m("div.w3-modal", {style: {display: 'block'}},
m("div.w3-modal-content.w3-card-4", {style: {width: '500px', top: '10%'}},
m("header.w3-container.w3-theme",
m("h2", modalTitle)
),
m("div.w3-container.w3-theme-l1", {style: {padding: '16px'}},
// Suchfeld. Der oncreate-Hook fokussiert das
// Feld direkt beim Öffnen des Modals — das
// Input wird ja erst mit dem Modal erzeugt,
// sodass der Nutzer sofort tippen kann.
m("input.w3-input.w3-border", {
type: "text",
value: search,
placeholder: "Suchbegriff …",
style: {'margin-bottom': '10px', width: '100%'},
oncreate: (vnode: m.VnodeDOM) => {
(vnode.dom as HTMLInputElement).focus()
},
oninput: (e: Event) => {
search = (e.target as HTMLInputElement).value
}
}),
// Checkbox-Liste der (gefilterten) Tags.
m("div.w3-bar-block.w3-border", {
style: {
'max-height': '300px',
'overflow-y': 'auto',
'margin-bottom': '10px'
}
},
filteredOptions.length === 0
? m("div.w3-padding-small", "Keine Treffer")
: filteredOptions.map((option: iSelectOption) => {
const color = colors ? colors.get(option.value) : undefined
return m("label.w3-bar-item.w3-button.w3-text-left", {
style: {whiteSpace: 'normal', display: 'flex', alignItems: 'center', gap: '6px'}
},
m("input.w3-check", {
type: "checkbox",
checked: isDraftSelected(option.value),
style: {'margin-right': '4px'},
onclick: (e: Event) => {
if (isReadonly) { return }
e.preventDefault()
toggleDraft(option)
}
}),
// Ist ein fertiger content gesetzt (z.B. eine
// Badge-Komponente des Aufrufers), wird dieser
// statt Farbpunkt und Label angezeigt — das
// Widget selbst bleibt frei von Tag-Wissen.
option.content
? m("span", {
style: {
display: 'inline-flex',
alignItems: 'center',
flex: '1 1 auto'
}
}, option.content)
: [
color
? m("span", {
style: {
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: color,
flex: '0 0 auto'
}
})
: null,
m("span", option.label)
]
)
})
),
// OK-Button schließt das Modal.
m("div.w3-bar",
m("button.w3-button.w3-theme.w3-right",
{type: "button", onclick: closeModal},
"OK"
)
)
)
)
)
: null
)
}
}
}
+25 -12
View File
@@ -11,6 +11,8 @@ import { Call_Log, call_log_liste } from "./Components/call_log"
import { Kontakt, kontaktliste } from "./Components/kontakt"
import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse"
import { Tag, TagList } from "./Components/tags"
import { TagesWorkflowListe, TagesWorkflowMaske } from "./Components/tages_workflow"
import { guardedNavigate } from "./Components/widgets/dirtyguard"
function healthcheck () {
@@ -118,21 +120,30 @@ const knopf: m.Component = {
}
const menu: m.ClosureComponent = () => {
let showProjektBerechtigungen = false
// Menü-Eintrag, der die Navigation über den Dirty-Guard leitet, so dass
// aus einer Bearbeitungsmaske heraus ungespeicherte Änderungen abgefragt
// werden, bevor die Ansicht wechselt.
const menuItem = (route: string, label: string) => {
return m("a.w3-button.w3-bar-item",
{href: "#", onclick: (e: Event) => {e.preventDefault(); guardedNavigate(route, undefined, e)}},
label)
}
return {
oninit: (vnode) => {
showProjektBerechtigungen = false
},
view: (vnode) => {
return m(menubar, [
m("a.w3-button.w3-bar-item", {href: "#!/kundenliste"}, "Kunden"),
m("a.w3-button.w3-bar-item", {href: "#!/projektliste"}, "Projekte"),
m("a.w3-button.w3-bar-item", {href: "#!/calllogliste"}, "Gesprächsnotizen"),
menuItem("/tagesworkflow", "Tages-Workflow"),
menuItem("/kundenliste", "Kunden"),
menuItem("/projektliste", "Projekte"),
menuItem("/calllogliste", "Gesprächsnotizen"),
m("div.w3-dropdown-hover",
m("button.w3-button", "Optionen"),
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
m("a.w3-button.w3-bar-item", {href: "#!/taglist"}, "Tags"),
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenexport"),
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenimport"),
menuItem("/taglist", "Tags"),
menuItem("/default", "Datenexport"),
menuItem("/default", "Datenimport"),
// Projekt-Berechtigungen nur für SysAdmin/AppAdmin sichtbar.
// Als Toggle-Button realisiert, da verschachtelte w3-dropdown-hover
// innerhalb eines offenen Dropdowns nicht zuverlässig funktionieren.
@@ -147,10 +158,10 @@ const menu: m.ClosureComponent = () => {
showProjektBerechtigungen
? m("div.w3-bar-block",
m("a.w3-button.w3-bar-item",
{href: "#!/projektuserliste", style: {'padding-left': '32px'}},
{href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/projektuserliste", undefined, e)}},
"Projekt-Zugriffe"),
m("a.w3-button.w3-bar-item",
{href: "#!/userprojektliste", style: {'padding-left': '32px'}},
{href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/userprojektliste", undefined, e)}},
"Nutzer-Zugriffe")
)
: null
@@ -162,9 +173,9 @@ const menu: m.ClosureComponent = () => {
m("div.w3-dropdown-hover.w3-right",
m("button.w3-button", "System"),
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
m("a.w3-button.w3-bar-item", {href: "#!/login"}, "Login"),
m("a.w3-button.w3-bar-item", {href: "#!/changepassword"}, "Kennwort ändern"),
m("a.w3-button.w3-bar-item", {href: "#!/usermanagement"}, "Verwalten"),
menuItem("/login", "Login"),
menuItem("/changepassword", "Kennwort ändern"),
menuItem("/usermanagement", "Verwalten"),
)
)
])
@@ -205,6 +216,8 @@ m.route(gridMountpoints.main, "/kundenliste", {
"/kontaktadresseliste": kontaktadresseliste,
"/kontaktadresse": Kontaktadresse,
"/taglist": TagList,
"/tag": Tag
"/tag": Tag,
"/tagesworkflow": TagesWorkflowListe,
"/tagesworkflowkontakt": TagesWorkflowMaske
})
+35 -1
View File
@@ -1,16 +1,34 @@
import m from "mithril"
import {iTag} from "./tag"
/**
* Datenmodell für eine Gesprächsnotiz (Call-Log).
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
* kontaktadresse_id ist Pflichtfeld: Jede Notiz gehört zu genau einer
* Kontaktadresse (Fremdschlüssel auf kontaktadressen.id).
* user_id erfasst den Ersteller (Mitarbeiter, der den Anruf getätigt hat);
* sie wird serverseitig beim Anlegen gesetzt und nie vom Client gesendet.
* Alt-Notizen ohne erfassten Ersteller haben null.
* mitarbeiter_name ist keine Spalte, sondern wird serverseitig aus der
* user_id gegen die Auth-Datenbank aufgelöst (null ohne user_id).
* Ein Erledigt-Status ist bewusst kein Feld der Notiz: Er ergibt sich
* daraus, ob einer der zugeordneten Tags is_erledigend=True hat (siehe
* Kontaktadresse.is_erledigt).
* tags (vom Server beim Lesen) und tag_ids (vom Client beim Schreiben)
* steuern die n:m-Beziehung zu den Tags.
*/
export interface iCallLog {
id: number
projekt_id: number
kontaktadresse_id: number
/** Ersteller (Mitarbeiter), serverseitig gesetzt; null bei Alt-Notizen. */
user_id: number | null
projekt_name: string
kunden_name: string
/** Aufgelöster Username des Erstellers; null bei Alt-Notizen. */
mitarbeiter_name: string | null
datum: string
uhrzeit_beginn: string
dauer: string
@@ -19,7 +37,10 @@ export interface iCallLog {
telefon: string
notiz: string
wiedervorlage_datum: string
erledigt: boolean
/** Vom Server gelieferte zugeordnete Tags (nur beim Lesen). */
tags?: iTag[]
/** Vom Client zu übermittelnde Tag-IDs (nur beim Schreiben). */
tag_ids?: number[]
}
/* ------------------------------------------------------------------ */
@@ -63,6 +84,19 @@ export function fetchCallLogById(id: number): Promise<iCallLog> {
}).then((result) => result as iCallLog)
}
/**
* Lädt die Gesprächsnotizen eines einzelnen Kontakts (Kontaktadresse)
* vom Server — serverseitig nach kontaktadresse_id gefiltert. Wird für
* die Historie in der Tages-Workflow-Maske genutzt.
*/
export function fetchCallLogsByKontaktadresse(kontaktadresseId: number): Promise<iCallLog[]> {
return m.request({
method: "GET",
url: CALLLOG_REST_BASE + "/list",
params: {kontaktadresse_id: kontaktadresseId}
}).then((result) => result as iCallLog[])
}
/**
* Legt eine neue Gesprächsnotiz serverseitig an.
*/
+9 -2
View File
@@ -4,13 +4,18 @@ import m from "mithril"
* Datenmodell für eine Kontaktadresse (Akquise-Ziel innerhalb eines Projekts).
* Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projekt_id).
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* projekt_name ist keine ORM-Spalte, sondern wird serverseitig aus der
* Projekt-Relationship abgeleitet und im JSON ergänzt.
* projekt_name, projekt_zweck und kunden_name sind keine ORM-Spalten, sondern
* werden serverseitig aus den Projekt-/Kunde-Relationships abgeleitet und im
* JSON ergänzt. is_erledigt ist ebenfalls keine Spalte, sondern wird
* serverseitig dynamisch berechnet: True, sobald mindestens eine
* Gesprächsnotiz des Kontakts einen Tag mit is_erledigend=True hat.
*/
export interface iKontaktadresse {
id: number
projekt_id: number
projekt_name: string
projekt_zweck: string
kunden_name: string
anrede: string
name: string
firma: string
@@ -22,6 +27,8 @@ export interface iKontaktadresse {
ort: string
land: string
notiz: string
/** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */
is_erledigt: boolean
}
/* ------------------------------------------------------------------ */
+5 -1
View File
@@ -4,7 +4,9 @@ import m from "mithril"
* Datenmodell für ein Projekt.
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* kunden_name ist keine ORM-Spalte, sondern wird serverseitig aus der
* Kunde-Relationship abgeleitet und im JSON ergänzt.
* Kunde-Relationship abgeleitet und im JSON ergänzt. is_erledigt ist
* ebenfalls keine Spalte, sondern wird serverseitig dynamisch berechnet:
* True, wenn das Projekt Kontaktadressen hat und alle davon erledigt sind.
*/
export interface iProjekt {
id: number
@@ -14,6 +16,8 @@ export interface iProjekt {
zweck: string
verantwortlicher: string
startdatum: string
/** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */
is_erledigt: boolean
}
/* ------------------------------------------------------------------ */
+143
View File
@@ -0,0 +1,143 @@
import m from "mithril"
/**
* Datenmodell für einen Tag (hierarchische Taxonomie).
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* Tags koennen beliebig verschachtelt werden (parent_id -> tags.id).
*/
export interface iTag {
id: number
name: string
description: string
color: string
parent_id: number | null
is_parent: boolean
/** True = Kontakt gilt als erledigt, wenn dieser Tag zugewiesen wird. */
is_erledigend: boolean
/** True = Tag löst eine Wiedervorlage aus. */
loest_wiedervorlage_aus: boolean
/** Tage bis zur Wiedervorlage (nur relevant, wenn loest_wiedervorlage_aus True). */
wiedervorlage_abstand_tage: number | null
}
/**
* Tag mit verschachtelten Kindern für die Hierarchie-Darstellung.
* Das children-Array wird serverseitig im Hierarchie-Endpunkt befüllt.
*/
export interface iTagWithChildren extends iTag {
children: iTagWithChildren[]
}
/* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten (/xhr/tag/*). */
/* ------------------------------------------------------------------ */
/**
* Basis-Pfad der Tag-REST-Endpunkte.
*/
const TAG_REST_BASE = "/xhr/tag"
/**
* Lädt alle Tags als flache Liste vom Server.
* Ist parentId gesetzt, werden nur die direkten Kinder dieses Tags
* geliefert (serverseitige Filterung).
*/
export function fetchTags(parentId?: number): Promise<iTag[]> {
const params: Record<string, any> = {}
if (parentId !== undefined) {
params.parent_id = parentId
}
return m.request({
method: "GET",
url: TAG_REST_BASE + "/list",
params: params
}).then((result) => result as iTag[])
}
/**
* Lädt die vollständige Tag-Hierarchie als verschachtelte Struktur vom Server.
* Jeder Tag enthält ein children-Array mit seinen rekursiv eingebetteten Kindern.
*/
export function fetchTagHierarchy(): Promise<iTagWithChildren[]> {
return m.request({
method: "GET",
url: TAG_REST_BASE + "/hierarchy"
}).then((result) => result as iTagWithChildren[])
}
/**
* Lädt einen einzelnen Tag anhand seiner ID vom Server.
*/
export function fetchTagById(id: number): Promise<iTag> {
return m.request({
method: "GET",
url: TAG_REST_BASE + "/get",
params: {id: id}
}).then((result) => result as iTag)
}
/**
* Legt einen neuen Tag serverseitig an. Die ID wird vom Server
* vergeben und im zurückgegebenen Tag-Objekt gesetzt.
*/
export function createTagREST(tag: iTag): Promise<iTag> {
return m.request({
method: "POST",
url: TAG_REST_BASE + "/create",
body: tag
}).then((result) => result as iTag)
}
/**
* Aktualisiert einen bestehenden Tag serverseitig.
*/
export function updateTagREST(tag: iTag): Promise<iTag> {
return m.request({
method: "PUT",
url: TAG_REST_BASE + "/update",
body: tag
}).then((result) => result as iTag)
}
/**
* Löscht einen Tag inkl. aller Nachfahren serverseitig anhand seiner ID.
*/
export function deleteTagREST(id: number): Promise<void> {
return m.request({
method: "DELETE",
url: TAG_REST_BASE + "/delete",
params: {id: id}
}).then(() => undefined)
}
/* ------------------------------------------------------------------ */
/* Fachliche Hilfsfunktionen */
/* ------------------------------------------------------------------ */
/**
* Berechnet das Wiedervorlage-Datum automatisch aus den ausgewählten
* Tags. Basis ist das übergebene Basisdatum (z.B. das Datum der Notiz
* oder heute). Aus allen ausgewählten Tags mit
* loest_wiedervorlage_aus=True wird das Minimum von
* wiedervorlage_abstand_tage ermittelt und zum Basisdatum addiert.
* Liefert einen leeren String, wenn kein Tag mit
* loest_wiedervorlage_aus=True ausgewählt ist.
*
* Wird von der Call_Log-Maske und der Tages-Workflow-Maske genutzt,
* damit die Berechnungslogik nur an einer Stelle gepflegt wird.
*/
export function berechneWiedervorlageDatum(tagIds: number[],
tagListe: iTag[],
basisdatum: string): string {
const wvTags = tagListe.filter((t: iTag) =>
tagIds.includes(t.id) && t.loest_wiedervorlage_aus && t.wiedervorlage_abstand_tage != null
)
if (wvTags.length === 0) return ""
const minAbstand = Math.min(...wvTags.map(t => t.wiedervorlage_abstand_tage as number))
const basisDate = new Date(basisdatum)
basisDate.setDate(basisDate.getDate() + minAbstand)
return basisDate.toISOString().split('T')[0]
}
+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[])
}
+3
View File
@@ -0,0 +1,3 @@
# TODOs
- [ ] Mit KBP klären, ob der Verantwortliche im Projekt ein freier Name ist oder ob das ein Kontakt aus der Kontaktliste des Kunden sein muss.
@@ -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**. |