Compare commits

..
60 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
br 947f299543 ignore ergänzt, continue (vscode ai plugin) hat noch eine context erhalten. 2026-09-17 14:16:38 +02:00
gg 44c411e5d7 fix(crm): DB-Migrationen idempotent machen für Neuinstallationen
Bei Neuinstallation erzeugt create_all bereits das gesamte Schema,
so dass nachfolgende ALTER-Table-Migrationen fehlschlagen ("duplicate
column name"). V01_04 prüft nun die Spalten-Existenz per PRAGMA
table_info. checkupdates läuft wieder sequentiell ohne Überspringen.
Konvention in AGENTS.md dokumentiert.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-15 17:36:52 +02:00
gg efef212544 feat(deploy): Image-Promotion, deploy.env, Health-Check-Fix und Anleitungen
- Deploy-Scripts: Image-Promotion statt Rebuild (docker tag + up -d),
  deploy.env fuer Server-Konfiguration, -p fuer Projekt-Namen,
  Health-Check mit Host-Header und /xhr/health-check
- docker-compose.prod.yaml: name-Feld entfernt (Projekt-Name per -p)
- deploy.env.example als Vorlage hinzugefuegt
- .vibe/skills/client-build/SKILL.md aus Git-Index entfernt (gitignore)
- Deployment-Anleitung: Zugangsdaten, deploy.env, Image-Promotion,
  prod ohne eigenes Repo, git-clone-Hinweis, Log-Befehle mit -p
- Deploy-lokal-testen.md: neue Anleitung fuer lokales Testen

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-15 17:10:18 +02:00
gg e824b545bb docs(agents): Begründung für user_id-Parameter ergänzt und Formulierungen korrigiert
Ergänzt eine kurze Begründung, warum user_id als Parameter an Modell-Funktionen
übergeben wird statt current_user direkt im Modell zu lesen. Zudem kleinere
Korrekturen an Tippfehlern, Pfadangaben und Formulierungen in den
TypeScript/Mithril-Vorgaben.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-14 10:06:11 +02:00
gg bde45871f6 Merge feature/auto-deploy: automatisches Deployment und Start-Meldungen 2026-09-13 20:08:24 +02:00
gg 4015f83ba0 chore(runserver): Start-Meldungen fuer Server-Typ ergaenzt
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-13 20:08:12 +02:00
gg fffa9d4a28 docs(deploy): Multi-Stage-Build korrekt beschrieben, SSL-Variablen-Hinweis ergaenzt
Die Deploy-Anleitung sagte irrefuehrend "gesamter Code (Python + TypeScript)" im Image. Korrigiert auf den tatsaechlichen Multi-Stage-Build: nur das fertige JS-Bundle landet im NGINX-Image, nicht der TS-Quellcode. Zussaetzlich Hinweis in env-sample und Deployment-Anleitung, dass NGINX_CERTS/CERT/CERT_KEY auch bei SSL=false nicht geloescht werden duerfen, da docker-compose.base.yaml sie immer als Volume/Env bindet.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-13 19:47:42 +02:00
gg b268b301c1 docs(deploy): COMPOSE_FILE-Beschreibung um BUILD_ENVIRONMENT-Bezug ergaenzt
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-13 19:30:09 +02:00
gg 40ad0e1ba9 refactor(deploy): Image-Versionen fest in Dockerfiles verankern
ARG-Variablen durch feste Werte in FROM ersetzt — nicht per .env
ueberschreibbar. PYTHON_VERSION, NODE_VERSION, NPM_VERSION,
NGINX_VERSION aus .env, env-sample und Compose-Dateien entfernt.
Deployment-Anleitung erstellt und entsprechend angepasst.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-13 19:12:14 +02:00
gg 3b3e2893e5 feat(deploy): Multi-Stage-Docker-Build fuer automatisches Deployment
- App_Server/Dockerfile: Build-Context auf Repo-Root, COPY-Pfade
  angepasst, ENTRYPOINT aktiviert, dev-Stage mit inittypescriptdev.sh
- NGINX_Server/Dockerfile: Multi-Stage mit frontend-Stage (Node.js baut
  TypeScript), statische Dateien und Config-Templates ins Image kopiert
- docker-compose.base.yaml: Context auf '.', python-venv Volume entfernt,
  NGINX_STATIC_DIR/CONFIG_TEMPLATE_DIR bind-mounts in Dev verschoben
- docker-compose.dev.yaml: python-venv-dev Volume, APP_DEBUG=true,
  nginx-proxy mit bind-mounts fuer statische Dateien und Templates
- docker-compose.prod.yaml: Code-bind-mount entfernt, nginx-proxy extends
- approot.py: absolute Pfade, Debug-Modus via APP_DEBUG Umgebungsvariable
- init.sh: vereinfacht (kein cd/pip, absolute Pfade)
- inireader.mjs: absoluter Pfad fuer config.ini
- package.json: scripts.build (tsc + rollup) ergaenzt
- .dockerignore, deploy-kbcrm-test.sh, deploy-kbcrm-prod.sh erstellt
- docker-compose.save.yaml geloescht
- env-sample: BUILD_ENVIRONMENT nach oben, Dev-only Variablen markiert

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-13 18:40:55 +02:00
gg 31ef206bd8 docs(env): Kommentare in env-sample verdeutlichen
NGINX_USE_SSL: Inline-Kommentar durch Block-Kommentar ersetzt,
der Port-Abhaengigkeit bei SSL-Deaktivierung erlaeutert.
COMPOSE_FILE: Einzeiligen Kommentar durch Block-Kommentar ersetzt,
der Bedeutung und Anwendung von COMPOSE_FILE erklaert.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-12 15:48:13 +02:00
gg b1795e2f41 Merge origin/main: revert 96c9cea und c1c92d1, behalte bdb4329 (OIDC/Email-Settings)
- 96c9cea (deploy-Prozess-Aenderungen) vollstaendig revertiert:
  lokale Versionen von .gitignore, NGINX_Server/Dockerfile,
  docker-compose.base.yaml, docker-compose.dev.yaml beibehalten,
  App_Server/Dockerfile auf Stand vor 96c9cea zurueckgesetzt.
- c1c92d1 (env-raus) revertiert: lokale env-sample als Basis,
  .env aus Tracking entfernt (bereits in .gitignore).
- bdb4329 behalten: OIDC/Email-Block an lokale env-sample angehaengt.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-12 15:30:09 +02:00
gg 9ee0433c4d feat(auth): Logout-Meldung 1 Sekunde anzeigen vor Weiterleitung
Dedizierte loggedout-Komponente für die Abmeldebestätigung und
Redirect um 1 Sekunde verzögert, damit die Meldung sichtbar bleibt.
Default-Route auf /kundenliste korrigiert.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-11 16:53:54 +02:00
gg 55d03ddeb3 chore: env-sample als Konfigurationsvorlage hinzufügen und .env ignorieren
.env-Dateien mit echten credentials ab sofort über .gitignore ausgeschlossen;
env-sample als kopierbare Vorlage für NGINX-, Python- und Dev-Parameter eingecheckt.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-11 16:40:06 +02:00
gg b6268f7d22 chore(dev): Vibe-Code-Setup in Devcontainer, docker-compose und .gitignore integrieren
- .devcontainer: mistralai.mistral-vibe-code Extension hinzugefügt
- docker-compose.dev: VIBE_HOME auf /usr/opt/pythonapp/.vibe-global gesetzt
- .gitignore: .vibe/ und .vibe-global/ ignoriert, Pfad-Slashes normalisiert

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-11 16:33:08 +02:00
br bdb4329240 oidc sample
compose stack files in .env
2026-09-11 15:15:44 +02:00
br 96c9ceafc1 kleinigkeiten für deploy prozess... 2026-09-11 15:01:51 +02:00
gg ef563bad2f NGINX: Dynamische SSL-Konfiguration über Umgebungsvariable
- Aufteilung des NGINX-Templates in kbp_crm.nossl.conf.template (ohne SSL)
  und kbp_crm.ssl.conf.template (mit SSL)
- Neues Skript 10-select-ssl.sh wählt Template zur Laufzeit basierend auf
  NGINX_USE_SSL (true/false)
- Dockerfile: Vorbereitung für dynamische Template-Auswahl
- docker-compose: Template-Verzeichnis auf /etc/nginx/configtemplates geändert,
  NGINX_USE_SSL als Umgebungsvariable hinzugefügt
- .env: NGINX_USE_SSL=true standardmäßig aktiviert

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-11 14:52:08 +02:00
gg a6876fcc55 oidc-test Verzeichnis zu .gitignore hinzugefuegt
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-11 13:48:08 +02:00
br c1c92d1169 env-raus 2026-09-11 12:14:23 +02:00
gg 9ed5e88e01 Menü Projekt-Berechtigungen, Übersichtssichten und AppAdmin-Erkennung
- Menü: Dropdown "Projekt-Berechtigungen" unter Optionen mit
  Unterpunkten "Projekt-Zugriffe" und "Nutzer-Zugriffe" (nur SysAdmin/AppAdmin)
- AppAdmin-Erkennung: Server liefert isAppAdmin-Flag im Login- und
  getusers-Response (applikationsspezifisch via has_permission)
- Login: CurrentUserIsAppAdmin in sessionStorage speichern/entfernen
- Projekt-Zugriffe: geschachtelter Treetable (Projekt → Nutzer) als
  reine Anzeige, Detail-Sicht aus Projekt-Maske mit Bearbeitung
- Nutzer-Zugriffe: neue Komponente user_projektliste.ts, geschachtelter
  Treetable (Nutzer → Projekte), reine Anzeige
- Server: neuer Endpunkt GET /xhr/projektuser/listall (nur AppAdmin/SysAdmin)
- Server: get_all_projekt_users() mit Batch-Query für Projekte und User
- Treetable-Widget: Platzhalter-Pfeil bei kinderlosen Nodes nur wenn
  Geschwister mit Kindern existieren (hasSiblingsWithChildren)
- Datei projekt_user.ts umbenannt in projekt_userliste.ts (konsistent
  mit user_projektliste.ts)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-07 16:04:06 +02:00
gg 505177b5e2 ProjektUser-Modal: Mehrfachauswahl, Filterung und Fehlermeldungs-Verbesserung
- Modal von Einzelauswahl (Select) auf Mehrfachauswahl mit Checkboxen umgestellt
- Nutzerliste sortiert nach username, scrollbar (eine Zeile pro Nutzer)
- SysAdmin und AppAdmin aus der Auswahlliste ausgeblendet (immer Zugriff)
- Bereits zugeordnete Nutzer ausgeblendet
- Fehleranzeige bei fehlender Rolle: closeModal im catch entfernt,
  sodass showServerError nicht sofort überschrieben wird
- AutorisationError: Meldung "Erforderliche Rollen" nur bei nicht-leerer
  allowed_roles-Liste (entfällt bei @authorisation_required([]))

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-05 16:14:13 +02:00
gg 4cb0084066 Projekt-Sichtbarkeit: Nutzer-Zuordnung, projektbezogene Filterung und Schreibschutz
- Neues Modell ProjektUser (projekt_users) für projektbezogene Sichtbarkeit
- Migration V01_09 legt Tabelle an (keine Initial-Befüllung)
- CRUD-Logik für Projekt-User-Zuordnungen mit Cross-DB-User-Auflösung
- Serverseitige Sichtbarkeits-Filterung: Projekt-Liste/Get filtert nach
  projekt_users-Zuordnung für Nicht-SysAdmin/Nicht-AppAdmin
- Auto-Assignment: Ersteller wird bei Projektanlage automatisch zugeordnet
- Sichtbarkeits-Filterung auf CallLog und Kontaktadresse erweitert
  (Liste und Einzelabruf)
- Schreibschutz: Alle Schreib-Endpunkte (update/delete) für Projekt,
  Kontaktadresse und CallLog prüfen Projekt-Zuordnung vor der Ausführung
- Hilfsfunktionen _get_visible_user_id() und _require_projekt_access()
  zur Kapselung der wiederkehrenden Prüfungen
- REST-Endpunkte /xhr/projektuser/{list,add,remove};
  Schreib-Endpunkte mit @authorisation_required([]) als Erweiterungspunkt
- Client: Neues Modell projekt_user.ts, Listen-Komponente mit Modal
  (Select-Widget), Button in Projekt-Maske, Route registriert
- Build aktualisiert (tsc + rollup)
- AGENTS.md: Vorgaben für projektbezogene Sichtbarkeit dokumentiert

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-05 15:45:32 +02:00
gg b2f8684afb Fehlerbehandlung für REST-Aufrufe in allen CRM-Komponenten und Konvention in AGENTS.md
Alle m.request-Aufrufe in den CRM-Komponenten (kunde, kontakt, projekt,
kontaktadresse, call_log) erhalten .catch(showServerError), sodass
Serverfehler wie 401 (fehlende Autorisierung) dem Nutzer als Modal
angezeigt werden. Die Navigation bleibt im .then-Zweig, damit sie bei
Fehler nicht ausgeführt wird. AGENTS.md erhält die entsprechende
Konvention für zukünftige REST-Aufrufe.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-05 14:29:23 +02:00
gg 461953a7e9 Datumsspalten auf Date-Typ umgestellt und Normalisierungsmigration
Modelle: startdatum (Projekt), datum und wiedervorlage_datum (CallLog)
von String(20) auf Date umgestellt. columns2dict serialisiert diese nun
über isoformat() als yyyy-MM-dd. Die String-Normalisierung in
columns2dict wurde entfernt, da sie nicht mehr benötigt wird.

Migration V01.08: Normalisiert bestehende String-Datumswerte (z.B.
'2026-08-3') auf ISO-Format yyyy-MM-dd mittels printf/substr, da
SQLite's date() einstellige Komponenten nicht erkennt. Leere Strings
werden zu NULL. V01.07 ist Platzhalter für die fehlgeschlagene V01.06.

Client: toDateInputValue normalisiert ebenfalls String-Datumswerte
als Fallback für Route-Params (Neuanlage).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-04 16:57:17 +02:00
gg 93827dde0f Serverseitige Filterung für Listen-Endpunkte und Einzelabrufe in Listen-Komponenten
Server: Die list-Endpunkte für Kontakt, Projekt, Kontaktadresse und CallLog
akzeptieren nun einen optionalen Query-Parameter (kunden_id bzw. projekt_id)
und filtern im ORM-Query. Neue Migration V01.05 legt Indizes auf den
FK-Spalten an.

Client: Die fetch-Funktionen erhalten einen optionalen Parent-ID-Parameter.
Die Listen-Komponenten übergeben die Parent-ID aus den Route-Params an den
Server und laden nur noch benötigte Daten. Statt kompletter Eltern-Listen
wird per fetchXxxById ein einzelner Datensatz für die Namensauflösung geladen.
Die CallLog-Bearbeitungsmaske behält ihre ungesfilterten Dropdown-Abrufe.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-04 15:43:43 +02:00
gg e9a2f48ede Kontaktadresse: Neues Feld Position, Filter-Funktionen null-sicher, Projektnamen-Spalte entfernt
- Server: Neue Spalte position in Kontaktadresse-Modell (String(200), nullable)
- Server: Migration V01_04 (ALTER TABLE kontaktadressen ADD COLUMN position)
- Server: position in Geschäftslogik create/update aufgenommen
- Server: CLI-Musterdaten um Position ergänzt (Geschäftsführer, IT-Leiter etc.)
- Client: position in iKontaktadresse-Interface, View nach Firma, Columnmapper, Filter
- Client: Projektnamen-Spalte aus Kontaktadressenliste entfernt (steht in Überschrift)
- Client: Filter-Funktionen in allen Listen null-sicher (kunde, kontakt, projekt, kontaktadresse, call_log)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-04 14:58:52 +02:00
gg b510c50497 Server+Client: REST-API für Kontaktadresse und CallLog, In-Memory vollständig entfernt, CLI-Musterdaten, Route-Param-Fix
- Server: ORM-Modelle Kontaktadresse (kontaktadressen) und CallLog (call_logs) mit FK projekt_id
- Server: Geschäftslogik kontaktadresse.py und call_log.py (get/create/update/delete)
- Server: DB-Migration V01_03 (kontaktadressen + call_logs Tabellen)
- Server: REST-Endpunkte /xhr/kontaktadresse/* und /xhr/calllog/* mit Autorisierung
- Server: CLI init-db legt Muster-Kontaktadressen (1-3/Projekt) und Muster-Gesprächsnotizen (2-5/Projekt) an
- Client: kontaktadresse.ts auf REST umgestellt, snake_case, In-Memory entfernt
- Client: call_log.ts auf REST umgestellt, snake_case, In-Memory entfernt, Submit-Handler implementiert
- Client: projekt.ts In-Memory vollständig entfernt (keine weiteren Verbraucher)
- Client: Route-Params projektId → projekt_id korrigiert (3 Stellen in projekt.ts)
- Client: addCallLog in projekt.ts Route-Params auf snake_case angepasst
- AGENTS.md: Erinnerung client-build-Skill nach .ts-Änderungen hinzugefügt

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-04 12:35:07 +02:00
gg e04a52857a Server+Client: REST-API für Entität Projekt, Kontakt auf REST umgestellt, ID-basiertes Laden, CLI-Musterprojekte, VS-Code Task
- Server: ORM-Modell Projekt (Tabelle projekte) mit FK kunden_id, Relationship zu Kunde
- Server: Geschäftslogik projekt.py (get/create/update/delete)
- Server: DB-Migration V01_02 (projekte-Tabelle)
- Server: REST-Endpunkte /xhr/projekt/* mit Autorisierung
- Server: CLI init-db legt Muster-Projekte je Kunde an (1-3)
- Client: kontakt.ts auf REST umgestellt, In-Memory entfernt, snake_case
- Client: projekt.ts auf REST umgestellt, In-Memory behalten für call_log/kontaktadresse
- Client: ID-basiertes Laden (nur ID in Route-Params, fetchById im oninit)
- Client: Kundenname in Überschrift von Kontaktliste/View und Projektliste/View
- Client: Select-Widget entfernt (Kunde voreingestellt), Hidden-Input für kunden_id
- Client: kunde.ts showProjekte/showKontakte auf kunden_id geändert
- Client: call_log.ts/kontaktadresse.ts Feldzugriffe auf iProjekt snake_case angepasst
- AGENTS.md: Vorgehensweise ID-basiertes Laden hinterlegt, Branch-Regel formuliert
- VS-Code Task "Flask: init-db" mit venv-Pfaden

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-04 12:03:52 +02:00
gg 4b172081b9 client-build Skill und AGENTS.md: generierte Dateien nicht beachten
Neuer Skill .vibe/skills/client-build: automatisiert tsc + rollup Build
nach TypeScript-Änderungen unter Client/.

AGENTS.md ergänzt: Client/tsout/ und NGINX_Server/html/static/*.js als
generierte Dateien markiert, nicht bei Codeanalyse berücksichtigen.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-03 16:53:46 +02:00
gg 455157942a vscode/tasks.json: Hartcodierte sqlite3-Pfade durch plattformunabhängigen Aufruf ersetzt
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-03 16:44:56 +02:00
gg 15d4e2ac48 Client: Kunden-Datenhaltung auf Server-REST-Endpunkte umgestellt
In-Memory-Datenhaltung (kundenDaten, getNewKundeId, addKunde, updateKunde,
getKunden) aus kunde.ts entfernt. Die Kundenliste und das Formular nutzen
jetzt fetchKunden/createKundeREST/updateKundeREST asynchron vom Server.

projekt.ts, kontakt.ts, call_log.ts: getKunden() durch asynchrones
fetchKunden() im oninit ersetzt, Kundenliste im Closure-State gehalten.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-03 16:43:50 +02:00
69 changed files with 9325 additions and 1719 deletions

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
# kbcrm Project Guide
Welcome to the **kbcrm** project! This guide provides an overview of the project's structure, technologies, and development workflow. It is designed to help you understand how to navigate and contribute to this application effectively.
## 1. Project Overview
**kbcrm** is a Customer Relationship Management application that features a decoupled architecture with a Python/Flask backend and a TypeScript/Mithril.js frontend. It delegates authentication to an external Identity Provider (Zitadel) using OIDC, while managing local user data and roles via a SQLite database.
### Key Technologies
* **Backend:** Python, Flask 2.x, SQLAlchemy (SQLite)
* **Authentication:** Authlib, Zitadel (IdP)
* **Production Server:** Waitress
* **Frontend:** TypeScript, Mithril.js
* **Infrastructure:** Docker, Nginx
### Architecture
The application follows a client-server architecture. The `App_Server` contains the backend API and business logic, serving REST endpoints. The `Client` directory holds the source code for the Single Page Application (SPA), which is built into static JavaScript files. These static files are served by an Nginx server, configured in the `NGINX_Server` directory.
## 2. Getting Started
### Prerequisites
* Python 3.11+
* Node.js and npm (for building the frontend)
* Docker and Docker Compose (for containerized deployment/development)
### Local Development Setup (Backend)
1. **Create and activate a virtual environment:**
```bash
cd App_Server
python3 -m venv venv
source venv/bin/activate
```
2. **Install dependencies:**
```bash
pip install -r root/requirements.txt
```
3. **Configuration:**
Copy the `.env.example` to `.env` (in the project root) and configure your Zitadel credentials and other environment variables.
4. **Initialize Database:**
The database schema is initialized using scripts in the application. (Refer to the `checkdb.py` mechanisms for DB migrations).
5. **Run the application:**
```bash
python root/runserver.py
```
*Note: The exact command might depend on how `runserver.py` is configured to launch the Flask app.*
### Local Development Setup (Frontend)
1. **Install Node dependencies:**
```bash
cd Client
npm install
```
2. **Build the client:**
```bash
npm run build
```
This command compiles TypeScript and uses Rollup to bundle the JavaScript files, placing the output in `NGINX_Server/html/static/`.
## 3. Project Structure
* `App_Server/`: Backend application directory.
* `root/`: Contains the core Python application code.
* `auth/`: User management and authentication logic.
* `routes/`: Flask route definitions.
* `db.py`: Database connection setup.
* `data/`: SQLite database files.
* `Client/`: Frontend SPA source code.
* `Components/`: Mithril.js GUI components.
* `widgets/`: Reusable UI elements (inputs, buttons, etc.).
* `models/`: TypeScript data models and API calls.
* `NGINX_Server/`: Configuration for the Nginx web server.
* `html/static/`: Contains the compiled CSS and JS files for the frontend.
* `ai-context/`: Guidelines and rules for AI assistants working on this project.
* `docker-compose.*.yaml`: Docker compose configurations for different environments.
## 4. Development Workflow
### Frontend Development (TypeScript & Mithril.js)
* **Responsive Design:** Use `w3.css` classes (located in `NGINX_Server/html/static/w3.css`) for layout and responsive design. Wrap view containers with `w3-container w3-auto`.
* **Theming:** Prefer classes from `app-theme.css` for colors.
* **Icons:** Use Google Material Icons from the `Google_Symbols.woff2` file.
* **Mithril.js Components:**
* When using `m.ClosureComponent`, omit the closure parameter (`() =>`) and access attributes exclusively via the `vnode` in lifecycle hooks (`oninit`, `view`, etc.).
* Avoid reading `attrs` directly in the closure scope, as they will be stale on redraws.
* Format long attribute lists cleanly.
* **API Calls:** Every `m.request` must append `.catch((e) => showServerError(e))` to display server errors globally. Navigation logic (`m.route.set`) should reside in the `.then` block.
* **Data Models:** Create distinct `.ts` files for data models in `Client/Components/models` (or `Client/Models` as per guidelines). Perform API calls in these files. Edit forms should fetch fresh data by ID on initialization, rather than relying on cached list data.
* **Build Process:** After editing files in `Client/`, always run `npm run build` (or the specific build command) to update the bundles in `NGINX_Server/html/static/`. *Do not edit files in `Client/tsout/` or the compiled `.js` files in `NGINX_Server`.*
### Backend Development (Python & Flask)
* **Project Isolation:** Projects are user-specific. Always filter read and write operations based on `projekt_users`.
* Use `_get_visible_user_id()` for read endpoints to filter by the current user (admins see all).
* Use `_require_projekt_access(projekt_id)` before write operations (create/update/delete) to enforce access control. Return a 404 error if access is denied to avoid revealing project existence.
* **Database Migrations:** Changes to the schema must be handled via `_db_Vxx_xx` functions in `checkdb.py`. **All migrations must be idempotent.** Use `IF NOT EXISTS` for indexes and check for column existence before adding them.
### Code Style and Conventions
* **Documentation:** All Python modules (`.py`) and Jinja2 templates (`.html`) must start with a standardized header containing metadata (`Author:`, `Date:`, `Purpose:`, etc.). Python docstrings are used by Sphinx to generate documentation.
* **Comments:** Write meaningful comments in German.
## 5. References
* **Mithril.js Documentation:** https://mithril.js.org/
* **Flask Documentation:** https://flask.palletsprojects.com/
* **Authlib Documentation:** https://docs.authlib.org/
* See the `ai-context/` directory for detailed structural and operational guidelines.
+2 -1
View File
@@ -17,7 +17,8 @@
"ms-python.python",
"ms-python.vscode-pylance",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
"esbenp.prettier-vscode",
"mistralai.mistral-vibe-code"
],
"settings": {
"python.defaultInterpreterPath": "/usr/opt/pythonapp/env/bin/python",
+8
View File
@@ -0,0 +1,8 @@
.git/
Client/node_modules/
Client/tsout/
App_Server/data/
App_Server/env/
oidc-test/
*.pyc
__pycache__/
-20
View File
@@ -1,20 +0,0 @@
# Parameter für NGINX-Server
NGINX_VERSION=mainline-alpine
NGINX_HOST_PORT=443
NGINX_SERVER_NAME=kbp_crm.mylocal.de
NGINX_STATIC_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/NGINX_Server/html
NGINX_CONFIG_TEMPLATE_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/NGINX_Server/configtemplates
NGINX_CERTS=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/NGINX_Server/certs
NGINX_CERT=wildcard.mylocal.crt
NGINX_CERT_KEY=wildcard.mylocal.key
# Parameter for Python App-Server
PYTHON_VERSION=slim-bookworm
PYTHON_APP_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/App_Server/root
PYTHON_DEV_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm
PYTHON_DB_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/App_Server/data
# PYTHON_ENV_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/App_Server/env
# Parameter für Dev-Mode (Development-Container für VS-Code)
NODE_VERSION=lts
NPM_VERSION=latest
+8 -3
View File
@@ -1,6 +1,11 @@
__pycache__
env
node_modules
tsout
.env
env/
node_modules/
tsout/
kbp_crm*.js
kbcrm/App_Server/data
App_Server/data/
oidc-test/
.vibe/
.vibe-global/
+20 -2
View File
@@ -67,7 +67,7 @@
"type": "shell",
"group": "none",
"label": "Open auth.db",
"command": "C:/Daten-GGG/OneDrive/GGG-Workspace/Projekte/Sqlite3/sqlite3.exe auth.db",
"command": "sqlite3 auth.db",
"options": {
"cwd": "${workspaceFolder}/App_Server/data"
},
@@ -85,7 +85,7 @@
"type": "shell",
"group": "none",
"label": "Open app.db",
"command": "C:/Daten-GGG/OneDrive/GGG-Workspace/Projekte/Sqlite3/sqlite3.exe kbp_crm.db",
"command": "sqlite3 kbp_crm.db",
"options": {
"cwd": "${workspaceFolder}/App_Server/data"
},
@@ -98,6 +98,24 @@
"showReuseMessage": true,
"clear": true
}
},
{
"type": "shell",
"group": "none",
"label": "Flask: init-db",
"command": "${workspaceFolder}/env/bin/flask --app root/wsgi:app init-db",
"options": {
"cwd": "${workspaceFolder}/App_Server"
},
"problemMatcher": [],
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": false,
"clear": false
}
}
]
}
+37 -6
View File
@@ -3,14 +3,14 @@ Beachte die strukturellen Vorgaben, die in ai-context/AI-operative-Vorgaben.md d
In den Programmen sollen aussagekräftige Kommentare in deutsch erstellt werden.
## Vorgaben git Versionsverwaltung
Ein neuer Branch darf nur auf ausdrückliche Anweisung hin erstellt werden. Ohne diese Anwendung werden Änderungen immer in main gemacht.
Ein neuer Branch darf nur auf ausdrückliche Anweisung hin erstellt werden. Ohne diese Anweisung werden Änderungen immer in main gemacht.
## Vorgaben für die Typescript und mithriljs Entwicklung
Verwende bei der GUI primar die widgets, die es in client/Components/widgets gibt. Fehler widgets, gib eines Hinweis und erstelle einen Vorschlag für ein entsprechendes neues Widget.
## Vorgaben für die Typescript und mithril.js Entwicklung
Verwende bei der GUI primar die Widgets und Komponenten, die es in Client/Components/widgets und Client/Components gibt. Wenn Widgets fehlen, gib eines Hinweis und erstelle einen Vorschlag für ein entsprechendes neues Widget.
Es soll immer ein responsive Design implmentiert werden, da die App sowohl auf PCs als auch auf mobilen Geräten laufen soll.
Verwende für Attribute der GUI-Oberfläche nach Möglichkeit Klassen aus NGINX_Server/html/static/w3.css, insbesondere wenn es darum geht, eine Respnosive Design zu implentieren.
Verwende für Attribute der GUI-Oberfläche nach Möglichkeit Klassen aus NGINX_Server/html/static/w3.css, insbesondere wenn es darum geht, ein Respnosive Design zu implentieren.
Für Farben (foreground und background) verwende priorisiert die Klassen aus NGINX_Server/html/static/app-theme.css
View-Container werden mit `w3-container.w3-auto` gewrappt, damit sie auf kleinen Bildschirmen 100% und auf großen bis max. 1140px (bzw. 95%) zentriert nutzen.
View-Container werden mit `w3-container.w3-auto` gewrappt, damit sie auf kleinen Bildschirmen 100% und auf großen bis max. 1140px (bzw. 95%) zentriert werden.
Bei Mithril `m(...)`-Aufrufen mit längeren Attributlisten (z.B. mehrere `style`-Eigenschaften oder mehrere Attribute) schreibe die Attribute untereinander, sobald sie in einer Zeile nicht mehr gut lesbar sind (Faustregel: mehr als 2–3 Attribute oder ein mehrzeiliges `style`-Objekt). Kurze Attributlisten (1–2 Attribute, einzeiliges `style`) dürfen in einer Zeile bleiben. Formatbeispiel:
```typescript
@@ -25,9 +25,13 @@ m("div.w3-bar-block.w3-border", {
})
```
Für Symbole auf Buttons und ähnlichen Steuerelementen verwende Symbole aus den Google Material Icons.
Für Symbole auf Buttons und ähnlichen Steuerelementen verwende Symbole aus den Google Material Icons aus der NGINX_Server/html/static/Google_Symbols.woff2 Datei.
Erstelle die Datenmodelle für die Typescript-Entwicklung im Verzeichnis Client/Models. Erstelle für die verschiedenen Datenmodelle (Kunde, Projekt usw.) eigene .ts-Dateien. Erstelle in der jeweiligen Datei auch den Aufruf der REST-Api für die Server-Seite.
Beim Aufruf einer Bearbeitungsmaske werden in den Route-Params nur die ID (und ggf. zum Vorbelegen notwendige Eltern-FKs wie `kunden_id`) übergeben — nicht die vollständigen Felder des Objekts. Die Maske lädt die Daten frisch vom Server via `fetchXxxById(id)` im `oninit`. So wird sichergestellt, dass die Daten aktuell sind und zwischenzeitliche Änderungen anderer Nutzer nicht mit veralteten Werten aus der Liste überschrieben werden. Beim Neuanlegen (`id === -1`) entfällt der Fetch; vorbelegte Werte (z.B. `kunden_id`) kommen aus den Route-Params.
Jeder `m.request`-Aufruf (REST-Endpunkt) muss `.catch((e) => showServerError(e))` anhängen, damit Serverfehler (z.B. 401 bei fehlender Autorisierung) dem Nutzer als Modal angezeigt werden. Die Navigation (`m.route.set`) steht im `.then`-Zweig, damit sie bei Fehler nicht ausgeführt wird und der Nutzer auf der Maske bleibt. `showServerError` wird aus `./auth/authmessages` importiert.
Bei `m.ClosureComponent` den Closure-Parameter weglassen (`() =>`) und Attribute ausschließlich über das `vnode` des jeweiligen Lifecycle-Hooks (`view`, `oninit`, `onupdate` etc.) lesen. Das `vnode` der Closure-Funktion ist ein Snapshot zum Erzeugungszeitpunkt und wird bei Redraws nicht aktualisiert — daraus gelesene `attrs` sind veraltet. Initialisierung, die `attrs` braucht, gehört nach `oninit`. Beispiel:
```typescript
export const Select: m.ClosureComponent<iSelect> = () => {
@@ -47,4 +51,31 @@ export const Select: m.ClosureComponent<iSelect> = () => {
}
```
Nach jeder Änderung an `.ts`-Dateien unter `Client/` ist der Build-Prozess (tsc + rollup) auszuführen, damit die deploybaren Bundles in `NGINX_Server/html/static/` aktuell sind. Verwende dafür den `client-build`-Skill.
## Vorgaben für die Server-seitige Entwicklung unter Python
### Projektbezogene Sichtbarkeit und Schreibschutz
Projekte sind nutzerbezogen: Ein Nutzer sieht nur Projekte, denen er über die Tabelle `projekt_users` zugeordnet ist. Diese Sichtbarkeits-Filterung muss für **jede** projektbezogene Entität (Projekt selbst und alle davon abhängigen Entitäten wie Kontaktadresse, CallLog) in den Lese- und Schreib-Endpunkten umgesetzt werden.
Die Prüfung erfolgt rollenbasiert, nicht flagbasiert: SysAdmin und AppAdmin (geprüft via `current_user.is_SysAdmin()` bzw. `current_user.has_permission(app.config["APPID"], [APP_ADMIN])`) sehen alle Projekte. Alle anderen Nutzer werden über ihre `current_user.id` gefiltert.
Dafür werden in `crm/routes.py` zwei Hilfsfunktionen bereitgestellt:
- `_get_visible_user_id()`: Liefert `None` für SysAdmin/AppAdmin (kein Filter), sonst `current_user.id`. Wird an die Modell-Funktionen der Lese-Endpunkte (list/get) übergeben.
- `_require_projekt_access(projekt_id)`: Prüft vor Schreiboperationen (create/update/delete), ob der Nutzer dem Projekt zugeordnet ist. Bei fehlender Zuordnung wird 404 (nicht 403) geworfen, um keine Rückschlüsse auf die Existenz des Projekts zu ermöglichen.
Die `user_id` wird als Parameter übergeben statt `current_user` direkt im Modell zu lesen, weil `None` als Signalwert für Admins (kein Filter) nur so sauber transportiert werden kann und die Modell-Funktionen frei von Flask-Request-Kontext bleiben — das wahrt die Schichtentrennung und Testbarkeit.
Bei neuen projektbezogenen Entitäten muss:
1. Die Modell-Funktion `get_xxx(...)` einen optionalen `user_id`-Parameter erhalten. Ist dieser gesetzt, wird per Subquery auf `projekt_users` gefiltert.
2. Die Modell-Funktion `get_xxx_by_id(id, user_id)` bei gesetztem `user_id` die Projekt-Zuordnung prüfen und bei Fehlen 404 werfen.
3. Die Lese-Endpunkte in `crm/routes.py` `_get_visible_user_id()` aufrufen und das Ergebnis an die Modell-Funktion übergeben.
4. Die Schreib-Endpunkte `_require_projekt_access(projekt_id)` aufrufen, bevor die Änderung durchgeführt wird. Bei create/update steht `projekt_id` im JSON-Body (`request.json["projekt_id"]` bzw. `request.json["id"]`); bei delete muss die Entität ggf. erst mit Sichtbarkeits-Check geladen werden, um die `projekt_id` zu ermitteln.
## Generierte Dateien nicht beachten
Das Verzeichnis `Client/tsout/` enthält die JavaScript-Ausgabe der TypeScript-Kompilation und wird durch den Build-Prozess (tsc + rollup) erzeugt. Diese Dateien sind nicht manuell zu bearbeiten und brauchen bei der Codeanalyse nicht berücksichtigt zu werden. Ebenso sind die JavaScript-Dateien in `NGINX_Server/html/static/` (z.B. `kbp_crm_00.00.01.js`) die Ausgabe des Rollup-Builds und nicht zu untersuchen.
## Vorgaben für DB-Migrationen (checkdb.py)
Die DB-Versionierung erfolgt über `_db_Vxx_xx`-Funktionen in `auth/model/checkdb.py` und `crm/model/checkdb.py`. Beim Start wird `checkupdates()` aufgerufen und führt alle Funktionen aus, deren Versionsnummer größer ist als die in der Systeminfo-Tabelle vermerkte. Bei einer Neuinstallation (leere Datenbank) erzeugt `_db_V01_00` über `create_all` bereits das **gesamte aktuelle Schema** aus den Models — inklusive aller Spalten, Tabellen und Indizes, die in späteren Migrationen erst nachträglich ergänzt wurden. Die inkrementellen Migrationen laufen danach dennoch sequentiell nach.
Daher gilt die Konvention: **Jede `_db_Vxx_xx`-Migration muss idempotent sein** — sie muss fehlerfrei durchlaufen, auch wenn das von ihr geänderte Schema-Element bereits durch `create_all` existiert. Insbesondere müssen `ALTER TABLE ADD COLUMN`-Anweisungen vorab prüfen, ob die Spalte bereits existiert (z.B. via `PRAGMA table_info`). `CREATE INDEX` verwendet `IF NOT EXISTS`. Daten-UPDATEs auf leeren Tabellen matchen nichts und sind somit unbedenklich. Migrationen, die Seed-Daten einfügen, müssen vorab prüfen, ob diese bereits vorhanden sind.
+22 -23
View File
@@ -1,7 +1,8 @@
ARG PYTHON_VERSION
ARG NODE_VERSION
ARG NPM_VERSION
FROM python:${PYTHON_VERSION} AS prod
# ── Stage: prod ──────────────────────────────────────────────────
# Build-Context ist das Repo-Root (.), daher muessen alle COPY-Pfade
# mit App_Server/ praefigiert werden.
# Python-Version fest im Dockerfile verankert — nicht per .env ueberschreibbar.
FROM python:slim-bookworm AS prod
RUN ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime
RUN apt-get clean && apt-get update && apt-get install -y locales
RUN echo "de_DE.UTF-8 UTF-8" >> /etc/locale.gen
@@ -12,18 +13,21 @@ RUN locale-gen
RUN mkdir -p /usr/opt/pythonapp/App_Server/root /usr/opt/pythonapp/env /usr/var/pythonappdata
WORKDIR /usr/opt/pythonapp
RUN python -m venv --upgrade-deps /usr/opt/pythonapp/env
COPY root/requirements.txt /usr/opt/pythonapp/App_Server/root/requirements.txt
COPY App_Server/root/requirements.txt /usr/opt/pythonapp/App_Server/root/requirements.txt
RUN /usr/opt/pythonapp/env/bin/pip install --no-cache-dir --upgrade pip -r /usr/opt/pythonapp/App_Server/root/requirements.txt
# COPY scripts/init.sh /init.sh
# RUN chmod 552 /init.sh
# # ENTRYPOINT [ "/init.sh" ]
# ENTRYPOINT [ "sh", "/init.sh" ]
# CMD []
# Python-Anwendungscode ins Image kopieren (inkl. templates/index.html)
COPY App_Server/root/ /usr/opt/pythonapp/App_Server/root/
# Prod-Start-Script
COPY App_Server/scripts/init.sh /usr/opt/pythonapp/App_Server/scripts/init.sh
RUN chmod +x /usr/opt/pythonapp/App_Server/scripts/init.sh
ENTRYPOINT ["sh", "/usr/opt/pythonapp/App_Server/scripts/init.sh"]
# ── Stage: dev ───────────────────────────────────────────────────
FROM prod AS dev
ARG NODE_VERSION
ARG NPM_VERSION
# Node.js-Version fest im Dockerfile verankert.
# --- 2. Systemabhängigkeiten (für Python + Node.js) ---
RUN apt-get update && \
@@ -41,19 +45,14 @@ RUN apt-get update && \
&& rm -rf /var/lib/apt/lists/*
# --- 3. Node.js + npm installieren ---
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
apt-get install -y nodejs && \
npm install -g npm@${NPM_VERSION}
npm install -g npm@latest
# --- 8. Arbeitsverzeichnis für TypeScript ---
# In dem Entwicklungsverzeichnis (PYTHON_DEV_DIR) existiert dies schon als Unterverzeichnis "Client", daher wird es hier nicht erstellt
# --- 4. Dev-Start-Script kopieren (fuer Konsistenz, wird sonst per bind-mount bereitgestellt) ---
COPY App_Server/scripts/inittypescriptdev.sh /usr/opt/pythonapp/App_Server/scripts/inittypescriptdev.sh
RUN chmod +x /usr/opt/pythonapp/App_Server/scripts/inittypescriptdev.sh
# --- 9. TypeScript + Rollup installieren ---
# Das Script inittypescriptdev.sh wird in der Entwicklungsumgebung ausgeführt, um die TypeScript-Umgebung zu initialisieren.
# Daher ist es nicht notwendig, es hier zu kopieren und auszuführen.
# COPY ./scripts/inittypescriptdev.sh /usr/opt/pythonapp/App_Server/scripts/inittypescriptdev.sh
# RUN chmod +x /usr/opt/pythonapp/App_Server/scripts/inittypescriptdev.sh
ENTRYPOINT ["sh", "/usr/opt/pythonapp/App_Server/scripts/inittypescriptdev.sh"]
CMD ["/usr/opt/pythonapp/env/bin/python", "root/runserver.py", "-D"]
CMD ["/usr/opt/pythonapp/env/bin/python", "/usr/opt/pythonapp/App_Server/root/runserver.py", "-D"]
+10 -4
View File
@@ -4,15 +4,15 @@ from settings import Configuration
from db import dbauth
from db import dbapp
from auth.model.checkdb import checkupdates as checkupdatesauth
from os import path
import os
login_manager = LoginManager()
def init_app():
def init_app(debug: bool | None = None):
app = Flask(__name__)
config: Configuration = Configuration("root/config.ini")
config: Configuration = Configuration("/usr/opt/pythonapp/App_Server/root/config.ini")
_projektname = config.app_projectname
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////usr/var/pythonappdb/{}.db".format(_projektname)
@@ -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"] = path.exists("root/debug")
# 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
+11 -4
View File
@@ -22,10 +22,17 @@ class AutorisationError(ApplicationError):
else:
userrolestxt = ", ".join([role.name for role in self.user_roles])
ApplicationError.__init__(self,
"Sie haben nicht die passende Rolle für diese Funktion",
"Ihre Rollen sind: " + userrolestxt,
"Erforderliche Rollen sind: " + ", ".join(self.allowed_roles), htmlstatus=HtmlStatusCode.Unauthorized)
# Die Meldung über erforderliche Rollen nur aufnehmen, wenn
# welche spezifiziert sind. Bei leerer allowed_roles-Liste
# (z.B. Endpunkte, die nur AppAdmin/SysAdmin erlauben) entfällt
# dieser Teil der Fehlermeldung komplett.
messages = [
"Sie haben nicht die passende Rolle für diese Funktion",
"Ihre Rollen sind: " + userrolestxt,
]
if self.allowed_roles:
messages.append("Erforderliche Rollen sind: " + ", ".join(self.allowed_roles))
ApplicationError.__init__(self, *messages, htmlstatus=HtmlStatusCode.Unauthorized)
class HtmlStatusCode():
Bad_Request = 400
+8 -2
View File
@@ -52,6 +52,7 @@ def checklogin():
login_user(user)
result = user.to_dict()
result["isSysAdmin"] = user.is_SysAdmin()
result["isAppAdmin"] = user.has_permission(app.config["APPID"], [APP_ADMIN])
return jsonify(result), 200
@@ -86,13 +87,18 @@ def logout():
def getusers():
"""
Die Rückgabe wird in 'users.get_users()' (s.u.) anhand er App-Berechtigung und der
Rollen gefiltert.
Rollen gefiltert. Pro User wird zusätzlich isSysAdmin und das app-spezifische
isAppAdmin Flag ergänzt, damit der Client keine app-unspezifische Rollenprüfung
durchführen muss.
"""
userquery = users.get_users()
result = []
user: User
for user in userquery:
result.append(user.to_relationaldict())
eintrag = user.to_relationaldict()
eintrag["isSysAdmin"] = user.is_SysAdmin()
eintrag["isAppAdmin"] = user.has_permission(app.config["APPID"], [APP_ADMIN])
result.append(eintrag)
return jsonify(result), 200
+581 -20
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,7 +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()
click.echo("Lösche vorhandene CRM-Geschäftsdaten ...")
_purge_crm_daten()
click.echo("Lege Musterdaten an ...")
_seed_musterdaten()
click.echo("Fertig.")
@@ -80,25 +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.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()
# ---------------------------------------------------------------------------
# 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 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, Kontakt, Projekt, Kontaktadresse, CallLog
session = dbapp.session
# Fester Seed für reproduzierbare Musterdaten (Datum, Uhrzeit, Dauer).
rng = random.Random(456)
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.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()
# 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()
+327 -1
View File
@@ -12,7 +12,8 @@ erfolgt ausschließlich über __bind_key__.
import datetime
import decimal
from sqlalchemy import Column, DateTime, Integer, String
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from db import dbapp
@@ -93,4 +94,329 @@ class Kunde(dbapp.Model):
return columns2dict(self)
class Kontakt(dbapp.Model):
"""
Ansprechpartner bei einem Kunden. Jeder Kontakt ist genau einem Kunden
zugeordnet (kunden_id). Der Kundenname (kunden_name) ist keine eigene
Spalte, sondern wird über die Relationship zu Kunde abgeleitet und nur
in to_dict() für die JSON-Serialisierung ergänzt.
"""
__tablename__ = 'kontakte'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_kontakte_kunden_id', 'kunden_id'),
)
id = Column(Integer, primary_key=True)
kunden_id = Column(Integer, ForeignKey('kunden.id'), nullable=False)
anrede = Column(String(30), nullable=True)
name = Column(String(200), nullable=False)
position = Column(String(200), nullable=True)
telefon = Column(String(100), nullable=True)
email = Column(String(200), nullable=True)
notiz = Column(String(2000), nullable=True)
kunde = relationship('Kunde')
def to_dict(self):
"""
Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; kunden_name wird zusätzlich aus der
Kunde-Relationship eingefügt.
"""
result = columns2dict(self)
result['kunden_name'] = self.kunde.name if self.kunde else None
return result
class Projekt(dbapp.Model):
"""
Projekt eines Kunden. Jedes Projekt ist genau einem Kunden zugeordnet
(kunden_id). Der Kundenname (kunden_name) ist keine eigene Spalte,
sondern wird über die Relationship zu Kunde abgeleitet und nur in
to_dict() für die JSON-Serialisierung ergänzt.
"""
__tablename__ = 'projekte'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_projekte_kunden_id', 'kunden_id'),
)
id = Column(Integer, primary_key=True)
kunden_id = Column(Integer, ForeignKey('kunden.id'), nullable=False)
projektname = Column(String(200), nullable=False)
zweck = Column(String(2000), nullable=True)
verantwortlicher = Column(String(200), nullable=True)
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. 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
class ProjektUser(dbapp.Model):
"""
Verknüpfungstabelle zwischen Projekt und User.
Steuert die Sichtbarkeit von Projekten: Ein Nutzer sieht nur Projekte,
denen er über diese Tabelle zugeordnet ist. Die Zuordnung erfolgt durch
den AppAdmin (oder später andere berechtigte Rollen).
Die Tabelle liegt in der CRM-Datenbank (appdb). Die Spalte 'user_id'
referenziert logisch die 'id' in der Auth-Datenbanktabelle 'users', ein
physischer Fremdschlüssel ist aufgrund der zwei getrennten SQLite-Datenbanken
nicht möglich. Die Auflösung von user_id auf username etc. erfolgt zur
Laufzeit durch eine separate Query gegen die Auth-Datenbank.
Das UniqueConstraint stellt sicher, dass jeder Nutzer nur einmal pro
Projekt zugeordnet werden kann.
"""
__tablename__ = 'projekt_users'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_projekt_users_projekt_id', 'projekt_id'),
UniqueConstraint('projekt_id', 'user_id', name='uq_projekt_user'),
)
id = Column(Integer, primary_key=True)
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False)
user_id = Column(Integer, nullable=False)
projekt = relationship('Projekt')
def to_dict(self):
"""Liefert die Projekt-User-Zuordnung als Dictionary."""
return columns2dict(self)
class Kontaktadresse(dbapp.Model):
"""
Akquise-Ziel innerhalb eines Projekts. Jede Kontaktadresse ist genau
einem Projekt zugeordnet (projekt_id). Der Projektname (projekt_name)
ist keine eigene Spalte, sondern wird über die Relationship zu Projekt
abgeleitet und nur in to_dict() für die JSON-Serialisierung ergänzt.
"""
__tablename__ = 'kontaktadressen'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_kontaktadressen_projekt_id', 'projekt_id'),
)
id = Column(Integer, primary_key=True)
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False)
anrede = Column(String(30), nullable=True)
name = Column(String(200), nullable=False)
firma = Column(String(200), nullable=True)
position = Column(String(200), nullable=True)
telefon = Column(String(100), nullable=True)
email = Column(String(200), nullable=True)
strasse = Column(String(200), nullable=True)
plz = Column(String(20), nullable=True)
ort = Column(String(100), nullable=True)
land = Column(String(80), nullable=True)
notiz = Column(String(2000), nullable=True)
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, 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 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)
betreff = Column(String(200), nullable=True)
name_ansprechpartner = Column(String(200), nullable=True)
telefon = Column(String(100), nullable=True)
notiz = Column(String(2000), nullable=True)
wiedervorlage_datum = Column(Date, nullable=True)
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. 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:
result['projekt_name'] = self.projekt.projektname
result['kunden_name'] = self.projekt.kunde.name if self.projekt.kunde else None
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")
+248
View File
@@ -0,0 +1,248 @@
"""
Geschäftslogik für die Entität CallLog (Gesprächsnotiz).
Diese Funktionen kapseln den DB-Zugriff und die fachlichen Validierungen.
Aufgerufen werden sie aus crm/routes.py; die Autorisierungsprüfung erfolgt
dort über den Decorator authorisation_required.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-04
:version: 1.0
"""
from datetime import date
from db import dbapp
from crm.model import CallLog, CallLogTag, Kontaktadresse, Projekt, ProjektUser, Tag
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
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). 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).
SysAdmin und AppAdmin übergeben user_id=None und sehen alle Notizen.
"""
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(
CallLog.projekt_id.in_(
dbapp.session.query(ProjektUser.projekt_id).filter(
ProjektUser.user_id == user_id
)
)
)
return query.order_by(CallLog.datum.desc()).all()
def get_call_log(call_log_id: int, user_id: int | None = None) -> CallLog:
"""
Liefert eine einzelne Gesprächsnotiz anhand ihrer ID.
Wirft ApplicationError (404), falls die Notiz nicht existiert.
Ist user_id gesetzt, wird zusätzlich geprüft, ob der Nutzer dem
Projekt der Notiz zugeordnet ist; sonst wird 404 geliefert.
"""
call_log = CallLog.query.get(call_log_id)
if call_log is None:
raise ApplicationError(
"Gesprächsnotiz mit der ID {} wurde nicht gefunden.".format(call_log_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
if user_id is not None:
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == call_log.projekt_id,
ProjektUser.user_id == user_id,
).first()
if zuordnung is None:
raise ApplicationError(
"Gesprächsnotiz mit der ID {} wurde nicht gefunden.".format(call_log_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return call_log
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, 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:
raise ApplicationError(
"Es muss ein Projekt ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
projekt = Projekt.query.get(int(projekt_id))
if projekt is None:
raise ApplicationError(
"Das ausgewählte Projekt existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kontaktadresse_id = _get_validierte_kontaktadresse_id(data, projekt_id)
call_log = CallLog()
call_log.projekt_id = int(projekt_id)
call_log.kontaktadresse_id = kontaktadresse_id
# Ersteller erfassen: welcher Mitarbeiter den Anruf getätigt hat.
# Alt-Notizen (vor der user_id-Spalte angelegt) haben NULL; neue
# Notizen ohne Angabe erhalten ebenfalls NULL, bleiben aber die
# Ausnahme, da die Route immer current_user.id übergibt.
if user_id is not None:
call_log.user_id = int(user_id)
call_log.datum = _parse_date(data.get("datum"))
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
call_log.dauer = (data.get("dauer") or "").strip() or None
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 = _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
def update_call_log(data: dict) -> CallLog:
"""
Aktualisiert eine bestehende Gesprächsnotiz. Die ID im data-Dict
bestimmt den zu ändernden Datensatz.
"""
call_log = get_call_log(data["id"])
projekt_id = data.get("projekt_id")
if projekt_id is None:
raise ApplicationError(
"Es muss ein Projekt ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
projekt = Projekt.query.get(int(projekt_id))
if projekt is None:
raise ApplicationError(
"Das ausgewählte Projekt existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kontaktadresse_id = _get_validierte_kontaktadresse_id(data, projekt_id)
call_log.projekt_id = int(projekt_id)
call_log.kontaktadresse_id = kontaktadresse_id
call_log.datum = _parse_date(data.get("datum"))
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
call_log.dauer = (data.get("dauer") or "").strip() or None
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 = _parse_date(data.get("wiedervorlage_datum"))
_set_call_log_tags(call_log, data.get("tag_ids"))
session.commit()
return call_log
def delete_call_log(call_log_id: int):
"""
Löscht eine Gesprächsnotiz anhand ihrer ID.
Wirft ApplicationError (404), falls die Notiz nicht existiert.
"""
call_log = get_call_log(call_log_id)
session.delete(call_log)
session.commit()
+364 -2
View File
@@ -43,12 +43,376 @@ def _db_V01_00():
session.commit()
def _db_V01_01():
"""
Legt die Tabelle 'kontakte' an. Bei bestehenden Datenbanken, die mit
Version 01.00 initialisiert wurden, fehlt diese Tabelle, da das
Kontakt-Modell erst nachträglich ergänzt wurde. create_all erzeugt
nur fehlende Tabellen, bestehende bleiben unangetastet.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.01')
session.commit()
def _db_V01_02():
"""
Legt die Tabelle 'projekte' an. Bei bestehenden Datenbanken, die mit
Version 01.01 initialisiert wurden, fehlt diese Tabelle, da das
Projekt-Modell erst nachträglich ergänzt wurde. create_all erzeugt
nur fehlende Tabellen, bestehende bleiben unangetastet.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.02')
session.commit()
def _db_V01_03():
"""
Legt die Tabellen 'kontaktadressen' und 'call_logs' an. Bei bestehenden
Datenbanken, die mit Version 01.02 initialisiert wurden, fehlen diese
Tabellen, da die Modelle erst nachträglich ergänzt wurden.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.03')
session.commit()
def _db_V01_04():
"""
Fügt die Spalte 'position' zur Tabelle 'kontaktadressen' hinzu.
Bei bestehenden Datenbanken, die mit Version 01.03 initialisiert wurden,
fehlt diese Spalte, da das Feld erst nachträglich ergänzt wurde.
Idempotent: Bei Neuinstallation hat create_all die Spalte bereits
angelegt — in diesem Fall wird das ALTER TABLE übersprungen.
"""
from sqlalchemy import text
# Prüfen, ob die Spalte bereits existiert (Neuinstallation via create_all)
result = session.execute(text("PRAGMA table_info(kontaktadressen)"))
spalten = {row[1] for row in result}
if 'position' not in spalten:
session.execute(text(
"ALTER TABLE kontaktadressen ADD COLUMN position VARCHAR(200)"
))
_updatesysteminfo('01.04')
session.commit()
def _db_V01_05():
"""
Legt Indizes auf den Fremdschlüsselspalten an, die für die
serverseitige Filterung der Listen-Endpunkte genutzt werden.
CREATE INDEX IF NOT EXISTS ist idempotent und überspringet bereits
vorhandene Indizes.
"""
from sqlalchemy import text
session.execute(text("CREATE INDEX IF NOT EXISTS ix_kontakte_kunden_id ON kontakte (kunden_id)"))
session.execute(text("CREATE INDEX IF NOT EXISTS ix_projekte_kunden_id ON projekte (kunden_id)"))
session.execute(text("CREATE INDEX IF NOT EXISTS ix_kontaktadressen_projekt_id ON kontaktadressen (projekt_id)"))
session.execute(text("CREATE INDEX IF NOT EXISTS ix_call_logs_projekt_id ON call_logs (projekt_id)"))
_updatesysteminfo('01.05')
session.commit()
def _db_V01_07():
"""
Platzhalter für die fehlgeschlagene Migration V01.06. Die eigentliche
Normalisierung erfolgt in V01.08, da V01.06 bereits (fehlerhaft)
durchlief und die Systeminfo auf 01.06 aktualisierte.
"""
pass
def _db_V01_08():
"""
Normalisiert bestehende Datumswerte in den Spalten 'datum' und
'wiedervorlage_datum' (call_logs) sowie 'startdatum' (projekte) auf
das ISO-Format yyyy-MM-dd. Diese Spalten waren bisher String-Spalten
und können Werte ohne führende Nullen (z.B. '2026-08-3') enthalten,
die von SQLite's date()-Funktion nicht erkannt werden. Daher wird
printf('%04d-%02d-%02d', ...) zur Normalisierung verwendet, das auch
einstellige Monate/Tage korrekt auffüllt. Leere Strings werden zu
NULL konvertiert, da der Date-Typ keine leeren Strings akzeptiert.
Alle Updates werden vor dem Update der Systeminfo committet, um
'database is locked' zu vermeiden.
"""
from sqlalchemy import text
# Normalisierungs-Ausdruck: splittet den Datum-String am '-' und
# formatiert die Komponenten mit führenden Nullen.
# _norm(col) = printf('%04d-%02d-%02d',
# cast(substr(col, 1, instr(col, '-')-1) as integer),
# cast(substr(col, instr(col, '-')+1, instr(substr(col, instr(col, '-')+1), '-')-1) as integer),
# cast(substr(col, instr(col, '-')+instr(substr(col, instr(col, '-')+1), '-')+1) as integer)
# )
# Da dieser Ausdruck sehr lang ist, wird er pro Spalte inline gesetzt.
# call_logs.datum
session.execute(text(
"UPDATE call_logs SET datum = "
"printf('%04d-%02d-%02d', "
" cast(substr(datum, 1, instr(datum, '-')-1) as integer), "
" cast(substr(datum, instr(datum, '-')+1, "
" instr(substr(datum, instr(datum, '-')+1), '-')-1) as integer), "
" cast(substr(datum, "
" instr(datum, '-')+instr(substr(datum, instr(datum, '-')+1), '-')+1"
" ) as integer)) "
"WHERE datum IS NOT NULL AND datum != '' "
"AND instr(datum, '-') > 0"
))
session.execute(text(
"UPDATE call_logs SET datum = NULL WHERE datum = ''"
))
# call_logs.wiedervorlage_datum
session.execute(text(
"UPDATE call_logs SET wiedervorlage_datum = "
"printf('%04d-%02d-%02d', "
" cast(substr(wiedervorlage_datum, 1, instr(wiedervorlage_datum, '-')-1) as integer), "
" cast(substr(wiedervorlage_datum, instr(wiedervorlage_datum, '-')+1, "
" instr(substr(wiedervorlage_datum, instr(wiedervorlage_datum, '-')+1), '-')-1) as integer), "
" cast(substr(wiedervorlage_datum, "
" instr(wiedervorlage_datum, '-')+instr(substr(wiedervorlage_datum, instr(wiedervorlage_datum, '-')+1), '-')+1"
" ) as integer)) "
"WHERE wiedervorlage_datum IS NOT NULL AND wiedervorlage_datum != '' "
"AND instr(wiedervorlage_datum, '-') > 0"
))
session.execute(text(
"UPDATE call_logs SET wiedervorlage_datum = NULL WHERE wiedervorlage_datum = ''"
))
# projekte.startdatum
session.execute(text(
"UPDATE projekte SET startdatum = "
"printf('%04d-%02d-%02d', "
" cast(substr(startdatum, 1, instr(startdatum, '-')-1) as integer), "
" cast(substr(startdatum, instr(startdatum, '-')+1, "
" instr(substr(startdatum, instr(startdatum, '-')+1), '-')-1) as integer), "
" cast(substr(startdatum, "
" instr(startdatum, '-')+instr(substr(startdatum, instr(startdatum, '-')+1), '-')+1"
" ) as integer)) "
"WHERE startdatum IS NOT NULL AND startdatum != '' "
"AND instr(startdatum, '-') > 0"
))
session.execute(text(
"UPDATE projekte SET startdatum = NULL WHERE startdatum = ''"
))
# Daten-Updates committen, bevor die Systeminfo aktualisiert wird.
session.commit()
# Systeminfo erst nach dem Commit der Daten-Updates aktualisieren.
_updatesysteminfo('01.08')
session.commit()
def _db_V01_09():
"""
Legt die Tabelle 'projekt_users' an. Keine Initial-Befüllung —
bestehende Projekte bleiben ohne Zuordnungen, bis der AppAdmin
diese manuell vergibt. SysAdmin/AppAdmin sehen alle Projekte ohnehin.
create_all erzeugt nur fehlende Tabellen, bestehende bleiben unangetastet.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.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
_db_Vxx_xx-Funktionen aus, deren Versionsnummer größer ist. Ist die
Datenbank noch leer (Tabelle fehlt), wird die Ersteinrichtung ausgeführt.
Bei einer Neuinstallation erzeugt _db_V01_00 über create_all bereits alle
Tabellen mit dem aktuellen Schema (inklusive aller später ergänzten Spalten,
Tabellen und Indizes). Die inkrementellen Migrationen laufen trotzdem
sequentiell nach — jede einzelne muss idempotent sein, damit sie auf der
frisch erzeugten DB fehlerfrei durchläuft (siehe Konvention in AGENTS.md).
"""
versionen = sorted(filter(lambda f: f.startswith("_db_V"), globals()))
curr_dbversion = '_db_V01_00'
try:
systeminfo: CrmSysteminfo = CrmSysteminfo.query.get(1)
@@ -56,8 +420,6 @@ def checkupdates():
except (OperationalError, AttributeError):
_db_V01_00()
versionen = sorted(filter(lambda f: f.startswith("_db_V"), globals()))
for v in versionen:
if v > curr_dbversion:
globals()[v]()
+127
View File
@@ -0,0 +1,127 @@
"""
Geschäftslogik für die Entität Kontakt.
Diese Funktionen kapseln den DB-Zugriff und die fachlichen Validierungen.
Aufgerufen werden sie aus crm/routes.py; die Autorisierungsprüfung erfolgt
dort über den Decorator authorisation_required.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-03
:version: 1.0
"""
from db import dbapp
from crm.model import Kontakt, Kunde
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_kontakte(kunden_id: int | None = None):
"""
Liefert Kontakte, sortiert nach Name.
Ist kunden_id gesetzt, werden nur die Kontakte dieses Kunden
zurückgegeben (serverseitige Filterung). Ohne kunden_id werden
alle Kontakte geliefert.
"""
query = Kontakt.query
if kunden_id is not None:
query = query.filter(Kontakt.kunden_id == kunden_id)
return query.order_by(Kontakt.name).all()
def get_kontakt(kontakt_id: int) -> Kontakt:
"""
Liefert einen einzelnen Kontakt anhand seiner ID.
Wirft ApplicationError (404), falls der Kontakt nicht existiert.
"""
kontakt = Kontakt.query.get(kontakt_id)
if kontakt is None:
raise ApplicationError(
"Kontakt mit der ID {} wurde nicht gefunden.".format(kontakt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return kontakt
def create_kontakt(data: dict) -> Kontakt:
"""
Legt einen neuen Kontakt an. Die vom Client übergebene ID wird ignoriert;
die ID wird serverseitig per Auto-Increment vergeben.
- data: dict mit den Feldern kunden_id, anrede, name, position, telefon,
email, notiz
"""
kunden_id = data.get("kunden_id")
if kunden_id is None:
raise ApplicationError(
"Es muss ein Kunde ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kunde = Kunde.query.get(int(kunden_id))
if kunde is None:
raise ApplicationError(
"Der ausgewählte Kunde existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
if not data.get("name") or not str(data["name"]).strip():
raise ApplicationError(
"Der Kontaktname darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kontakt = Kontakt()
kontakt.kunden_id = int(kunden_id)
kontakt.anrede = (data.get("anrede") or "").strip() or None
kontakt.name = data["name"].strip()
kontakt.position = (data.get("position") or "").strip() or None
kontakt.telefon = (data.get("telefon") or "").strip() or None
kontakt.email = (data.get("email") or "").strip() or None
kontakt.notiz = (data.get("notiz") or "").strip() or None
session.add(kontakt)
session.commit()
return kontakt
def update_kontakt(data: dict) -> Kontakt:
"""
Aktualisiert einen bestehenden Kontakt. Die ID im data-Dict bestimmt den
zu ändernden Datensatz.
"""
kontakt = get_kontakt(data["id"])
kunden_id = data.get("kunden_id")
if kunden_id is None:
raise ApplicationError(
"Es muss ein Kunde ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kunde = Kunde.query.get(int(kunden_id))
if kunde is None:
raise ApplicationError(
"Der ausgewählte Kunde existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
if not data.get("name") or not str(data["name"]).strip():
raise ApplicationError(
"Der Kontaktname darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kontakt.kunden_id = int(kunden_id)
kontakt.anrede = (data.get("anrede") or "").strip() or None
kontakt.name = data["name"].strip()
kontakt.position = (data.get("position") or "").strip() or None
kontakt.telefon = (data.get("telefon") or "").strip() or None
kontakt.email = (data.get("email") or "").strip() or None
kontakt.notiz = (data.get("notiz") or "").strip() or None
session.commit()
return kontakt
def delete_kontakt(kontakt_id: int):
"""
Löscht einen Kontakt anhand seiner ID.
Wirft ApplicationError (404), falls der Kontakt nicht existiert.
"""
kontakt = get_kontakt(kontakt_id)
session.delete(kontakt)
session.commit()
+163
View File
@@ -0,0 +1,163 @@
"""
Geschäftslogik für die Entität Kontaktadresse.
Diese Funktionen kapseln den DB-Zugriff und die fachlichen Validierungen.
Aufgerufen werden sie aus crm/routes.py; die Autorisierungsprüfung erfolgt
dort über den Decorator authorisation_required.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-04
:version: 1.0
"""
from db import dbapp
from crm.model import Kontaktadresse, Projekt, ProjektUser
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_kontaktadressen(projekt_id: int | None = None, user_id: int | None = None):
"""
Liefert Kontaktadressen, sortiert nach Name.
Ist projekt_id gesetzt, werden nur die Kontaktadressen dieses
Projekts zurückgegeben (serverseitige Filterung). Ohne projekt_id
werden alle Kontaktadressen geliefert.
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.
"""
query = Kontaktadresse.query
if projekt_id is not None:
query = query.filter(Kontaktadresse.projekt_id == projekt_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
)
)
)
return query.order_by(Kontaktadresse.name).all()
def get_kontaktadresse(kontaktadresse_id: int, user_id: int | None = None) -> Kontaktadresse:
"""
Liefert eine einzelne Kontaktadresse anhand ihrer ID.
Wirft ApplicationError (404), falls die Kontaktadresse nicht existiert.
Ist user_id gesetzt, wird zusätzlich geprüft, ob der Nutzer dem
Projekt der Kontaktadresse zugeordnet ist; sonst wird 404 geliefert.
"""
kontaktadresse = Kontaktadresse.query.get(kontaktadresse_id)
if kontaktadresse is None:
raise ApplicationError(
"Kontaktadresse mit der ID {} wurde nicht gefunden.".format(kontaktadresse_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
if user_id is not None:
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == kontaktadresse.projekt_id,
ProjektUser.user_id == user_id,
).first()
if zuordnung is None:
raise ApplicationError(
"Kontaktadresse mit der ID {} wurde nicht gefunden.".format(kontaktadresse_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return kontaktadresse
def create_kontaktadresse(data: dict) -> Kontaktadresse:
"""
Legt eine neue Kontaktadresse an. Die vom Client übergebene ID wird
ignoriert; die ID wird serverseitig per Auto-Increment vergeben.
- data: dict mit den Feldern projekt_id, anrede, name, firma, telefon,
email, strasse, plz, ort, land, notiz
"""
projekt_id = data.get("projekt_id")
if projekt_id is None:
raise ApplicationError(
"Es muss ein Projekt ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
projekt = Projekt.query.get(int(projekt_id))
if projekt is None:
raise ApplicationError(
"Das ausgewählte Projekt existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
if not data.get("name") or not str(data["name"]).strip():
raise ApplicationError(
"Der Name darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kontaktadresse = Kontaktadresse()
kontaktadresse.projekt_id = int(projekt_id)
kontaktadresse.anrede = (data.get("anrede") or "").strip() or None
kontaktadresse.name = data["name"].strip()
kontaktadresse.firma = (data.get("firma") or "").strip() or None
kontaktadresse.position = (data.get("position") or "").strip() or None
kontaktadresse.telefon = (data.get("telefon") or "").strip() or None
kontaktadresse.email = (data.get("email") or "").strip() or None
kontaktadresse.strasse = (data.get("strasse") or "").strip() or None
kontaktadresse.plz = (data.get("plz") or "").strip() or None
kontaktadresse.ort = (data.get("ort") or "").strip() or None
kontaktadresse.land = (data.get("land") or "").strip() or None
kontaktadresse.notiz = (data.get("notiz") or "").strip() or None
session.add(kontaktadresse)
session.commit()
return kontaktadresse
def update_kontaktadresse(data: dict) -> Kontaktadresse:
"""
Aktualisiert eine bestehende Kontaktadresse. Die ID im data-Dict
bestimmt den zu ändernden Datensatz.
"""
kontaktadresse = get_kontaktadresse(data["id"])
projekt_id = data.get("projekt_id")
if projekt_id is None:
raise ApplicationError(
"Es muss ein Projekt ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
projekt = Projekt.query.get(int(projekt_id))
if projekt is None:
raise ApplicationError(
"Das ausgewählte Projekt existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
if not data.get("name") or not str(data["name"]).strip():
raise ApplicationError(
"Der Name darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kontaktadresse.projekt_id = int(projekt_id)
kontaktadresse.anrede = (data.get("anrede") or "").strip() or None
kontaktadresse.name = data["name"].strip()
kontaktadresse.firma = (data.get("firma") or "").strip() or None
kontaktadresse.position = (data.get("position") or "").strip() or None
kontaktadresse.telefon = (data.get("telefon") or "").strip() or None
kontaktadresse.email = (data.get("email") or "").strip() or None
kontaktadresse.strasse = (data.get("strasse") or "").strip() or None
kontaktadresse.plz = (data.get("plz") or "").strip() or None
kontaktadresse.ort = (data.get("ort") or "").strip() or None
kontaktadresse.land = (data.get("land") or "").strip() or None
kontaktadresse.notiz = (data.get("notiz") or "").strip() or None
session.commit()
return kontaktadresse
def delete_kontaktadresse(kontaktadresse_id: int):
"""
Löscht eine Kontaktadresse anhand ihrer ID.
Wirft ApplicationError (404), falls die Kontaktadresse nicht existiert.
"""
kontaktadresse = get_kontaktadresse(kontaktadresse_id)
session.delete(kontaktadresse)
session.commit()
+167
View File
@@ -0,0 +1,167 @@
"""
Geschäftslogik für die Entität Projekt.
Diese Funktionen kapseln den DB-Zugriff und die fachlichen Validierungen.
Aufgerufen werden sie aus crm/routes.py; die Autorisierungsprüfung erfolgt
dort über den Decorator authorisation_required.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-04
:version: 1.0
"""
from db import dbapp
from crm.model import Projekt, Kunde, ProjektUser
from auth.model.errors import ApplicationError, HtmlStatusCode
from flask_login import current_user
current_user: object
session = dbapp.session
def get_projekte(kunden_id: int | None = None, user_id: int | None = None):
"""
Liefert Projekte, sortiert nach projektname.
Ist kunden_id gesetzt, werden nur die Projekte dieses Kunden
zurückgegeben (serverseitige Filterung). Ohne kunden_id werden
alle Projekte geliefert.
Ist user_id gesetzt, werden nur Projekte geliefert, denen der
Nutzer über die Tabelle 'projekt_users' zugeordnet ist. Dies
steuert die Sichtbarkeit: Nicht zugeordnete Nutzer sehen diese
Projekte nicht. SysAdmin und AppAdmin übergeben user_id=None
und sehen alle Projekte.
"""
query = Projekt.query
if kunden_id is not None:
query = query.filter(Projekt.kunden_id == kunden_id)
if user_id is not None:
# Nur Projekte, denen der Nutzer zugeordnet ist. Der Subquery
# auf projekt_users filtert die sichtbaren Projekt-IDs.
query = query.filter(
Projekt.id.in_(
dbapp.session.query(ProjektUser.projekt_id).filter(
ProjektUser.user_id == user_id
)
)
)
return query.order_by(Projekt.projektname).all()
def get_projekt(projekt_id: int, user_id: int | None = None) -> Projekt:
"""
Liefert ein einzelnes Projekt anhand seiner ID.
Wirft ApplicationError (404), falls das Projekt nicht existiert.
Ist user_id gesetzt, wird zusätzlich geprüft, ob der Nutzer dem
Projekt zugeordnet ist. Ist er nicht zugeordnet, wird ein 404
geliefert, damit nicht zugeordnete Nutzer keine Rückschlüsse auf
die Existenz des Projekts ziehen können.
"""
projekt = Projekt.query.get(projekt_id)
if projekt is None:
raise ApplicationError(
"Projekt mit der ID {} wurde nicht gefunden.".format(projekt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
if user_id is not None:
# Sichtbarkeitsprüfung: Ist der Nutzer dem Projekt zugeordnet?
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id,
ProjektUser.user_id == user_id,
).first()
if zuordnung is None:
# 404 statt 403, damit nicht zugeordnete Nutzer keine Info über
# die Existenz des Projekts erhalten.
raise ApplicationError(
"Projekt mit der ID {} wurde nicht gefunden.".format(projekt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return projekt
def create_projekt(data: dict) -> Projekt:
"""
Legt ein neues Projekt an. Die vom Client übergebene ID wird ignoriert;
die ID wird serverseitig per Auto-Increment vergeben.
- data: dict mit den Feldern kunden_id, projektname, zweck,
verantwortlicher, startdatum
"""
kunden_id = data.get("kunden_id")
if kunden_id is None:
raise ApplicationError(
"Es muss ein Kunde ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kunde = Kunde.query.get(int(kunden_id))
if kunde is None:
raise ApplicationError(
"Der ausgewählte Kunde existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
if not data.get("projektname") or not str(data["projektname"]).strip():
raise ApplicationError(
"Der Projektname darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
projekt = Projekt()
projekt.kunden_id = int(kunden_id)
projekt.projektname = data["projektname"].strip()
projekt.zweck = (data.get("zweck") or "").strip() or None
projekt.verantwortlicher = (data.get("verantwortlicher") or "").strip() or None
projekt.startdatum = (data.get("startdatum") or "").strip() or None
session.add(projekt)
session.commit()
# Auto-Assignment: Den Ersteller dem neuen Projekt zuordnen, damit
# er es sehen kann. Nur für authentifizierte Nutzer mit einer id.
if hasattr(current_user, "id") and current_user.id is not None:
zuordnung = ProjektUser()
zuordnung.projekt_id = projekt.id
zuordnung.user_id = current_user.id
session.add(zuordnung)
session.commit()
return projekt
def update_projekt(data: dict) -> Projekt:
"""
Aktualisiert ein bestehendes Projekt. Die ID im data-Dict bestimmt den
zu ändernden Datensatz.
"""
projekt = get_projekt(data["id"])
kunden_id = data.get("kunden_id")
if kunden_id is None:
raise ApplicationError(
"Es muss ein Kunde ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kunde = Kunde.query.get(int(kunden_id))
if kunde is None:
raise ApplicationError(
"Der ausgewählte Kunde existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
if not data.get("projektname") or not str(data["projektname"]).strip():
raise ApplicationError(
"Der Projektname darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
projekt.kunden_id = int(kunden_id)
projekt.projektname = data["projektname"].strip()
projekt.zweck = (data.get("zweck") or "").strip() or None
projekt.verantwortlicher = (data.get("verantwortlicher") or "").strip() or None
projekt.startdatum = (data.get("startdatum") or "").strip() or None
session.commit()
return projekt
def delete_projekt(projekt_id: int):
"""
Löscht ein Projekt anhand seiner ID.
Wirft ApplicationError (404), falls das Projekt nicht existiert.
"""
projekt = get_projekt(projekt_id)
session.delete(projekt)
session.commit()
+209
View File
@@ -0,0 +1,209 @@
"""
Geschäftslogik für die Projekt-User-Zuordnung (Sichtbarkeit von Projekten).
Diese Funktionen kapseln den DB-Zugriff auf die Tabelle 'projekt_users' und
die Auflösung der user_id gegen die Auth-Datenbank (username, email etc.).
Da CRM- und Auth-Datenbank getrennte SQLite-Dateien sind, erfolgt die
Nutzer-Auflösung durch eine separate Query, nicht durch einen SQL-Join.
Aufgerufen werden die Funktionen aus crm/routes.py; die Autorisierungsprüfung
erfolgt dort über den Decorator authorisation_required.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-05
:version: 1.0
"""
from db import dbapp
from crm.model import ProjektUser, Projekt
from auth.model import User
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_projekt_users(projekt_id: int):
"""
Liefert alle einem Projekt zugeordneten Nutzer als Liste von Dictionaries.
Jedes Dictionary enthält die Felder der ProjektUser-Zuordnung (id,
projekt_id, user_id) sowie die aus der Auth-Datenbank aufgelösten
Nutzerdaten (userid, username, email).
- projekt_id: int
- Die ID des Projekts, dessen Zuordnungen geliefert werden sollen.
"""
zuordnungen = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id
).all()
if not zuordnungen:
return []
# Alle user_ids sammeln und in einer einzigen Query gegen die Auth-DB
# auflösen (Batch-Query statt N+1).
user_ids = [z.user_id for z in zuordnungen]
users = User.query.filter(User.id.in_(user_ids)).all()
user_map = {u.id: u for u in users}
result = []
for z in zuordnungen:
eintrag = z.to_dict()
user = user_map.get(z.user_id)
if user is not None:
eintrag['userid'] = user.userid
eintrag['username'] = user.username
eintrag['email'] = user.email or ""
else:
# Nutzer wurde aus der Auth-DB gelöscht, die Zuordnung existiert
# noch. Die Felder werden leer geliefert.
eintrag['userid'] = ""
eintrag['username'] = ""
eintrag['email'] = ""
result.append(eintrag)
return result
def add_projekt_user(projekt_id: int, user_id: int) -> ProjektUser:
"""
Ordnet einen Nutzer einem Projekt zu. Das UniqueConstraint verhindert
Duplikate. Wirft ApplicationError (404), falls das Projekt nicht
existiert, oder (400), falls die Zuordnung bereits besteht oder die
Nutzer-ID ungültig ist.
- projekt_id: int
- Die ID des Projekts.
- user_id: int
- Die ID (nicht userid) des Nutzers aus der Auth-Datenbank.
"""
projekt = Projekt.query.get(projekt_id)
if projekt is None:
raise ApplicationError(
"Das Projekt mit der ID {} wurde nicht gefunden.".format(projekt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
if user_id is None or int(user_id) <= 0:
raise ApplicationError(
"Es muss ein gültiger Nutzer ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
# Prüfen, ob der Nutzer in der Auth-Datenbank existiert.
user = User.query.get(int(user_id))
if user is None:
raise ApplicationError(
"Der ausgewählte Nutzer existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
# Duplikat-Prüfung (zusätzlich zum UniqueConstraint, um eine
# aussagekräftige Fehlermeldung zu liefern).
existing = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id,
ProjektUser.user_id == int(user_id),
).first()
if existing is not None:
raise ApplicationError(
"Der Nutzer ist diesem Projekt bereits zugeordnet.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
zuordnung = ProjektUser()
zuordnung.projekt_id = projekt_id
zuordnung.user_id = int(user_id)
session.add(zuordnung)
session.commit()
return zuordnung
def remove_projekt_user(projekt_id: int, user_id: int):
"""
Entfernt die Zuordnung eines Nutzers zu einem Projekt.
Wirft ApplicationError (404), falls die Zuordnung nicht existiert.
- projekt_id: int
- Die ID des Projekts.
- user_id: int
- Die ID (nicht userid) des Nutzers aus der Auth-Datenbank.
"""
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id,
ProjektUser.user_id == int(user_id),
).first()
if zuordnung is None:
raise ApplicationError(
"Die Zuordnung existiert nicht.",
htmlstatus=HtmlStatusCode.Not_Found,
)
session.delete(zuordnung)
session.commit()
def get_projekt_ids_for_user(user_id: int):
"""
Liefert alle Projekt-IDs, denen der Nutzer zugeordnet ist.
Wird für die serverseitige Sichtbarkeits-Filterung in get_projekte
und get_projekt verwendet.
- user_id: int
- Die ID (nicht userid) des Nutzers aus der Auth-Datenbank.
"""
zuordnungen = ProjektUser.query.filter(
ProjektUser.user_id == user_id
).all()
return [z.projekt_id for z in zuordnungen]
def is_user_assigned(projekt_id: int, user_id: int) -> bool:
"""
Prüft, ob ein Nutzer einem Projekt zugeordnet ist.
- projekt_id: int
- Die ID des Projekts.
- user_id: int
- Die ID (nicht userid) des Nutzers aus der Auth-Datenbank.
"""
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id,
ProjektUser.user_id == user_id,
).first()
return zuordnung is not None
def get_all_projekt_users():
"""
Liefert alle Projekt-User-Zuordnungen, angereichert mit projektname
(aus Projekt-Relationship) sowie userid, username und email (aus
Auth-DB via Batch-Query).
Wird für die Übersichtssicht "Nutzer-Zugriffe" verwendet, in der alle
Zuordnungen in einem Aufruf geladen werden.
"""
zuordnungen = ProjektUser.query.all()
if not zuordnungen:
return []
# Projekte und Nutzer in jeweils einer Batch-Query auflösen.
projekt_ids = list({z.projekt_id for z in zuordnungen})
user_ids = list({z.user_id for z in zuordnungen})
projekte = Projekt.query.filter(Projekt.id.in_(projekt_ids)).all()
projekt_map = {p.id: p for p in projekte}
users = User.query.filter(User.id.in_(user_ids)).all()
user_map = {u.id: u for u in users}
result = []
for z in zuordnungen:
eintrag = z.to_dict()
projekt = projekt_map.get(z.projekt_id)
eintrag['projektname'] = projekt.projektname if projekt else ""
user = user_map.get(z.user_id)
if user is not None:
eintrag['userid'] = user.userid
eintrag['username'] = user.username
eintrag['email'] = user.email or ""
else:
eintrag['userid'] = ""
eintrag['username'] = ""
eintrag['email'] = ""
result.append(eintrag)
return result
+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
+403
View File
@@ -15,14 +15,67 @@ KundenManager werden aus crm.model importiert.
"""
from flask import current_app as app
from flask import jsonify, request
from flask_login import current_user
from auth.decorators import authorisation_required
from auth.model import APP_ADMIN
from auth.model.errors import ApplicationError, HtmlStatusCode
from crm.model import KUNDEN_MANAGER, MITARBEITER
from crm.model import kunde as kunden
from crm.model import kontakt as kontakte
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")
def _get_visible_user_id():
"""
Liefert die user_id für die Sichtbarkeits-Filterung.
SysAdmin und AppAdmin sehen alle Projekte und abhängige Entitäten —
für sie wird None zurückgegeben, sodass die Modell-Funktionen keinen
Filter anwenden. Alle anderen Nutzer (Mitarbeiter, KundenManager)
erhalten ihre current_user.id, sodass nur die ihnen zugeordneten
Projekte bzw. abhängige Einträge geliefert werden.
"""
if current_user.is_SysAdmin() or current_user.has_permission(
app.config["APPID"], [APP_ADMIN]
):
return None
return current_user.id
def _require_projekt_access(projekt_id: int):
"""
Prüft, ob der aktuelle Nutzer für das angegebene Projekt schreibberechtigt ist.
SysAdmin und AppAdmin haben immer Zugriff. Alle anderen Nutzer müssen
dem Projekt über projekt_users zugeordnet sein. Ist der Nutzer nicht
zugeordnet, wird ein ApplicationError (404) geworfen — 404 statt 403,
damit nicht zugeordnete Nutzer keine Rückschlüsse auf die Existenz
des Projekts ziehen können.
Muss in allen Schreib-Endpunkten (create/update/delete) aufgerufen
werden, die ein konkretes Projekt betreffen, bevor die Änderung
durchgeführt wird.
- projekt_id: int
- Die ID des Projekts, auf das zugegriffen werden soll.
"""
user_id = _get_visible_user_id()
if user_id is not None:
if not projekt_users.is_user_assigned(projekt_id, user_id):
raise ApplicationError(
"Projekt mit der ID {} wurde nicht gefunden.".format(projekt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
# --------------------------------------------------------------------------- #
# Kunde #
# --------------------------------------------------------------------------- #
@@ -68,4 +121,354 @@ def kunde_delete():
return jsonify({}), 200
# --------------------------------------------------------------------------- #
# Kontakt #
# --------------------------------------------------------------------------- #
@app.get("/xhr/kontakt/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def kontakt_list():
"""Liefert Kontakte als Liste. Optional nach kunden_id gefiltert."""
kunden_id = request.args.get("kunden_id", type=int)
result = [k.to_dict() for k in kontakte.get_kontakte(kunden_id)]
return jsonify(result), 200
@app.get("/xhr/kontakt/get")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def kontakt_get():
"""Liefert einen einzelnen Kontakt anhand seiner ID (Query-Param id)."""
kontakt = kontakte.get_kontakt(int(request.args.get("id")))
return jsonify(kontakt.to_dict()), 200
@app.post("/xhr/kontakt/create")
@authorisation_required([KUNDEN_MANAGER])
def kontakt_create():
"""Legt einen neuen Kontakt an. ID wird serverseitig vergeben."""
kontakt = kontakte.create_kontakt(request.json)
return jsonify(kontakt.to_dict()), 200
@app.put("/xhr/kontakt/update")
@authorisation_required([KUNDEN_MANAGER])
def kontakt_update():
"""Aktualisiert einen bestehenden Kontakt."""
kontakt = kontakte.update_kontakt(request.json)
return jsonify(kontakt.to_dict()), 200
@app.delete("/xhr/kontakt/delete")
@authorisation_required([KUNDEN_MANAGER])
def kontakt_delete():
"""Löscht einen Kontakt anhand seiner ID (Query-Param id)."""
kontakte.delete_kontakt(int(request.args.get("id")))
return jsonify({}), 200
# --------------------------------------------------------------------------- #
# Projekt #
# --------------------------------------------------------------------------- #
@app.get("/xhr/projekt/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def projekt_list():
"""Liefert Projekte als Liste. Optional nach kunden_id gefiltert.
Für Nicht-SysAdmin/Nicht-AppAdmin werden nur die Projekte geliefert,
denen der Nutzer über projekt_users zugeordnet ist."""
kunden_id = request.args.get("kunden_id", type=int)
user_id = _get_visible_user_id()
result = [p.to_dict() for p in projekte.get_projekte(kunden_id, user_id)]
return jsonify(result), 200
@app.get("/xhr/projekt/get")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def projekt_get():
"""Liefert ein einzelnes Projekt anhand seiner ID (Query-Param id).
Für Nicht-SysAdmin/Nicht-AppAdmin wird geprüft, ob der Nutzer dem
Projekt zugeordnet ist; sonst wird 404 geliefert."""
user_id = _get_visible_user_id()
projekt = projekte.get_projekt(int(request.args.get("id")), user_id)
return jsonify(projekt.to_dict()), 200
@app.post("/xhr/projekt/create")
@authorisation_required([KUNDEN_MANAGER])
def projekt_create():
"""Legt ein neues Projekt an. ID wird serverseitig vergeben."""
projekt = projekte.create_projekt(request.json)
return jsonify(projekt.to_dict()), 200
@app.put("/xhr/projekt/update")
@authorisation_required([KUNDEN_MANAGER])
def projekt_update():
"""Aktualisiert ein bestehendes Projekt."""
_require_projekt_access(request.json["id"])
projekt = projekte.update_projekt(request.json)
return jsonify(projekt.to_dict()), 200
@app.delete("/xhr/projekt/delete")
@authorisation_required([KUNDEN_MANAGER])
def projekt_delete():
"""Löscht ein Projekt anhand seiner ID (Query-Param id)."""
_require_projekt_access(int(request.args.get("id")))
projekte.delete_projekt(int(request.args.get("id")))
return jsonify({}), 200
# --------------------------------------------------------------------------- #
# ProjektUser (Nutzer-Zuordnung / Sichtbarkeit) #
# --------------------------------------------------------------------------- #
@app.get("/xhr/projektuser/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def projektuser_list():
"""Liefert alle einem Projekt zugeordneten Nutzer (Query-Param projekt_id)."""
projekt_id = request.args.get("projekt_id", type=int)
result = projekt_users.get_projekt_users(projekt_id)
return jsonify(result), 200
@app.get("/xhr/projektuser/listall")
# Leeres allowed_roles-Array als Erweiterungspunkt (nur AppAdmin/SysAdmin).
@authorisation_required([])
def projektuser_listall():
"""Liefert alle Projekt-User-Zuordnungen inkl. projektname und Nutzerdaten."""
result = projekt_users.get_all_projekt_users()
return jsonify(result), 200
@app.post("/xhr/projektuser/add")
# Leeres allowed_roles-Array als Erweiterungspunkt: Aktuell passieren nur
# AppAdmin und SysAdmin (vom Decorator implizit ergänzt). Sobald später eine
# weitere Rolle diese Endpunkte nutzen soll, wird sie einfach hier eingetragen,
# z.B. @authorisation_required([KUNDEN_MANAGER]).
@authorisation_required([])
def projektuser_add():
"""Ordnet einen Nutzer einem Projekt zu (body: {projekt_id, user_id})."""
data = request.json
projekt_users.add_projekt_user(data["projekt_id"], data["user_id"])
return jsonify({}), 200
@app.delete("/xhr/projektuser/remove")
# Leeres allowed_roles-Array als Erweiterungspunkt (siehe projektuser_add).
@authorisation_required([])
def projektuser_remove():
"""Entfernt die Zuordnung eines Nutzers zu einem Projekt (params: projekt_id, user_id)."""
projekt_id = request.args.get("projekt_id", type=int)
user_id = request.args.get("user_id", type=int)
projekt_users.remove_projekt_user(projekt_id, user_id)
return jsonify({}), 200
# --------------------------------------------------------------------------- #
# Kontaktadresse #
# --------------------------------------------------------------------------- #
@app.get("/xhr/kontaktadresse/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def kontaktadresse_list():
"""Liefert Kontaktadressen als Liste. Optional nach projekt_id gefiltert.
Für Nicht-SysAdmin/Nicht-AppAdmin nur Kontaktadressen sichtbarer Projekte."""
projekt_id = request.args.get("projekt_id", type=int)
user_id = _get_visible_user_id()
result = [k.to_dict() for k in kontaktadressen.get_kontaktadressen(projekt_id, user_id)]
return jsonify(result), 200
@app.get("/xhr/kontaktadresse/get")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def kontaktadresse_get():
"""Liefert eine einzelne Kontaktadresse anhand ihrer ID (Query-Param id).
Für Nicht-SysAdmin/Nicht-AppAdmin wird geprüft, ob der Nutzer dem
Projekt der Kontaktadresse zugeordnet ist; sonst 404."""
user_id = _get_visible_user_id()
kontaktadresse = kontaktadressen.get_kontaktadresse(int(request.args.get("id")), user_id)
return jsonify(kontaktadresse.to_dict()), 200
@app.post("/xhr/kontaktadresse/create")
@authorisation_required([KUNDEN_MANAGER])
def kontaktadresse_create():
"""Legt eine neue Kontaktadresse an. ID wird serverseitig vergeben."""
_require_projekt_access(int(request.json["projekt_id"]))
kontaktadresse = kontaktadressen.create_kontaktadresse(request.json)
return jsonify(kontaktadresse.to_dict()), 200
@app.put("/xhr/kontaktadresse/update")
@authorisation_required([KUNDEN_MANAGER])
def kontaktadresse_update():
"""Aktualisiert eine bestehende Kontaktadresse."""
_require_projekt_access(int(request.json["projekt_id"]))
kontaktadresse = kontaktadressen.update_kontaktadresse(request.json)
return jsonify(kontaktadresse.to_dict()), 200
@app.delete("/xhr/kontaktadresse/delete")
@authorisation_required([KUNDEN_MANAGER])
def kontaktadresse_delete():
"""Löscht eine Kontaktadresse anhand ihrer ID (Query-Param id)."""
# projekt_id der Kontaktadresse ermitteln, um die Projekt-Zuordnung
# des Nutzers zu prüfen, bevor gelöscht wird.
user_id = _get_visible_user_id()
kontaktadresse = kontaktadressen.get_kontaktadresse(int(request.args.get("id")), user_id)
kontaktadressen.delete_kontaktadresse(int(request.args.get("id")))
return jsonify({}), 200
# --------------------------------------------------------------------------- #
# CallLog (Gesprächsnotiz) #
# --------------------------------------------------------------------------- #
@app.get("/xhr/calllog/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def calllog_list():
"""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, kontaktadresse_id
)]
return jsonify(result), 200
@app.get("/xhr/calllog/get")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def calllog_get():
"""Liefert eine einzelne Gesprächsnotiz anhand ihrer ID (Query-Param id).
Für Nicht-SysAdmin/Nicht-AppAdmin wird geprüft, ob der Nutzer dem
Projekt der Notiz zugeordnet ist; sonst 404."""
user_id = _get_visible_user_id()
call_log = call_logs.get_call_log(int(request.args.get("id")), user_id)
return jsonify(call_log.to_dict()), 200
@app.post("/xhr/calllog/create")
@authorisation_required([KUNDEN_MANAGER])
def calllog_create():
"""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, current_user.id)
return jsonify(call_log.to_dict()), 200
@app.put("/xhr/calllog/update")
@authorisation_required([KUNDEN_MANAGER])
def calllog_update():
"""Aktualisiert eine bestehende Gesprächsnotiz."""
_require_projekt_access(int(request.json["projekt_id"]))
call_log = call_logs.update_call_log(request.json)
return jsonify(call_log.to_dict()), 200
@app.delete("/xhr/calllog/delete")
@authorisation_required([KUNDEN_MANAGER])
def calllog_delete():
"""Löscht eine Gesprächsnotiz anhand ihrer ID (Query-Param id)."""
# projekt_id der Notiz ermitteln, um die Projekt-Zuordnung
# des Nutzers zu prüfen, bevor gelöscht wird.
user_id = _get_visible_user_id()
call_log = call_logs.get_call_log(int(request.args.get("id")), user_id)
call_logs.delete_call_log(int(request.args.get("id")))
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 -16
View File
@@ -1,20 +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
serve(app, listen='*:8080')
else:
_server = "werkzeug"
serving.run_simple("0.0.0.0", 8080, app, use_reloader=True)
except Exception as ex:
print(str(ex))
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)
+6 -15
View File
@@ -1,16 +1,7 @@
# Python virtual Environment anlegen
cd /usr/opt/pythonapp
# Wenn es die Datei pyvenv.cfg im Unterverzeichnis env nocht nicht gibt, muss das
# virtuelle Environment angelegt werden
# if [ ! -s env/pyvenv.cfg ]
# then
# echo "Creating Python Virtual environment..."
# python -m venv --upgrade-deps /usr/opt/pythonapp/env
# echo "Python Virtual environment created."
# fi
echo "Installing Python requirements..."
env/bin/pip install -r root/requirements.txt
echo "Python requirements installed."
#!/bin/bash
# Startet den Python-Server im Prod-Modus.
# pip install ist nicht mehr noetig — Abhaengigkeiten sind im Image.
# Kein cd noetig — alle Pfade sind absolut.
# Python-Imports funktionieren ueber sys.path[0] (Verzeichnis des Scripts).
echo "Starting Python-Server..."
env/bin/python root/runserver.py
exec /usr/opt/pythonapp/env/bin/python /usr/opt/pythonapp/App_Server/root/runserver.py
+8
View File
@@ -34,11 +34,18 @@ export const loginscreen: m.ClosureComponent<iLoginscreen> = (vnode) => {
if (user.isSysAdmin) {
sessionStorage.setItem("CurrentUserIsSysAdmin", "1");
}
// AppAdmin: der Server prüft applikationsspezifisch via
// has_permission(appid, [APP_ADMIN]) und liefert isAppAdmin
// im Login-Response. Der Client wertet nur das Flag aus.
if (user.isAppAdmin) {
sessionStorage.setItem("CurrentUserIsAppAdmin", "1");
}
})
.then(() => {m.route.set(vnode.attrs.setnextpage)})
.catch((error) => {
sessionStorage.removeItem("CurrentUser");
sessionStorage.removeItem("CurrentUserIsSysAdmin");
sessionStorage.removeItem("CurrentUserIsAppAdmin");
showServerError(error)
})
@@ -52,6 +59,7 @@ export const loginscreen: m.ClosureComponent<iLoginscreen> = (vnode) => {
.then (() => {
sessionStorage.removeItem("CurrentUser");
sessionStorage.removeItem("CurrentUserIsSysAdmin");
sessionStorage.removeItem("CurrentUserIsAppAdmin");
// global.appglobals.currentuser = "nicht angemeldet"
// global.appglobals.currentuserid = ""
})
+1
View File
@@ -9,6 +9,7 @@ export interface iUser {
email: string
telefon: string
isSysAdmin: boolean
isAppAdmin: boolean
userapps: iUserApp[]
}
+365 -192
View File
@@ -1,34 +1,44 @@
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 {iCallLog, getCallLogsByProjektId, getNewCallLogId, addCallLogData, updateCallLogData} from "../models/call_log"
import {iKunde, getKunden} from "../models/kunde"
import {getProjekteByKundenId, getProjektById} from "../models/projekt"
import {getKontaktadressenByProjektId, getKontaktadresseById} from "../models/kontaktadresse"
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.
// 1000 = jede Sekunde, 60000 = jede Minute.
const DAUER_UPDATE_INTERVALL = 1000
function createCallLogTreenodes(projektId: number | null = null): iTreenode[] {
let filteredLogs = getCallLogsByProjektId(projektId)
return filteredLogs.map((log: iCallLog) => {
return new Treenode(log, true, [])
})
}
export const call_log_liste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = createCallLogTreenodes()
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
let filterProjektId: number | null = null
// Projektname für die Überschrift, asynchron per fetchProjektById
// aufgelöst. kunden_name wird ebenfalls aus dem Projekt bezogen,
// 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]
@@ -36,57 +46,54 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
const params: any = {
id: -1,
datum: datum,
uhrzeitBeginn: uhrzeit,
uhrzeit_beginn: uhrzeit,
dauer: "",
betreff: "",
nameAnsprechpartner: "",
name_ansprechpartner: "",
telefon: "",
notiz: "",
wiedervorlageDatum: "",
erledigt: false
wiedervorlage_datum: "",
from_liste: "1"
}
// Bei projektgefilterter Liste Kunde und Projekt vorbelegen.
if (filterProjektId !== null) {
const projekt = getProjektById(filterProjektId)
if (projekt) {
params.kundenName = projekt.kundenName
params.projektId = projekt.id
params.projektName = projekt.projektname
}
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,
kundenName: log.kundenName,
projektId: log.projektId,
projektName: log.projektName,
datum: log.datum,
uhrzeitBeginn: log.uhrzeitBeginn,
dauer: log.dauer,
betreff: log.betreff,
nameAnsprechpartner: log.nameAnsprechpartner,
telefon: log.telefon,
notiz: log.notiz,
wiedervorlageDatum: log.wiedervorlageDatum,
erledigt: log.erledigt
})
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.kundenName + ": " + log.projektName)),
m("div", m("strong", log.kunden_name + ": " + log.projekt_name)),
m("span", log.datum),
m("span", log.uhrzeitBeginn),
m("span", log.uhrzeit_beginn),
m("span", log.dauer + " min"),
m("span", log.betreff),
m("span", log.wiedervorlageDatum),
m("span", log.erledigt ? "Erledigt" : "Offen"),
m("span", log.betreff, ...tagBadges),
m("span", log.wiedervorlage_datum),
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"))
]
}
@@ -94,25 +101,44 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let log: iCallLog = node.nodevalue
let searchLower = searchvalue.toLowerCase()
return log.kundenName.toLowerCase().includes(searchLower) ||
log.projektName.toLowerCase().includes(searchLower) ||
log.datum.includes(searchLower) ||
log.uhrzeitBeginn.toLowerCase().includes(searchLower) ||
log.betreff.toLowerCase().includes(searchLower) ||
log.nameAnsprechpartner.toLowerCase().includes(searchLower) ||
log.notiz.toLowerCase().includes(searchLower) ||
log.wiedervorlageDatum.includes(searchLower)
return (log.kunden_name || "").toLowerCase().includes(searchLower) ||
(log.projekt_name || "").toLowerCase().includes(searchLower) ||
(log.datum || "").includes(searchLower) ||
(log.uhrzeit_beginn || "").toLowerCase().includes(searchLower) ||
(log.betreff || "").toLowerCase().includes(searchLower) ||
(log.name_ansprechpartner || "").toLowerCase().includes(searchLower) ||
(log.notiz || "").toLowerCase().includes(searchLower) ||
(log.wiedervorlage_datum || "").includes(searchLower)
}
return {
oninit: (vnode) => {
const routeParams = m.route.param()
if (routeParams && routeParams.projektId) {
filterProjektId = parseInt(routeParams.projektId)
if (routeParams && routeParams.projekt_id) {
filterProjektId = parseInt(routeParams.projekt_id)
projektName = routeParams.projekt_name || ""
} else {
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.
fetchCallLogs(filterProjektId !== null ? filterProjektId : undefined).then((result) => {
treenodes = result.map((log: iCallLog) => new Treenode(log, true, []))
}).catch((e) => showServerError(e))
// Projektnamen und Kundennamen asynchron auflösen, falls
// nicht in den Route-Params enthalten.
if (filterProjektId !== null) {
fetchProjektById(filterProjektId).then((projekt: iProjekt) => {
if (projektName === "") {
projektName = projekt.projektname
}
kundenName = projekt.kunden_name
}).catch((e) => showServerError(e))
}
treenodes = createCallLogTreenodes(filterProjektId)
},
view: (vnode) => {
return m("div.w3-container.w3-auto", {
@@ -121,7 +147,11 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", "Gesprächsnotizen"),
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,
{
@@ -142,181 +172,297 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
}
export const Call_Log: m.ClosureComponent = (vnode) => {
// currentLog hält den Formularzustand. kundenId ist intern und wird über
// das Projekt aufgelöst; kundenName/projektName dienen der Anzeige.
// currentLog hält den Formularzustand. projekt_id referenziert das
// Projekt, kunden_name und projekt_name dienen der Anzeige.
let currentLog: {
id: number
kundenId: number
kundenName: string
projektId: number
projektName: string
projekt_id: number
kontaktadresse_id: number
projekt_name: string
kunden_name: string
datum: string
uhrzeitBeginn: string
uhrzeit_beginn: string
dauer: string
betreff: string
nameAnsprechpartner: string
name_ansprechpartner: string
telefon: string
notiz: string
wiedervorlageDatum: string
erledigt: boolean
wiedervorlage_datum: string
} = {
id: -1,
kundenId: -1,
kundenName: "",
projektId: -1,
projektName: "",
projekt_id: -1,
kontaktadresse_id: -1,
projekt_name: "",
kunden_name: "",
datum: "",
uhrzeitBeginn: "",
uhrzeit_beginn: "",
dauer: "",
betreff: "",
nameAnsprechpartner: "",
name_ansprechpartner: "",
telefon: "",
notiz: "",
wiedervorlageDatum: "",
erledigt: false
wiedervorlage_datum: ""
}
// Sind Kunde und Projekt voreingestellt (Liste wurde projektgefiltert
// oder aus der Projektliste aufgerufen)?
let kundeVoreingestellt: boolean = false
// Ist das Projekt voreingestellt (Liste wurde projektgefiltert)?
let projektVoreingestellt: boolean = false
// Ist der Ansprechpartner voreingestellt (aus Kontaktadressenliste)?
// Wenn ja, wird ein readonly Input gezeigt; sonst ein Select, das die
// Kontaktadressen des gewählten Projekts anbietet.
let ansprechpartnerVoreingestellt: boolean = false
// Startzeit für die Dauer-Messung (nur bei Neuanlage gesetzt).
let startZeit: Date | null = null
// Timer-Handle für die Dauer-Aktualisierung.
let timerId: number | null = null
// Direkte DOM-Referenz auf das Dauer-Feld, damit der Timer den Wert
// ohne Redraw aktualisieren kann (ein Redraw würde die übrigen Felder
// mit den veralteten currentLog-Werten überschreiben).
// Direkte DOM-Referenz auf das Dauer-Feld.
let dauerInput: HTMLInputElement | null = null
// Asynchron geladene Listen für Select-Optionen.
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 (Minuten:Sekunden).
* Formatiert verstrichene Sekunden als mm:ss.
*/
const formatDauer = (sekunden: number): string => {
const m = Math.floor(sekunden / 60)
const s = sekunden % 60
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
const min = Math.floor(sekunden / 60)
const sek = sekunden % 60
return min.toString().padStart(2, '0') + ':' + sek.toString().padStart(2, '0')
}
return {
oninit: (vnode) => {
const routeParams = m.route.param()
if (routeParams) {
const projektId = routeParams.projektId ? parseInt(routeParams.projektId) : -1
kundeVoreingestellt = projektId !== -1
// kundenId aus dem Projekt ableiten, falls nicht direkt gesetzt.
let kundenId = -1
if (kundeVoreingestellt) {
const projekt = getProjektById(projektId)
if (projekt) { kundenId = projekt.kundenId }
}
currentLog = {
id: routeParams.id ? parseInt(routeParams.id) : -1,
kundenId: kundenId,
kundenName: routeParams.kundenName || "",
projektId: projektId,
projektName: routeParams.projektName || "",
datum: routeParams.datum || "",
uhrzeitBeginn: routeParams.uhrzeitBeginn || "",
dauer: routeParams.dauer || "",
betreff: routeParams.betreff || "",
nameAnsprechpartner: routeParams.nameAnsprechpartner || "",
telefon: routeParams.telefon || "",
notiz: routeParams.notiz || "",
wiedervorlageDatum: routeParams.wiedervorlageDatum || "",
erledigt: routeParams.erledigt === true || routeParams.erledigt === "true"
}
// Bei Neuanlage Startzeit merken und Dauer-Timer starten.
ansprechpartnerVoreingestellt = currentLog.nameAnsprechpartner !== ""
if (currentLog.id === -1) {
startZeit = new Date()
timerId = window.setInterval(() => {
if (startZeit) {
// Verstrichene Sekunden berechnen; das Intervall
// steuert nur die Update-Frequenz.
const sekunden = Math.floor((Date.now() - startZeit.getTime()) / 1000)
const dauer = formatDauer(sekunden)
currentLog.dauer = dauer
// Nur das Dauer-Feld direkt im DOM aktualisieren,
// ohne redraw (sonst werden andere Felder zurückgesetzt).
if (dauerInput) { dauerInput.value = dauer }
}
}, DAUER_UPDATE_INTERVALL)
}
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.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
currentLog.projekt_id = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) : -1
currentLog.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
currentLog.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
currentLog.datum = routeParams && routeParams.datum ? routeParams.datum : ""
currentLog.uhrzeit_beginn = routeParams && routeParams.uhrzeit_beginn ? routeParams.uhrzeit_beginn : ""
currentLog.dauer = routeParams && routeParams.dauer ? routeParams.dauer : ""
currentLog.betreff = routeParams && routeParams.betreff ? routeParams.betreff : ""
currentLog.name_ansprechpartner = routeParams && routeParams.name_ansprechpartner ? routeParams.name_ansprechpartner : ""
currentLog.telefon = routeParams && routeParams.telefon ? routeParams.telefon : ""
currentLog.notiz = routeParams && routeParams.notiz ? routeParams.notiz : ""
currentLog.wiedervorlage_datum = routeParams && routeParams.wiedervorlage_datum ? routeParams.wiedervorlage_datum : ""
// 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(() => {
if (startZeit) {
const sekunden = Math.floor((Date.now() - startZeit.getTime()) / 1000)
const dauer = formatDauer(sekunden)
currentLog.dauer = dauer
if (dauerInput) { dauerInput.value = dauer }
}
}, DAUER_UPDATE_INTERVALL)
}
// Alle Listen asynchron laden, für Select-Optionen.
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) => {
// Timer stoppen, damit er nach Verlassen des Views nicht weiterläuft.
if (timerId != null) { window.clearInterval(timerId) }
guard.cleanup()
},
view: (vnode) => {
// Select-Optionen aus Kunden- und Projektliste aufbauen.
const kundenOptionen: iSelectOption[] = getKunden().map((k: iKunde) => {
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[] = getProjekteByKundenId(currentLog.kundenId).map((p) => {
return {value: p.id, label: p.projektname}
})
// Kontaktadressen-Optionen sind vom gewählten Projekt abhängig.
// Werden nur benötigt, wenn der Ansprechpartner nicht voreingestellt ist.
const kontaktadressenOptionen: iSelectOption[] = !ansprechpartnerVoreingestellt
? getKontaktadressenByProjektId(currentLog.projektId).map((ka) => {
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
const projektOptionen: iSelectOption[] = projektListe
.filter((p: iProjekt) => selectedKundenId === -1 || p.kunden_id === selectedKundenId)
.map((p: iProjekt) => {
return {value: p.id, label: p.projektname}
})
// Kontaktadressen-Optionen sind vom gewählten Projekt abhängig.
const kontaktadressenOptionen: iSelectOption[] = !ansprechpartnerVoreingestellt
? kontaktadressenListe
.filter((ka: iKontaktadresse) => ka.projekt_id === currentLog.projekt_id)
.map((ka: iKontaktadresse) => {
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
})
: []
// Einheit der Dauer-Anzeige im Format mm:ss.
// 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: 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: 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: 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(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"),
// Form füllt den verbleibenden Platz; das Form-Widget selbst
// sorgt (mit stickyButtons, Default) dafür, dass nur der
// Inhalt scrollt und die Buttons fixiert bleiben.
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: (null), oncancel: (null)} as iForm, [
m("label", "Kunde",
m(Select, {
name: "kundenId",
value: currentLog.kundenId,
options: kundenOptionen,
readonly: kundeVoreingestellt,
required: true,
onchanged: (kundenId: number) => {
// Bei Kundenwechsel die kundenId übernehmen
// und projektId zurücksetzen, da die Projekte
// kundenabhängig sind.
currentLog.kundenId = kundenId
currentLog.projektId = -1
}
} as iSelect)),
m("label", "Projekt",
m(Select, {
name: "projektId",
value: currentLog.projektId,
options: projektOptionen,
readonly: kundeVoreingestellt,
required: true,
onchanged: (projektId: number) => {
currentLog.projektId = projektId
// Bei Projektwechsel Ansprechpartner und Telefon
// zurücksetzen, falls nicht voreingestellt.
if (!ansprechpartnerVoreingestellt) {
currentLog.nameAnsprechpartner = ""
currentLog.telefon = ""
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)),
} 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: "uhrzeitBeginn", value: currentLog.uhrzeitBeginn, type: "time"} as iInput)),
m(Input, {name: "uhrzeit_beginn", value: currentLog.uhrzeit_beginn, type: "time"} as iInput)),
m("label", {style: {flex: '1'}}, "Dauer (mm:ss)",
// Raw input statt Input-Widget, damit oncreate die
// DOM-Referenz für den Timer greifen kann. Text-Typ,
// da das Format mm:ss keine Zahl ist.
m("input.w3-input.w3-border", {
name: "dauer",
type: "text",
@@ -328,23 +474,21 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
),
m("label", "Betreff", m(Input, {name: "betreff", value: currentLog.betreff, type: "text"} as iInput)),
// Ansprechpartner und Telefon in einer Zeile.
// Wenn der Ansprechpartner voreingestellt ist (aus Kontaktadressenliste),
// wird ein readonly Input gezeigt; sonst ein Select, das die
// Kontaktadressen des gewählten Projekts anbietet. Bei Auswahl
// werden Name und Telefon automatisch übernommen.
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
m("label", {style: {flex: '1'}}, "Name Ansprechpartner",
ansprechpartnerVoreingestellt
? m(Input, {name: "nameAnsprechpartner", value: currentLog.nameAnsprechpartner, type: "text", readonly: true} as iInput)
? m(Input, {name: "name_ansprechpartner", value: currentLog.name_ansprechpartner, type: "text", readonly: true} as iInput)
: m(Select, {
name: "kontaktadresseId",
name: "kontaktadresse_id",
value: -1,
options: kontaktadressenOptionen,
required: true,
onchanged: (kontaktadresseId: number) => {
const ka = getKontaktadresseById(kontaktadresseId)
const ka = kontaktadressenListe.find((k: iKontaktadresse) => k.id === kontaktadresseId)
if (ka) {
currentLog.nameAnsprechpartner = ka.name
currentLog.name_ansprechpartner = ka.name
currentLog.telefon = ka.telefon
guard.markDirty()
}
}
} as iSelect)),
@@ -352,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: "wiedervorlageDatum", value: currentLog.wiedervorlageDatum, 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))
])
)
)
+104 -95
View File
@@ -1,62 +1,50 @@
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 {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {iKontakt, getKontakteByKundenId, getNewKontaktId, addKontakt, updateKontakt} from "../models/kontakt"
import {iKunde, getKunden} from "../models/kunde"
function createKontaktTreenodes(kundenId: number | null = null): iTreenode[] {
let filteredKontakte = getKontakteByKundenId(kundenId)
return filteredKontakte.map((kontakt: iKontakt) => {
return new Treenode(kontakt, true, [])
})
}
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[] = createKontaktTreenodes()
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
let filterKundenId: number | null = null
// 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,
anrede: "",
name: "",
position: "",
telefon: "",
email: "",
notiz: ""
}
const params: any = {id: -1}
// Nur bei kundengefilterter Liste den Kunden vorbelegen.
if (filterKundenId !== null) {
const kunde = getKunden().find((k: iKunde) => k.id === filterKundenId)
if (kunde) {
params.kundenId = kunde.id
params.kundenname = kunde.name
}
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,
kundenId: kontakt.kundenId,
kundenname: kontakt.kundenName,
anrede: kontakt.anrede,
name: kontakt.name,
position: kontakt.position,
telefon: kontakt.telefon,
email: kontakt.email,
notiz: kontakt.notiz
})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/kontakt", {id: kontakt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
@@ -67,7 +55,7 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
m("span", kontakt.position),
m("span", kontakt.telefon),
m("span", kontakt.email),
m("span", kontakt.kundenName),
m("span", kontakt.kunden_name),
m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editKontakt(kontakt, e)}, m("i.material-icons", "edit"))
]
}
@@ -75,22 +63,36 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let kontakt: iKontakt = node.nodevalue
let searchLower = searchvalue.toLowerCase()
return kontakt.name.toLowerCase().includes(searchLower) ||
kontakt.position.toLowerCase().includes(searchLower) ||
kontakt.telefon.toLowerCase().includes(searchLower) ||
kontakt.email.toLowerCase().includes(searchLower) ||
kontakt.kundenName.toLowerCase().includes(searchLower)
return (kontakt.name || "").toLowerCase().includes(searchLower) ||
(kontakt.position || "").toLowerCase().includes(searchLower) ||
(kontakt.telefon || "").toLowerCase().includes(searchLower) ||
(kontakt.email || "").toLowerCase().includes(searchLower) ||
(kontakt.kunden_name || "").toLowerCase().includes(searchLower)
}
return {
oninit: (vnode) => {
const routeParams = m.route.param()
if (routeParams && routeParams.kundenId) {
filterKundenId = parseInt(routeParams.kundenId)
if (routeParams && routeParams.kunden_id) {
filterKundenId = parseInt(routeParams.kunden_id)
kundenName = routeParams.kunden_name || ""
} else {
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) => {
treenodes = result.map((kontakt: iKontakt) => new Treenode(kontakt, true, []))
}).catch((e) => showServerError(e))
// Kundennamen asynchron auflösen, falls nicht in Route-Params.
if (filterKundenId !== null && kundenName === "") {
fetchKundeById(filterKundenId).then((kunde: iKunde) => {
kundenName = kunde.name
}).catch((e) => showServerError(e))
}
treenodes = createKontaktTreenodes(filterKundenId)
},
view: (vnode) => {
return m("div.w3-container.w3-auto", {
@@ -99,7 +101,11 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", "Kontaktliste"),
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,
{
@@ -120,12 +126,12 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
}
export const Kontakt: m.ClosureComponent = (vnode) => {
// currentKontakt hält den Formularzustand. kundenId referenziert den
// Kunden eindeutig, kundenname dient der Anzeige.
// currentKontakt hält den Formularzustand. kunden_id referenziert den
// Kunden eindeutig, kunden_name dient der Anzeige in der Überschrift.
let currentKontakt: {
id: number
kundenId: number
kundenname: string
kunden_id: number
kunden_name: string
anrede: string
name: string
position: string
@@ -134,8 +140,8 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
notiz: string
} = {
id: -1,
kundenId: -1,
kundenname: "",
kunden_id: -1,
kunden_name: "",
anrede: "",
name: "",
position: "",
@@ -143,38 +149,50 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
email: "",
notiz: ""
}
// Ist der Kunde voreingestellt (gefilterte Kontaktliste)?
let kundeVoreingestellt: boolean = false
// 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()
if (routeParams) {
const kundenId = routeParams.kundenId ? parseInt(routeParams.kundenId) : -1
kundeVoreingestellt = kundenId !== -1
currentKontakt = {
id: routeParams.id ? parseInt(routeParams.id) : -1,
kundenId: kundenId,
kundenname: routeParams.kundenname || "",
anrede: routeParams.anrede || "",
name: routeParams.name || "",
position: routeParams.position || "",
telefon: routeParams.telefon || "",
email: routeParams.email || "",
notiz: routeParams.notiz || ""
}
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.
fetchKontaktById(id).then((result) => {
currentKontakt = result
}).catch((e) => showServerError(e))
} else {
// Neuanlegen: kunden_id und kunden_name aus Route-Params
// (voreingestellt aus der Kontaktliste).
currentKontakt.kunden_id = routeParams && routeParams.kunden_id ? parseInt(routeParams.kunden_id) : -1
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)
const kundenId = parseInt(formData.get("kundenId") as string)
// Kundennamen zur gewählten kundenId auflösen.
const kunde = getKunden().find((k: iKunde) => k.id === kundenId)
const gespeicherterKontakt: iKontakt = {
id: currentKontakt.id,
kundenId: kundenId,
kundenName: kunde ? kunde.name : "",
kunden_id: currentKontakt.kunden_id,
kunden_name: "",
anrede: (formData.get("anrede") as string) || "",
name: (formData.get("name") as string) || "",
position: (formData.get("position") as string) || "",
@@ -183,33 +201,24 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
notiz: (formData.get("notiz") as string) || ""
}
if (currentKontakt.id === -1) {
gespeicherterKontakt.id = getNewKontaktId()
addKontakt(gespeicherterKontakt)
createKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateKontakt(gespeicherterKontakt)
updateKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
m.route.set("/kontaktliste")
return true
}
const cancel = (e: Event) => {
m.route.set("/kontaktliste")
}
// Select-Optionen aus der Kundenliste aufbauen.
const kundenOptionen: iSelectOption[] = getKunden().map((k: iKunde) => {
return {value: k.id, label: k.name}
})
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentKontakt.id === -1 ? "Neuen Kontakt anlegen" : "Kontakt-Stammblatt"),
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("label", "Kunde",
m(Select, {
name: "kundenId",
value: currentKontakt.kundenId,
options: kundenOptionen,
readonly: kundeVoreingestellt,
required: true
} as iSelect)),
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)),
m("label", "Position", m(Input, {name: "position", value: currentKontakt.position, type: "text"} as iInput)),
+133 -120
View File
@@ -1,70 +1,49 @@
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 {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {iKontaktadresse, getKontaktadressenByProjektId, getNewKontaktadresseId, addKontaktadresse, updateKontaktadresse} from "../models/kontaktadresse"
import {iProjekt, getProjekte, getProjektById} from "../models/projekt"
function createKontaktadresseTreenodes(projektId: number | null = null): iTreenode[] {
let filteredKontaktadressen = getKontaktadressenByProjektId(projektId)
return filteredKontaktadressen.map((kontaktadresse: iKontaktadresse) => {
return new Treenode(kontaktadresse, true, [])
})
}
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[] = createKontaktadresseTreenodes()
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
let filterProjektId: number | null = null
// 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,
anrede: "",
name: "",
firma: "",
telefon: "",
email: "",
strasse: "",
plz: "",
ort: "",
land: "",
notiz: ""
}
// Nur bei projektgefilterter Liste das Projekt vorbelegen.
const params: any = {id: -1}
if (filterProjektId !== null) {
const projekt = getProjekte().find((p: iProjekt) => p.id === filterProjektId)
if (projekt) {
params.projektId = projekt.id
params.projektname = projekt.projektname
}
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,
projektId: kontaktadresse.projektId,
projektname: kontaktadresse.projektName,
anrede: kontaktadresse.anrede,
name: kontaktadresse.name,
firma: kontaktadresse.firma,
telefon: kontaktadresse.telefon,
email: kontaktadresse.email,
strasse: kontaktadresse.strasse,
plz: kontaktadresse.plz,
ort: kontaktadresse.ort,
land: kontaktadresse.land,
notiz: kontaktadresse.notiz
})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/kontaktadresse", {id: kontaktadresse.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const addCallLog = (kontaktadresse: iKontaktadresse, e: Event) => {
@@ -72,22 +51,29 @@ 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]
// Kundennamen über das Projekt auflösen.
const projekt = getProjektById(kontaktadresse.projektId)
// 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,
kundenName: projekt ? projekt.kundenName : "",
projektId: kontaktadresse.projektId,
projektName: kontaktadresse.projektName,
projekt_id: kontaktadresse.projekt_id,
projekt_name: kontaktadresse.projekt_name,
// Kontaktadresse als Pflichtfeld an die Notizmaske durchreichen
kontaktadresse_id: kontaktadresse.id,
datum: datum,
uhrzeitBeginn: uhrzeit,
uhrzeit_beginn: uhrzeit,
dauer: "",
betreff: "",
nameAnsprechpartner: kontaktadresse.name,
name_ansprechpartner: kontaktadresse.name,
telefon: kontaktadresse.telefon,
notiz: "",
wiedervorlageDatum: "",
erledigt: false
wiedervorlage_datum: "",
bc: serializeBc(chain)
})
}
@@ -97,10 +83,10 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
return [
m("div", m("strong", kontaktadresse.name)),
m("span", kontaktadresse.firma),
m("span", kontaktadresse.position),
m("span", kontaktadresse.telefon),
m("span", kontaktadresse.email),
m("span", kontaktadresse.ort),
m("span", kontaktadresse.projektName),
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap'}},
m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editKontaktadresse(kontaktadresse, e)}, m("i.material-icons", "edit")),
m("button.w3-button.w3-small", {title: "Gesprächsnotiz hinzufügen", style: {padding: '0 2px'}, onclick: (e: Event) => addCallLog(kontaktadresse, e)}, m("i.material-icons", "add_call"))
@@ -111,23 +97,39 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let kontaktadresse: iKontaktadresse = node.nodevalue
let searchLower = searchvalue.toLowerCase()
return kontaktadresse.name.toLowerCase().includes(searchLower) ||
kontaktadresse.firma.toLowerCase().includes(searchLower) ||
kontaktadresse.telefon.toLowerCase().includes(searchLower) ||
kontaktadresse.email.toLowerCase().includes(searchLower) ||
kontaktadresse.ort.toLowerCase().includes(searchLower) ||
kontaktadresse.projektName.toLowerCase().includes(searchLower)
return (kontaktadresse.name || "").toLowerCase().includes(searchLower) ||
(kontaktadresse.firma || "").toLowerCase().includes(searchLower) ||
(kontaktadresse.position || "").toLowerCase().includes(searchLower) ||
(kontaktadresse.telefon || "").toLowerCase().includes(searchLower) ||
(kontaktadresse.email || "").toLowerCase().includes(searchLower) ||
(kontaktadresse.ort || "").toLowerCase().includes(searchLower) ||
(kontaktadresse.projekt_name || "").toLowerCase().includes(searchLower)
}
return {
oninit: (vnode) => {
const routeParams = m.route.param()
if (routeParams && routeParams.projektId) {
filterProjektId = parseInt(routeParams.projektId)
if (routeParams && routeParams.projekt_id) {
filterProjektId = parseInt(routeParams.projekt_id)
projektName = routeParams.projekt_name || ""
} else {
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.
fetchKontaktadressen(filterProjektId !== null ? filterProjektId : undefined).then((result) => {
treenodes = result.map((kontaktadresse: iKontaktadresse) => new Treenode(kontaktadresse, true, []))
}).catch((e) => showServerError(e))
// Projektnamen asynchron auflösen, falls nicht in Route-Params.
if (filterProjektId !== null && projektName === "") {
fetchProjektById(filterProjektId).then((projekt: iProjekt) => {
projektName = projekt.projektname
}).catch((e) => showServerError(e))
}
treenodes = createKontaktadresseTreenodes(filterProjektId)
},
view: (vnode) => {
return m("div.w3-container.w3-auto", {
@@ -136,7 +138,11 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", "Kontaktadressen"),
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,
{
@@ -157,15 +163,16 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
}
export const Kontaktadresse: m.ClosureComponent = (vnode) => {
// currentKontaktadresse hält den Formularzustand. projektId referenziert das
// Projekt eindeutig, projektname dient der Anzeige.
// currentKontaktadresse hält den Formularzustand. projekt_id referenziert
// das Projekt eindeutig, projekt_name dient der Anzeige in der Überschrift.
let currentKontaktadresse: {
id: number
projektId: number
projektname: string
projekt_id: number
projekt_name: string
anrede: string
name: string
firma: string
position: string
telefon: string
email: string
strasse: string
@@ -175,11 +182,12 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
notiz: string
} = {
id: -1,
projektId: -1,
projektname: "",
projekt_id: -1,
projekt_name: "",
anrede: "",
name: "",
firma: "",
position: "",
telefon: "",
email: "",
strasse: "",
@@ -188,88 +196,93 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
land: "",
notiz: ""
}
// Ist das Projekt voreingestellt (gefilterte Kontaktadressenliste)?
let projektVoreingestellt: boolean = false
// 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()
if (routeParams) {
const projektId = routeParams.projektId ? parseInt(routeParams.projektId) : -1
projektVoreingestellt = projektId !== -1
currentKontaktadresse = {
id: routeParams.id ? parseInt(routeParams.id) : -1,
projektId: projektId,
projektname: routeParams.projektname || "",
anrede: routeParams.anrede || "",
name: routeParams.name || "",
firma: routeParams.firma || "",
telefon: routeParams.telefon || "",
email: routeParams.email || "",
strasse: routeParams.strasse || "",
plz: routeParams.plz || "",
ort: routeParams.ort || "",
land: routeParams.land || "",
notiz: routeParams.notiz || ""
}
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) => {
currentKontaktadresse = result
}).catch((e) => showServerError(e))
} else {
// Neuanlegen: projekt_id und projekt_name aus Route-Params.
currentKontaktadresse.projekt_id = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) : -1
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)
const projektId = parseInt(formData.get("projektId") as string)
// Projektnamen zur gewählten projektId auflösen.
const projekt = getProjekte().find((p: iProjekt) => p.id === projektId)
const gespeicherteKontaktadresse: iKontaktadresse = {
id: currentKontaktadresse.id,
projektId: projektId,
projektName: projekt ? projekt.projektname : "",
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) || "",
position: (formData.get("position") as string) || "",
telefon: (formData.get("telefon") as string) || "",
email: (formData.get("email") as string) || "",
strasse: (formData.get("strasse") as string) || "",
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) {
gespeicherteKontaktadresse.id = getNewKontaktadresseId()
addKontaktadresse(gespeicherteKontaktadresse)
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateKontaktadresse(gespeicherteKontaktadresse)
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
m.route.set("/kontaktadresseliste")
return true
}
const cancel = (e: Event) => {
m.route.set("/kontaktadresseliste")
}
// Select-Optionen aus der Projektliste aufbauen.
const projektOptionen: iSelectOption[] = getProjekte().map((p: iProjekt) => {
return {value: p.id, label: p.projektname}
})
return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentKontaktadresse.id === -1 ? "Neue Kontaktadresse anlegen" : "Kontaktadresse-Stammblatt"),
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("label", "Projekt",
m(Select, {
name: "projektId",
value: currentKontaktadresse.projektId,
options: projektOptionen,
readonly: projektVoreingestellt,
required: true
} as iSelect)),
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)),
m("label", "Firma", m(Input, {name: "firma", value: currentKontaktadresse.firma, type: "text"} as iInput)),
m("label", "Position", m(Input, {name: "position", value: currentKontaktadresse.position, type: "text"} as iInput)),
m("label", "Telefon", m(Input, {name: "telefon", value: currentKontaktadresse.telefon, type: "tel"} as iInput)),
m("label", "E-Mail", m(Input, {name: "email", value: currentKontaktadresse.email, type: "email"} as iInput)),
m("label", "Straße", m(Input, {name: "strasse", value: currentKontaktadresse.strasse, type: "text"} as iInput)),
// PLZ und Ort in einer Zeile.
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
m("label", {style: {flex: '0 0 120px'}}, "PLZ",
m(Input, {name: "plz", value: currentKontaktadresse.plz, type: "text"} as iInput)),
+68 -41
View File
@@ -2,7 +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 {iKunde, kundenDaten, getNewKundeId, addKunde, updateKunde} from "../models/kunde"
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 = {
@@ -13,21 +16,29 @@ 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()
if (routeParams) {
currentKunde = {
id: routeParams.id ? parseInt(routeParams.id) : -1,
name: routeParams.name || "",
strasse: routeParams.strasse || "",
plz: routeParams.plz || "",
ort: routeParams.ort || "",
land: routeParams.land || ""
}
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.
fetchKundeById(id).then((result) => {
currentKunde = result
}).catch((e) => showServerError(e))
}
},
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
@@ -40,28 +51,28 @@ export const kunde: m.ClosureComponent = (vnode) => {
land: (formData.get("land") as string) || ""
}
if (currentKunde.id === -1) {
gespeicherterKunde.id = getNewKundeId()
addKunde(gespeicherterKunde)
createKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateKunde(gespeicherterKunde)
updateKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
m.route.set("/kundenliste")
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", {kundenId: 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,43 +85,46 @@ export const kunde: m.ClosureComponent = (vnode) => {
}
}
/**
* Erzeugt aus der Kundendatenliste die Treenode-Struktur für die Treetable der
* Kundenliste. Jeder Kunde wird zu einem expandierten Treenode ohne Kinder
* (expand=true, children=[]) - d.h. ein Blattknoten, der sofort in der
* Tabelle sichtbar ist und sich nicht weiter aufklappen lässt.
*/
function createKundenTreenodes(): iTreenode[] {
return kundenDaten.map((kunde: iKunde) => {
return new Treenode(kunde, true, [])
})
}
export const kundenliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = createKundenTreenodes()
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, name: kunde.name, strasse: kunde.strasse, plz: kunde.plz, ort: kunde.ort, land: kunde.land})
// 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", {kundenId: 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", {kundenId: 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, name: "", strasse: "", plz: "", ort: "", land: ""})
// Breadcrumb-Kette um eigene Position erweitern.
m.route.set("/kunde", {id: -1, bc: serializeBc([...breadcrumbs, ownCrumb()])})
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
@@ -133,14 +147,23 @@ export const kundenliste: m.ClosureComponent = (vnode) => {
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let kunde: iKunde = node.nodevalue
let searchLower = searchvalue.toLowerCase()
return kunde.name.toLowerCase().includes(searchLower) ||
kunde.strasse.toLowerCase().includes(searchLower) ||
kunde.plz.includes(searchLower) ||
kunde.ort.toLowerCase().includes(searchLower) ||
kunde.land.toLowerCase().includes(searchLower)
return (kunde.name || "").toLowerCase().includes(searchLower) ||
(kunde.strasse || "").toLowerCase().includes(searchLower) ||
(kunde.plz || "").includes(searchLower) ||
(kunde.ort || "").toLowerCase().includes(searchLower) ||
(kunde.land || "").toLowerCase().includes(searchLower)
}
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[]) => {
treenodes = kunden.map((kunde: iKunde) => new Treenode(kunde, true, []))
}).catch((e) => showServerError(e))
},
view: (vnode) => {
return m("div.w3-container.w3-auto", {
style: {
@@ -149,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,
{
+136 -105
View File
@@ -1,69 +1,62 @@
import m from "mithril"
import {Form, iForm} from "./widgets/form"
import { Input, iInput} from "./widgets/inputs"
import {Select, iSelect, iSelectOption} from "./widgets/select"
import {Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {iProjekt, getProjekteByKundenId, getNewProjektId, addProjekt, updateProjekt} from "../models/projekt"
import {iKunde, getKunden} from "../models/kunde"
function createProjektTreenodes(kundenId: number | null = null): iTreenode[] {
let filteredProjekte = getProjekteByKundenId(kundenId)
return filteredProjekte.map((projekt: iProjekt) => {
return new Treenode(projekt, true, [])
})
}
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[] = createProjektTreenodes()
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
let filterKundenId: number | null = null
// 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,
kundenname: "",
projektname: "",
zweck: "",
verantwortlicher: "",
startdatum: ""
}
const params: any = {id: -1}
// Nur bei kundengefilterter Liste den Kunden vorbelegen.
if (filterKundenId !== null) {
const kunde = getKunden().find((k: iKunde) => k.id === filterKundenId)
if (kunde) {
params.kundenId = kunde.id
params.kundenname = kunde.name
}
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,
kundenId: projekt.kundenId,
kundenname: projekt.kundenName,
projektname: projekt.projektname,
zweck: projekt.zweck,
verantwortlicher: projekt.verantwortlicher,
startdatum: projekt.startdatum
})
// 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", {projektId: 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", {projektId: 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) => {
@@ -71,20 +64,28 @@ 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,
kundenName: projekt.kundenName,
projektId: projekt.id,
projektName: projekt.projektname,
kunden_name: projekt.kunden_name,
projekt_id: projekt.id,
projekt_name: projekt.projektname,
datum: datum,
uhrzeitBeginn: uhrzeit,
uhrzeit_beginn: uhrzeit,
dauer: "",
betreff: "",
nameAnsprechpartner: "",
name_ansprechpartner: "",
telefon: "",
notiz: "",
wiedervorlageDatum: "",
erledigt: false
wiedervorlage_datum: "",
bc: serializeBc(chain)
})
}
@@ -92,7 +93,7 @@ export const projektliste: m.ClosureComponent = (vnode) => {
let projekt: iProjekt = node.nodevalue
return [
m("div", m("strong", projekt.kundenName)),
m("div", m("strong", projekt.kunden_name)),
m("span", projekt.projektname),
m("span", projekt.zweck),
m("span", projekt.verantwortlicher),
@@ -109,22 +110,36 @@ export const projektliste: m.ClosureComponent = (vnode) => {
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let projekt: iProjekt = node.nodevalue
let searchLower = searchvalue.toLowerCase()
return projekt.kundenName.toLowerCase().includes(searchLower) ||
projekt.projektname.toLowerCase().includes(searchLower) ||
projekt.zweck.toLowerCase().includes(searchLower) ||
projekt.verantwortlicher.toLowerCase().includes(searchLower) ||
projekt.startdatum.includes(searchLower)
return (projekt.kunden_name || "").toLowerCase().includes(searchLower) ||
(projekt.projektname || "").toLowerCase().includes(searchLower) ||
(projekt.zweck || "").toLowerCase().includes(searchLower) ||
(projekt.verantwortlicher || "").toLowerCase().includes(searchLower) ||
(projekt.startdatum || "").includes(searchLower)
}
return {
oninit: (vnode) => {
const routeParams = m.route.param()
if (routeParams && routeParams.kundenId) {
filterKundenId = parseInt(routeParams.kundenId)
if (routeParams && routeParams.kunden_id) {
filterKundenId = parseInt(routeParams.kunden_id)
kundenName = routeParams.kunden_name || ""
} else {
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) => {
treenodes = result.map((projekt: iProjekt) => new Treenode(projekt, true, []))
}).catch((e) => showServerError(e))
// Kundennamen asynchron auflösen, falls nicht in Route-Params.
if (filterKundenId !== null && kundenName === "") {
fetchKundeById(filterKundenId).then((kunde: iKunde) => {
kundenName = kunde.name
}).catch((e) => showServerError(e))
}
treenodes = createProjektTreenodes(filterKundenId)
},
view: (vnode) => {
return m("div.w3-container.w3-auto", {
@@ -133,7 +148,11 @@ export const projektliste: m.ClosureComponent = (vnode) => {
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'}},
m("h1", "Projektliste"),
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,
{
@@ -154,97 +173,109 @@ export const projektliste: m.ClosureComponent = (vnode) => {
}
export const Projekt: m.ClosureComponent = (vnode) => {
// currentProjekt hält den Formularzustand. kundenId referenziert den
// Kunden eindeutig, kundenname dient der Anzeige.
// currentProjekt hält den Formularzustand. kunden_id referenziert den
// Kunden eindeutig, kunden_name dient der Anzeige in der Überschrift.
let currentProjekt: {
id: number
kundenId: number
kundenname: string
kunden_id: number
kunden_name: string
projektname: string
zweck: string
verantwortlicher: string
startdatum: string
} = {
id: -1,
kundenId: -1,
kundenname: "",
kunden_id: -1,
kunden_name: "",
projektname: "",
zweck: "",
verantwortlicher: "",
startdatum: ""
}
// Ist der Kunde voreingestellt (gefilterte Projektliste)?
let kundeVoreingestellt: boolean = false
// 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()
if (routeParams) {
const kundenId = routeParams.kundenId ? parseInt(routeParams.kundenId) : -1
kundeVoreingestellt = kundenId !== -1
currentProjekt = {
id: routeParams.id ? parseInt(routeParams.id) : -1,
kundenId: kundenId,
kundenname: routeParams.kundenname || "",
projektname: routeParams.projektname || "",
zweck: routeParams.zweck || "",
verantwortlicher: routeParams.verantwortlicher || "",
startdatum: routeParams.startdatum || ""
}
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.
fetchProjektById(id).then((result) => {
currentProjekt = result
}).catch((e) => showServerError(e))
} else {
// Neuanlegen: kunden_id und kunden_name aus Route-Params
// (voreingestellt aus der Projektliste).
currentProjekt.kunden_id = routeParams && routeParams.kunden_id ? parseInt(routeParams.kunden_id) : -1
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)
const kundenId = parseInt(formData.get("kundenId") as string)
// Kundennamen zur gewählten kundenId auflösen.
const kunde = getKunden().find((k: iKunde) => k.id === kundenId)
const gespeichertesProjekt: iProjekt = {
id: currentProjekt.id,
kundenId: kundenId,
kundenName: kunde ? kunde.name : "",
kunden_id: currentProjekt.kunden_id,
kunden_name: "",
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) {
gespeichertesProjekt.id = getNewProjektId()
addProjekt(gespeichertesProjekt)
createProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateProjekt(gespeichertesProjekt)
updateProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
m.route.set("/projektliste")
return true
}
const cancel = (e: Event) => {
m.route.set("/projektliste")
}
// Select-Optionen aus der Kundenliste aufbauen.
const kundenOptionen: iSelectOption[] = getKunden().map((k: iKunde) => {
return {value: k.id, label: k.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 ? "Neues Projekt anlegen" : "Projekt-Stammblatt"),
m("h1", currentProjekt.id === -1
? "Neues Projekt für " + currentProjekt.kunden_name
: "Projekt-Stammblatt"),
currentProjekt.id !== -1
? m("button.w3-button.w3-theme",
{onclick: () => m.route.set("/kontaktadresseliste", {projektId: currentProjekt.id})},
m("i.material-icons", "contact_page"), " Kontaktadressen")
? [
m("button.w3-button.w3-theme",
{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: (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("label", "Kunde",
m(Select, {
name: "kundenId",
value: currentProjekt.kundenId,
options: kundenOptionen,
readonly: kundeVoreingestellt,
required: true
} as iSelect)),
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))
])
+459
View File
@@ -0,0 +1,459 @@
import m from "mithril"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {ModalView, ModalType, iModalView} from "./widgets/Modal"
import {gridMountpoints} from "./mainlayout"
import {iProjektUser, iProjektUserWithProjekt, fetchProjektUsers, fetchAllProjektUsers, addProjektUserREST, removeProjektUserREST} from "../models/projekt_user"
import {iProjekt, fetchProjektById, fetchProjekte} from "../models/projekt"
import {iUser} from "./auth/models/users"
import {showServerError} from "./auth/authmessages"
/**
* Übersicht "Projekt-Zugriffe": Zeigt alle Projekte als Top-Level-Knoten
* mit ihren jeweils zugeordneten Nutzern als Child-Knoten. Der SysAdmin und der AppAdmin
* können Nutzer zuordnen und entfernen.
*
* Wird die Komponente mit einer projekt_id in den Route-Params aufgerufen,
* zeigt sie nur die Nutzer-Liste für dieses eine Projekt (Detail-Sicht,
* aufgerufen aus der Projekt-Maske heraus).
*/
export const projektuserliste: m.ClosureComponent = () => {
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
let projektId: number = -1
let projektName: string = ""
let projektUsers: iProjektUser[] = []
// Alle Projekte für den Übersichtsmodus.
let projekte: iProjekt[] = []
// Alle Projekt-User-Zuordnungen (inkl. projektname) für den Übersichtsmodus.
let allZuordnungen: iProjektUserWithProjekt[] = []
// True, wenn die Detail-Sicht (Nutzer-Liste für ein Projekt) aktiv ist.
let detailMode: boolean = false
// Alle Nutzer aus der Auth-DB, für das Hinzufügen-Modal.
let allUsers: iUser[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
/**
* Lädt die zugeordneten Nutzer vom Server und aktualisiert die Treenodes.
*/
const loadProjektUsers = () => {
fetchProjektUsers(projektId).then((result) => {
projektUsers = result
treenodes = result.map((pu: iProjektUser) => new Treenode(pu, true, []))
}).catch((e) => showServerError(e))
}
/**
* Lädt alle Nutzer aus der Auth-DB (für das Hinzufügen-Modal).
*/
const loadAllUsers = () => {
m.request({
method: "GET",
url: "/auth/getusers"
}).then((result) => {
allUsers = result as iUser[]
}).catch((e) => showServerError(e))
}
/**
* Entfernt einen Nutzer aus dem Projekt. Wird beim Klick auf den
* Entfernen-Button in der Zeile aufgerufen.
*/
const removeUser = (projektUser: iProjektUser, e: Event) => {
e.stopPropagation()
removeProjektUserREST(projektId, projektUser.user_id).then(() => {
loadProjektUsers()
}).catch((e) => showServerError(e))
}
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let pu: iProjektUser = node.nodevalue
return [
m("span", m("strong", pu.userid)),
m("span", pu.username),
m("span", pu.email),
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap'}},
m("button.w3-button.w3-small.w3-red",
{
title: "Nutzer entfernen",
style: {padding: '0 4px'},
onclick: (e: Event) => removeUser(pu, e)
},
m("i.material-icons", "person_remove"))
)
]
}
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let pu: iProjektUser = node.nodevalue
let searchLower = searchvalue.toLowerCase()
return (pu.userid || "").toLowerCase().includes(searchLower) ||
(pu.username || "").toLowerCase().includes(searchLower) ||
(pu.email || "").toLowerCase().includes(searchLower)
}
/**
* Entfernt einen Nutzer aus einem Projekt (Übersichtsmodus).
*/
const removeUserOverview = (zuordnung: iProjektUserWithProjekt, e: Event) => {
e.stopPropagation()
removeProjektUserREST(zuordnung.projekt_id, zuordnung.user_id).then(() => {
loadOverviewData()
}).catch((e) => showServerError(e))
}
/**
* Columnmapper für Top-Level (Projekt-Zeilen im Übersichtsmodus).
*/
const overviewProjektColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let projekt: iProjekt = node.nodevalue
return [
m("span", m("strong", projekt.projektname)),
m("span", projekt.kunden_name),
m("span.w3-small.w3-opacity", node.children.length + " Nutzer")
]
}
/**
* Columnmapper für Child-Level (Nutzer-Zeilen im Übersichtsmodus).
* Nur Anzeige — keine Buttons.
*/
const overviewNutzerColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let z: iProjektUserWithProjekt = node.nodevalue
return [
m("span", m("strong", z.userid)),
m("span", z.username + " — " + z.email),
m("span", "")
]
}
// Der Treetable verwendet denselben columnmapper für alle Levels.
// Über die nodevalue-Eigenschaft wird zwischen Projekt- und Nutzer-
// Zeilen unterschieden.
const overviewColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
if (node.nodevalue && node.nodevalue.user_id !== undefined) {
return overviewNutzerColumnmapper(node)
}
return overviewProjektColumnmapper(node)
}
const overviewFilterFunction = (node: iTreenode, searchvalue: string): boolean => {
let searchLower = searchvalue.toLowerCase()
let nv = node.nodevalue
if (nv && nv.user_id !== undefined) {
// Nutzer-Zeile: nach userid, username filtern.
return (nv.userid || "").toLowerCase().includes(searchLower) ||
(nv.username || "").toLowerCase().includes(searchLower)
}
// Projekt-Zeile: nach projektname, kunden_name filtern.
// Bei Treffer in Child-Nutzern das Projekt ebenfalls anzeigen.
if ((nv.projektname || "").toLowerCase().includes(searchLower) ||
(nv.kunden_name || "").toLowerCase().includes(searchLower)) {
return true
}
return node.children.some((child) => {
let z = child.nodevalue
return (z.userid || "").toLowerCase().includes(searchLower) ||
(z.username || "").toLowerCase().includes(searchLower)
})
}
/**
* Lädt alle Projekte und alle Zuordnungen und baut den
* Treenode-Baum (Projekt → Nutzer) auf.
*/
const loadOverviewData = () => {
Promise.all([
fetchProjekte(),
fetchAllProjektUsers(),
m.request({method: "GET", url: "/auth/getusers"})
]).then(([projekteResult, zuordnungenResult, usersResult]) => {
projekte = projekteResult as iProjekt[]
allZuordnungen = zuordnungenResult as iProjektUserWithProjekt[]
allUsers = usersResult as iUser[]
buildOverviewTreenodes()
}).catch((e) => showServerError(e))
}
/**
* Baut den Treenode-Baum für den Übersichtsmodus:
* Top-Level = alle Projekte, Child-Nodes = zugeordnete Nutzer.
*/
const buildOverviewTreenodes = () => {
treenodes = projekte.map((p: iProjekt) => {
const nutzerNodes = allZuordnungen
.filter((z) => z.projekt_id === p.id)
.map((z) => new Treenode(z, true, []))
return new Treenode(p, true, nutzerNodes)
})
}
/**
* Öffnet das Modal zum Hinzufügen von Nutzern.
* Im Detail-Modus für das aktuelle Projekt, im Übersichts-Modus
* für das selektierte Projekt.
*/
const openAddModal = () => {
if (allUsers.length === 0) {
loadAllUsers()
}
m.mount(gridMountpoints.modalhook, addUserModal)
}
/**
* Liefert die projekt_id, für die das Add-Modal aktuell gilt:
* im Detail-Modus die Route-Param projektId, im Übersichts-Modus
* die projekt_id der selektierten Projekt-Zeile.
*/
const getModalProjektId = (): number => {
if (detailMode) {
return projektId
}
// Übersichtsmodus: selektierte Projekt-Zeile.
if (selectedNode && selectedNode.nodevalue &&
selectedNode.nodevalue.user_id === undefined) {
return (selectedNode.nodevalue as iProjekt).id
}
return -1
}
/**
* Liefert die bereits zugeordneten Nutzer für das Modal:
* im Detail-Modus projektUsers, im Übersichts-Modus aus
* allZuordnungen gefiltert.
*/
const getModalAssignedIds = (): Set<number> => {
if (detailMode) {
return new Set(projektUsers.map((pu) => pu.user_id))
}
const pid = getModalProjektId()
return new Set(
allZuordnungen
.filter((z) => z.projekt_id === pid)
.map((z) => z.user_id)
)
}
/**
* Modal-Komponente zur Auswahl mehrerer Nutzer, die dem Projekt
* zugeordnet werden sollen. Zeigt eine durchsuchbare Liste mit
* Checkboxen pro noch nicht zugeordnetem Nutzer. Bereits zugeordnete
* Nutzer werden ausgeblendet.
*/
const addUserModal: m.ClosureComponent = () => {
// Menge der ausgewählten user_ids (Checkbox aktiviert).
let selectedIds: Set<number> = new Set()
// Suchtext zum Filtern der Liste.
let search: string = ""
// Verfügbare Nutzer: alle, die noch nicht zugeordnet sind, nicht
// SysAdmin oder AppAdmin sind (diese haben ohnehin immer Zugriff)
// und deren Suchtext-Filter entsprechen. Sortiert nach username.
const getAvailableUsers = (): iUser[] => {
const assignedIds = getModalAssignedIds()
const searchLower = search.toLowerCase()
return allUsers.filter((u: iUser) => {
// SysAdmin und AppAdmin haben immer Zugriff,
// sie brauchen keine projektbezogene Zuordnung.
if (u.isSysAdmin) return false
if (u.isAppAdmin) return false
if (assignedIds.has(u.id)) return false
if (searchLower === "") return true
return (u.userid || "").toLowerCase().includes(searchLower) ||
(u.username || "").toLowerCase().includes(searchLower) ||
(u.email || "").toLowerCase().includes(searchLower)
}).sort((a: iUser, b: iUser) => {
return (a.username || "").localeCompare(b.username || "")
})
}
const closeModal = () => {
m.mount(gridMountpoints.modalhook, null)
}
/**
* Ordnet alle ausgewählten Nutzer dem Projekt zu. Die Aufrufe
* erfolgen parallel; nachdem alle abgeschlossen sind, wird das
* Modal geschlossen und die Liste neu geladen.
*/
const confirmAdd = () => {
if (selectedIds.size === 0) {
closeModal()
return
}
const modalProjektId = getModalProjektId()
const promises: Promise<void>[] = []
selectedIds.forEach((uid: number) => {
promises.push(addProjektUserREST(modalProjektId, uid))
})
Promise.all(promises).then(() => {
closeModal()
if (detailMode) {
loadProjektUsers()
} else {
loadOverviewData()
}
}).catch((e) => {
showServerError(e)
if (detailMode) {
loadProjektUsers()
} else {
loadOverviewData()
}
})
}
/**
* Schaltet die Auswahl für einen Nutzer um.
*/
const toggleUser = (userId: number) => {
if (selectedIds.has(userId)) {
selectedIds.delete(userId)
} else {
selectedIds.add(userId)
}
}
return {
view: (vnode) => {
const availableUsers = getAvailableUsers()
return m(ModalView,
{
title: "Nutzer zuordnen",
type: ModalType.MESSAGE,
width: '500px'
} as iModalView,
m("div.w3-padding-16",
m("p", "Wählen Sie die Nutzer aus, die dem Projekt \"" + projektName + "\" zugeordnet werden sollen:"),
// Suchfeld zum Filtern der Nutzerliste.
m("label", "Suche"),
m("input.w3-input.w3-border",
{
type: "text",
value: search,
placeholder: "Nach userid, Name oder E-Mail suchen",
style: {'margin-bottom': '8px'},
oninput: (e: Event) => {
search = (e.target as HTMLInputElement).value
}
}
),
// Scrollbare Liste mit Checkboxen.
m("div",
{
style: {
'max-height': '300px',
'overflow-y': 'auto',
'margin-top': '8px',
border: '1px solid #ccc'
}
},
availableUsers.length === 0
? m("div.w3-padding-small", "Keine weiteren Nutzer verfügbar")
: availableUsers.map((u: iUser) =>
m("label.w3-block.w3-hover-light-grey.w3-text-left",
{
style: {'border-bottom': '1px solid #eee', padding: '4px 8px'},
onclick: () => toggleUser(u.id)
},
m("input",
{
type: "checkbox",
checked: selectedIds.has(u.id),
style: {'margin-right': '8px'},
onclick: (e: Event) => e.stopPropagation(),
onchange: (e: Event) => toggleUser(u.id)
}
),
m("strong", u.userid),
" (" + u.username + ")",
u.email ? m("span.w3-small.w3-opacity", " — " + u.email) : null
)
)
),
m("div.w3-bar.w3-padding-16",
m("span.w3-small.w3-left",
{style: {'line-height': '36px', 'margin-right': '8px'}},
selectedIds.size + " ausgewählt"),
m("button.w3-button.w3-theme.w3-right",
{style: {'margin-left': '4px'}, onclick: confirmAdd},
"Zuordnen"),
m("button.w3-button.w3-theme.w3-right",
{style: {'margin-left': '4px'}, onclick: closeModal},
"Abbrechen")
)
)
)
}
}
}
return {
oninit: (vnode) => {
const routeParams = m.route.param()
projektId = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) : -1
projektName = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
detailMode = projektId !== -1
if (detailMode) {
// Detail-Sicht: Nutzer-Liste für ein konkretes Projekt.
if (projektName === "") {
fetchProjektById(projektId).then((projekt: iProjekt) => {
projektName = projekt.projektname
}).catch((e) => showServerError(e))
}
loadAllUsers()
loadProjektUsers()
} else {
// Übersichts-Sicht: alle Projekte + Zuordnungen + Nutzer laden.
loadOverviewData()
}
},
view: (vnode) => {
return m("div.w3-container.w3-auto",
{
style: {
display: 'flex',
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'
}
},
m("h1", detailMode
? (projektName
? "Nutzerberechtigungen für Projekt: " + projektName
: "Nutzerberechtigungen")
: "Projekt-Zugriffe — Übersicht"),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
detailMode
? m(Treetable,
{
treenodes: treenodes,
columnmapper: columnmapper,
filter: filterFunction,
onRowMarked: handleRowMarked,
isSelected: (node: iTreenode) => node === selectedNode
} as iTreetable,
m(TopbarButton,
{
onclick: openAddModal,
tooltip: "Nutzer hinzufügen"
} as iTopbarButton,
m("i.material-icons", "person_add")
)
)
: m(Treetable,
{
treenodes: treenodes,
columnmapper: overviewColumnmapper,
filter: overviewFilterFunction,
onRowMarked: handleRowMarked,
isSelected: (node: iTreenode) => node === selectedNode
} as iTreetable
)
)
)
}
}
}
+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,
};
}
}
+313
View File
@@ -0,0 +1,313 @@
import m from "mithril"
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {ModalView, ModalType, iModalView} from "./widgets/Modal"
import {gridMountpoints} from "./mainlayout"
import {iProjektUserWithProjekt, fetchAllProjektUsers, addProjektUserREST, removeProjektUserREST} from "../models/projekt_user"
import {iProjekt, fetchProjekte} from "../models/projekt"
import {iUser} from "./auth/models/users"
import {showServerError} from "./auth/authmessages"
/**
* Übersicht "Nutzer-Zugriffe": Zeigt alle Nutzer (ohne SysAdmin/AppAdmin)
* als Top-Level-Knoten mit ihren jeweils zugeordneten Projekten als
* Child-Knoten. Der AppAdmin kann Projekte zuordnen und entfernen.
*/
export const userprojektliste: m.ClosureComponent = () => {
let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null
// Alle Nutzer aus der Auth-DB.
let allUsers: iUser[] = []
// Alle Projekt-User-Zuordnungen (inkl. projektname).
let allZuordnungen: iProjektUserWithProjekt[] = []
// Alle Projekte (für das Hinzufügen-Modal).
let allProjekte: iProjekt[] = []
const handleRowMarked = (node: iTreenode) => {
selectedNode = node
}
/**
* Lädt alle Nutzer, alle Projekte und alle Zuordnungen und baut
* den Treenode-Baum (Nutzer → Projekte) auf.
*/
const loadData = () => {
Promise.all([
m.request({method: "GET", url: "/auth/getusers"}),
fetchProjekte(),
fetchAllProjektUsers()
]).then(([usersResult, projekteResult, zuordnungenResult]) => {
allUsers = usersResult as iUser[]
allProjekte = projekteResult as iProjekt[]
allZuordnungen = zuordnungenResult as iProjektUserWithProjekt[]
buildTreenodes()
}).catch((e) => showServerError(e))
}
/**
* Baut den Treenode-Baum: Top-Level = Nutzer (ohne SysAdmin/AppAdmin),
* Child-Nodes = zugeordnete Projekte.
*/
const buildTreenodes = () => {
// Nur Nutzer ohne SysAdmin/AppAdmin-Rolle anzeigen.
const nutzer = allUsers.filter((u: iUser) => {
if (u.isSysAdmin) return false
if (u.isAppAdmin) return false
return true
}).sort((a: iUser, b: iUser) => {
return (a.username || "").localeCompare(b.username || "")
})
treenodes = nutzer.map((u: iUser) => {
// Zugeordnete Projekte für diesen Nutzer.
const projektNodes = allZuordnungen
.filter((z) => z.user_id === u.id)
.map((z) => new Treenode(z, true, []))
return new Treenode(u, true, projektNodes)
})
}
/**
* Entfernt ein Projekt von einem Nutzer.
*/
const removeProjekt = (zuordnung: iProjektUserWithProjekt, e: Event) => {
e.stopPropagation()
removeProjektUserREST(zuordnung.projekt_id, zuordnung.user_id).then(() => {
loadData()
}).catch((e) => showServerError(e))
}
/**
* Columnmapper für Top-Level (Nutzer-Zeilen).
*/
const nutzerColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let u: iUser = node.nodevalue
return [
m("span", m("strong", u.userid)),
m("span", u.username),
m("span", u.email),
m("span.w3-small.w3-opacity", node.children.length + " Projekt(e)")
]
}
/**
* Columnmapper für Child-Level (Projekt-Zeilen).
* Nur Anzeige — keine Buttons.
*/
const projektColumnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let z: iProjektUserWithProjekt = node.nodevalue
return [
m("span", m("strong", z.projektname)),
m("span", ""),
m("span", ""),
m("span", "")
]
}
// Der Treetable verwendet pro Level denselben columnmapper. Da Nutzer-
// und Projekt-Zeilen unterschiedlich aussehen, wird über die
// nodevalue-Eigenschaft zwischen ihnen unterschieden.
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
// Wenn die nodevalue eine projekt_id-Eigenschaft hat, ist es
// eine Projekt-Zuordnung (Child-Knoten), sonst ein Nutzer (Top-Level).
if (node.nodevalue && node.nodevalue.projekt_id !== undefined) {
return projektColumnmapper(node)
}
return nutzerColumnmapper(node)
}
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let searchLower = searchvalue.toLowerCase()
let nv = node.nodevalue
if (nv && nv.projekt_id !== undefined) {
// Projekt-Zeile: nach projektname filtern.
return (nv.projektname || "").toLowerCase().includes(searchLower)
}
// Nutzer-Zeile: nach userid, username, email filtern.
// Bei Suche auch Treffer in den Child-Projekten anzeigen.
if ((nv.userid || "").toLowerCase().includes(searchLower) ||
(nv.username || "").toLowerCase().includes(searchLower) ||
(nv.email || "").toLowerCase().includes(searchLower)) {
return true
}
// Prüfen, ob ein Child-Projekt den Suchtext enthält.
return node.children.some((child) => {
let z = child.nodevalue
return (z.projektname || "").toLowerCase().includes(searchLower)
})
}
/**
* Öffnet das Modal zum Hinzufügen von Projekten zu einem Nutzer.
*/
const openAddModal = (user: iUser) => {
m.mount(gridMountpoints.modalhook, {
view: () => m(addProjektModal, {user: user})
})
}
/**
* Modal zum Hinzufügen mehrerer Projekte zu einem Nutzer.
* Zeigt eine durchsuchbare Checkbox-Liste aller Projekte, die dem
* Nutzer noch nicht zugeordnet sind.
*/
const addProjektModal: m.ClosureComponent<{user: iUser}> = () => {
let selectedIds: Set<number> = new Set()
let search: string = ""
const getAvailableProjekte = (): iProjekt[] => {
const assignedIds = new Set(
allZuordnungen
.filter((z) => z.user_id === vnode_userId)
.map((z) => z.projekt_id)
)
const searchLower = search.toLowerCase()
return allProjekte.filter((p: iProjekt) => {
if (assignedIds.has(p.id)) return false
if (searchLower === "") return true
return (p.projektname || "").toLowerCase().includes(searchLower) ||
(p.kunden_name || "").toLowerCase().includes(searchLower)
}).sort((a: iProjekt, b: iProjekt) => {
return (a.projektname || "").localeCompare(b.projektname || "")
})
}
// Die user_id wird in der Closure gehalten, da der Closure-
// Parameter vom Typ m.ClosureComponent ist und attrs nur im
// view/oninit verfügbar sind.
let vnode_userId: number = -1
const closeModal = () => {
m.mount(gridMountpoints.modalhook, null)
}
const confirmAdd = () => {
if (selectedIds.size === 0) {
closeModal()
return
}
const promises: Promise<void>[] = []
selectedIds.forEach((pid: number) => {
promises.push(addProjektUserREST(pid, vnode_userId))
})
Promise.all(promises).then(() => {
closeModal()
loadData()
}).catch((e) => {
showServerError(e)
loadData()
})
}
const toggleProjekt = (projektId: number) => {
if (selectedIds.has(projektId)) {
selectedIds.delete(projektId)
} else {
selectedIds.add(projektId)
}
}
return {
oninit: (vnode) => {
vnode_userId = vnode.attrs.user.id
},
view: (vnode) => {
const user = vnode.attrs.user
const availableProjekte = getAvailableProjekte()
return m(ModalView,
{
title: "Projekt zuordnen",
type: ModalType.MESSAGE,
width: '500px'
} as iModalView,
m("div.w3-padding-16",
m("p", "Wählen Sie die Projekte aus, die dem Nutzer \"" + user.username + "\" zugeordnet werden sollen:"),
m("label", "Suche"),
m("input.w3-input.w3-border",
{
type: "text",
value: search,
placeholder: "Nach Projektname oder Kunde suchen",
style: {'margin-bottom': '8px'},
oninput: (e: Event) => {
search = (e.target as HTMLInputElement).value
}
}
),
m("div",
{
style: {
'max-height': '300px',
'overflow-y': 'auto',
'margin-top': '8px',
border: '1px solid #ccc'
}
},
availableProjekte.length === 0
? m("div.w3-padding-small", "Keine weiteren Projekte verfügbar")
: availableProjekte.map((p: iProjekt) =>
m("label.w3-block.w3-hover-light-grey.w3-text-left",
{
style: {'border-bottom': '1px solid #eee', padding: '4px 8px'},
onclick: () => toggleProjekt(p.id)
},
m("input",
{
type: "checkbox",
checked: selectedIds.has(p.id),
style: {'margin-right': '8px'},
onclick: (e: Event) => e.stopPropagation(),
onchange: (e: Event) => toggleProjekt(p.id)
}
),
m("strong", p.projektname),
" (" + p.kunden_name + ")"
)
)
),
m("div.w3-bar.w3-padding-16",
m("span.w3-small.w3-left",
{style: {'line-height': '36px', 'margin-right': '8px'}},
selectedIds.size + " ausgewählt"),
m("button.w3-button.w3-theme.w3-right",
{style: {'margin-left': '4px'}, onclick: confirmAdd},
"Zuordnen"),
m("button.w3-button.w3-theme.w3-right",
{style: {'margin-left': '4px'}, onclick: closeModal},
"Abbrechen")
)
)
)
}
}
}
return {
oninit: (vnode) => {
loadData()
},
view: (vnode) => {
return m("div.w3-container.w3-auto",
{
style: {
display: 'flex',
'flex-direction': 'column',
height: 'inherit',
'overflow-y': 'inherit'
}
},
m("h1", "Nutzer-Zugriffe — Übersicht"),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable,
{
treenodes: treenodes,
columnmapper: columnmapper,
filter: filterFunction,
onRowMarked: handleRowMarked,
isSelected: (node: iTreenode) => node === selectedNode
} as iTreetable
)
)
)
}
}
}
+118 -24
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) => {
@@ -51,26 +65,80 @@ export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
}
return {
view: (vnode) => {
// Prüfen, ob mindestens ein Top-Level-Node Kinder hat.
// Wird benötigt, um bei kinderlosen Top-Level-Nodes den
// Platzhalter-Pfeil nur dann zu rendern, wenn es Geschwister
// mit Kindern (und damit Pfeil) gibt.
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, {
@@ -80,7 +148,10 @@ export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
columnmapper1: vnode.attrs.columnmapper,
onRowMarked: onRowMarked,
isSelected: isSelected,
isEven: ((z % 2) == 0)} as iTreerow)
isEven: ((z % 2) == 0),
hasSiblingsWithChildren: topHasSiblingsWithChildren,
filter: vnode.attrs.filter,
searchvalue: searchvalue} as iTreerow)
}
else {
return null
@@ -101,6 +172,17 @@ interface iTreerow {
onRowMarked: (node: iTreenode) => void
isSelected?: (node: iTreenode) => boolean
isEven?: boolean
/** True, wenn mindestens ein Geschwister-Node auf demselben Level
* Kinder hat. Wird verwendet, um bei kinderlosen Top-Level-Nodes
* 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) => {
@@ -110,6 +192,10 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
// Zeile selektiert ist; in diesem Fall wird der Hintergrund der
// gesamten <tr>-Zeile eingefärbt.
const rowSelected = vnode.attrs.isSelected ? vnode.attrs.isSelected(vnode.attrs.treenode) : false
// Prüfen, ob mindestens ein Child-Node selbst Kinder hat.
const childHasSiblingsWithChildren = vnode.attrs.treenode.children.some(
(tn: iTreenode) => tn.hasChildren
)
return [
m("tr.w3-border-theme", {onmouseup: () => vnode.attrs.onRowMarked(vnode.attrs.treenode),
onkeyup: () => vnode.attrs.onRowMarked(vnode.attrs.treenode),
@@ -136,9 +222,12 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
// Buttons eingerückt werden, damit die Einrückungs-Level auch bei "kinderlosen" Treenodes
// denen der Trennodes mit Kindern entsprechen
: vnode.attrs.level == 0
? null // Wenn der kinderlos Treenode allerdings sirekt auf Level 0 ist, soll nocht
// eingerückt werden, da es bei Level 0 keine Eltern-Nodes mit Kindern und
// demzufolge mit "expand_more/expand_less"-Button gibt.
? (vnode.attrs.hasSiblingsWithChildren
? m("span", {
style: {'margin-left': '0px',
'margin-right': '32px'}
})
: null)
: m(
"span", {
style: {'margin-left': '24px',
@@ -158,14 +247,19 @@ 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,
columnmapper1: vnode.attrs.columnmapper1,
onRowMarked: vnode.attrs.onRowMarked,
isSelected: vnode.attrs.isSelected,
isEven: ((z % 2) == 0)
isEven: ((z % 2) == 0),
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)
}
+18 -3
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)
@@ -118,10 +123,20 @@ export const Textarea:m.Component<iTextarea> = {
}
export function toDateInputValue(date: Date|string): string {
console.log(date, typeof(date), (date instanceof Date))
let result: string = null
if (typeof date === "string") {
result = date
// String-Datumswerte (aus der DB) auf das vom HTML-Date-Input
// geforderte Format yyyy-MM-dd normalisieren (führende Nullen).
if (date === "") {
result = ""
} else {
const parts = date.split("-")
if (parts.length === 3) {
result = [parts[0], parts[1].padStart(2, "0"), parts[2].padStart(2, "0")].join("-")
} else {
result = date
}
}
} else if (date instanceof Date && date != null) {
result = [date.getFullYear(), (date.getMonth() + 1).toString().padStart(2, "0"), date.getDate().toString().padStart(2, "0")].join("-")
}
+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
)
}
}
}
+87 -28
View File
@@ -5,10 +5,14 @@ import { appmanagementpage } from "./Components/auth/appmanagementpage"
import { changepasswordscreen, loginscreen } from "./Components/auth/login"
import {kunde, kundenliste} from "./Components/kunde"
import { Projekt, projektliste } from "./Components/projekt"
import { projektuserliste } from "./Components/projekt_userliste"
import { userprojektliste } from "./Components/user_projektliste"
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 () {
@@ -62,11 +66,12 @@ function logout() {
console.log(result)
sessionStorage.removeItem("CurrentUser");
sessionStorage.removeItem("CurrentUserIsSysAdmin");
sessionStorage.removeItem("CurrentUserIsAppAdmin");
headerdata.username = "Nicht angemeldet"
if (result['logout'] == true) {m.route.set("/blankpage")}
})
.finally(() => {
location.href = "/index.html"
setTimeout(() => { location.href = "/index.html" }, 1000)
})
}
@@ -83,6 +88,19 @@ const hello: m.Component = {
}
}
const loggedout: m.Component = {
view: () => {
return m("h1", {
style: {
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%"
}
}, "Sie sind nicht mehr angemeldet!")
}
}
const hello2: m.Component = {
view: (vnode) => {
return m("ul",
@@ -100,31 +118,68 @@ const knopf: m.Component = {
return m("button", {onclick: healthcheck}, "Aktualisieren")
}
}
const menu: m.Component = {
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"),
// m("a.w3-button.w3-bar-item", {onclick: healthcheck}, "Aktualisieren"),
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"),
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, [
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",
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.
(sessionStorage.getItem("CurrentUserIsSysAdmin") || sessionStorage.getItem("CurrentUserIsAppAdmin"))
? [
m("button.w3-button.w3-bar-item",
{
onclick: () => {showProjektBerechtigungen = !showProjektBerechtigungen}
},
m("span", showProjektBerechtigungen ? "Projekt-Berechtigungen \u25B2" : "Projekt-Berechtigungen \u25BC")
),
showProjektBerechtigungen
? m("div.w3-bar-block",
m("a.w3-button.w3-bar-item",
{href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/projektuserliste", undefined, e)}},
"Projekt-Zugriffe"),
m("a.w3-button.w3-bar-item",
{href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/userprojektliste", undefined, e)}},
"Nutzer-Zugriffe")
)
: null
]
: null,
)
),
m("div.w3-button.w3-right", {onclick: logout}, "Logout"),
m("div.w3-dropdown-hover.w3-right",
m("button.w3-button", "System"),
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
menuItem("/login", "Login"),
menuItem("/changepassword", "Kennwort ändern"),
menuItem("/usermanagement", "Verwalten"),
)
)
),
m("div.w3-button.w3-right", {onclick: logout}, "Logout"),
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"),
)
)
])
])
}
}
}
@@ -143,8 +198,8 @@ if (check_currentuser() == false) {
m.route.set("/login")
}
m.route(gridMountpoints.main, "/blankpage", {
"/blankpage": hello,
m.route(gridMountpoints.main, "/kundenliste", {
"/blankpage": loggedout,
"/usermanagement": appmanagementpage,
"/changepassword": changepasswordscreen,
"/login": loginscreen,
@@ -152,6 +207,8 @@ m.route(gridMountpoints.main, "/blankpage", {
"/kunde": kunde,
"/projektliste": projektliste,
"/projekt": Projekt,
"/projektuserliste": projektuserliste,
"/userprojektliste": userprojektliste,
"/calllogliste": call_log_liste,
"/call_log": Call_Log,
"/kontaktliste": kontaktliste,
@@ -159,6 +216,8 @@ m.route(gridMountpoints.main, "/blankpage", {
"/kontaktadresseliste": kontaktadresseliste,
"/kontaktadresse": Kontaktadresse,
"/taglist": TagList,
"/tag": Tag
"/tag": Tag,
"/tagesworkflow": TagesWorkflowListe,
"/tagesworkflowkontakt": TagesWorkflowMaske
})
+1 -1
View File
@@ -38,5 +38,5 @@ export function getIniValue(value){
return result["APP"][value]
}
var inifile = readFileSync("../App_Server/root/config.ini").toString()
var inifile = readFileSync("/usr/opt/pythonapp/App_Server/root/config.ini").toString()
result = parseINIString(inifile)
+55 -68
View File
@@ -1,87 +1,53 @@
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
kundenName: string
projektId: number
projektName: string
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
uhrzeitBeginn: string
uhrzeit_beginn: string
dauer: string
betreff: string
nameAnsprechpartner: string
name_ansprechpartner: string
telefon: string
notiz: string
wiedervorlageDatum: string
erledigt: boolean
}
/**
* In-Memory-Datenspeicher für Gesprächsnotizen. Wird als Fallback solange
* verwendet, bis die serverseitigen REST-Endpunkte verfügbar sind.
*/
export const callLogDaten: iCallLog[] = [
{id: 1, kundenName: "Musterfirma GmbH", projektId: 1, projektName: "Website-Relaunch", datum: "2024-01-20", uhrzeitBeginn: "09:30", dauer: "45", betreff: "Anforderungen klären", nameAnsprechpartner: "Herr Schmidt", telefon: "+49 123 45678", notiz: "Kunde möchte responsive Design und neue Farbgestaltung. Budget noch offen.", wiedervorlageDatum: "2024-01-27", erledigt: false},
{id: 2, kundenName: "Musterfirma GmbH", projektId: 1, projektName: "Website-Relaunch", datum: "2024-01-25", uhrzeitBeginn: "14:00", dauer: "30", betreff: "Feedback zum Design", nameAnsprechpartner: "Frau Müller", telefon: "+49 123 45679", notiz: "Design-Entwürfe wurden positiv aufgenommen. Kleine Änderungen an der Navigation nötig.", wiedervorlageDatum: "2024-02-01", erledigt: false},
{id: 3, kundenName: "Beispiel AG", projektId: 2, projektName: "E-Commerce Plattform", datum: "2024-02-10", uhrzeitBeginn: "10:00", dauer: "60", betreff: "Technische Anforderungen", nameAnsprechpartner: "Herr Weber", telefon: "+49 123 45680", notiz: "Payment-Gateway und Versand-Schnittstellen müssen integriert werden. Zeitplan besprochen.", wiedervorlageDatum: "2024-02-15", erledigt: true},
{id: 4, kundenName: "Beispiel AG", projektId: 3, projektName: "CRM-System", datum: "2024-02-18", uhrzeitBeginn: "11:15", dauer: "20", betreff: "Schulungstermin", nameAnsprechpartner: "Frau Bauer", telefon: "+49 123 45681", notiz: "Schulung für 10 Mitarbeiter geplant. Raum noch zu buchen.", wiedervorlageDatum: "2024-02-20", erledigt: false},
{id: 5, kundenName: "Test Unternehmen", projektId: 4, projektName: "Mobile App", datum: "2024-03-05", uhrzeitBeginn: "15:45", dauer: "35", betreff: "Feature-Request", nameAnsprechpartner: "Herr König", telefon: "+49 123 45682", notiz: "Kunde wünscht Push-Benachrichtigungen und Offline-Modus.", wiedervorlageDatum: "2024-03-12", erledigt: false}
]
/**
* Liefert die nächste freie Call-Log-ID auf Basis des In-Memory-Speichers.
* Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion.
*/
export function getNewCallLogId(): number {
if (callLogDaten.length === 0) {
return 1
}
return Math.max(...callLogDaten.map((log: iCallLog) => log.id)) + 1
}
/**
* Fügt eine neue Gesprächsnotiz dem In-Memory-Speicher hinzu.
*/
export function addCallLogData(log: iCallLog): void {
callLogDaten.push(log)
}
/**
* Aktualisiert eine bestehende Gesprächsnotiz im In-Memory-Speicher.
*/
export function updateCallLogData(log: iCallLog): void {
const index = callLogDaten.findIndex((l: iCallLog) => l.id === log.id)
if (index !== -1) {
callLogDaten[index] = log
}
}
/**
* Liefert eine Kopie aller Gesprächsnotizen aus dem In-Memory-Speicher.
*/
export function getCallLogs(): iCallLog[] {
return [...callLogDaten]
}
/**
* Liefert die Gesprächsnotizen eines bestimmten Projekts. Ist projektId null,
* werden alle Gesprächsnotizen zurückgegeben.
*/
export function getCallLogsByProjektId(projektId: number | null): iCallLog[] {
return projektId
? callLogDaten.filter((l: iCallLog) => l.projektId === projektId)
: [...callLogDaten]
wiedervorlage_datum: string
/** Vom Server gelieferte zugeordnete Tags (nur beim Lesen). */
tags?: iTag[]
/** Vom Client zu übermittelnde Tag-IDs (nur beim Schreiben). */
tag_ids?: number[]
}
/* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten. Die Endpunkte sind noch nicht */
/* implementiert und werden später nachgereicht. */
/* serverseitigen REST-Endpunkten (/xhr/calllog/*). */
/* ------------------------------------------------------------------ */
/**
@@ -91,11 +57,19 @@ const CALLLOG_REST_BASE = "/xhr/calllog"
/**
* Lädt die Liste der Gesprächsnotizen vom Server.
* Ist projektId gesetzt (>= 0), werden nur die Notizen dieses Projekts
* geliefert (serverseitige Filterung). Ohne Parameter werden alle
* Notizen geliefert.
*/
export function fetchCallLogs(): Promise<iCallLog[]> {
export function fetchCallLogs(projektId?: number): Promise<iCallLog[]> {
const params: Record<string, any> = {}
if (projektId !== undefined && projektId >= 0) {
params.projekt_id = projektId
}
return m.request({
method: "GET",
url: CALLLOG_REST_BASE + "/list"
url: CALLLOG_REST_BASE + "/list",
params: params
}).then((result) => result as iCallLog[])
}
@@ -110,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.
*/
+17 -70
View File
@@ -2,12 +2,15 @@ import m from "mithril"
/**
* Datenmodell für einen Kontakt (Ansprechpartner beim Kunden).
* Jeder Kontakt ist genau einem Kunden zugeordnet (FK kundenId).
* Jeder Kontakt ist genau einem Kunden zugeordnet (FK kunden_id).
* 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.
*/
export interface iKontakt {
id: number
kundenId: number
kundenName: string
kunden_id: number
kunden_name: string
anrede: string
name: string
position: string
@@ -16,75 +19,11 @@ export interface iKontakt {
notiz: string
}
/**
* In-Memory-Datenspeicher für Kontakte. Wird als Fallback solange verwendet,
* bis die serverseitigen REST-Endpunkte verfügbar sind.
*/
export const kontaktDaten: iKontakt[] = [
{id: 1, kundenId: 1, kundenName: "Musterfirma GmbH", anrede: "Herr", name: "Max Mustermann", position: "Geschäftsführer", telefon: "+49 123 45678", email: "m.mustermann@musterfirma.de", notiz: "Erster Ansprechpartner für das Website-Projekt."},
{id: 2, kundenId: 1, kundenName: "Musterfirma GmbH", anrede: "Frau", name: "Anna Schmidt", position: "Marketingleiterin", telefon: "+49 123 45679", email: "a.schmidt@musterfirma.de", notiz: "Zuständig für Design-Fragen."},
{id: 3, kundenId: 2, kundenName: "Beispiel AG", anrede: "Herr", name: "Peter Müller", position: "IT-Leiter", telefon: "+49 543 21012", email: "p.mueller@beispiel-ag.de", notiz: "Technischer Ansprechpartner."},
{id: 4, kundenId: 3, kundenName: "Test Unternehmen", anrede: "Frau", name: "Lisa Bauer", position: "Projektmanagerin", telefon: "+49 678 90123", email: "l.bauer@test-unternehmen.com", notiz: ""}
]
/**
* Liefert die nächste freie Kontakt-ID auf Basis des In-Memory-Speichers.
* Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion.
*/
export function getNewKontaktId(): number {
if (kontaktDaten.length === 0) {
return 1
}
return Math.max(...kontaktDaten.map((kontakt: iKontakt) => kontakt.id)) + 1
}
/**
* Fügt einen neuen Kontakt dem In-Memory-Speicher hinzu.
*/
export function addKontakt(kontakt: iKontakt): void {
kontaktDaten.push(kontakt)
}
/**
* Aktualisiert einen bestehenden Kontakt im In-Memory-Speicher.
*/
export function updateKontakt(kontakt: iKontakt): void {
const index = kontaktDaten.findIndex((k: iKontakt) => k.id === kontakt.id)
if (index !== -1) {
kontaktDaten[index] = kontakt
}
}
/**
* Liefert eine Kopie aller Kontakte aus dem In-Memory-Speicher.
*/
export function getKontakte(): iKontakt[] {
return [...kontaktDaten]
}
/**
* Liefert die Kontakte eines bestimmten Kunden. Ist kundenId null, werden
* alle Kontakte zurückgegeben.
*/
export function getKontakteByKundenId(kundenId: number | null): iKontakt[] {
return kundenId
? kontaktDaten.filter((k: iKontakt) => k.kundenId === kundenId)
: [...kontaktDaten]
}
/**
* Liefert einen einzelnen Kontakt anhand seiner ID oder undefined.
*/
export function getKontaktById(id: number): iKontakt | undefined {
return kontaktDaten.find((k: iKontakt) => k.id === id)
}
/* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten. Die Endpunkte sind noch nicht */
/* implementiert und werden später nachgereicht. */
/* serverseitigen REST-Endpunkten (/xhr/kontakt/*). */
/* ------------------------------------------------------------------ */
/**
@@ -94,11 +33,19 @@ const KONTAKT_REST_BASE = "/xhr/kontakt"
/**
* Lädt die Kontaktliste vom Server.
* Ist kundenId gesetzt (>= 0), werden nur die Kontakte dieses Kunden
* geliefert (serverseitige Filterung). Ohne Parameter werden alle
* Kontakte geliefert.
*/
export function fetchKontakte(): Promise<iKontakt[]> {
export function fetchKontakte(kundenId?: number): Promise<iKontakt[]> {
const params: Record<string, any> = {}
if (kundenId !== undefined && kundenId >= 0) {
params.kunden_id = kundenId
}
return m.request({
method: "GET",
url: KONTAKT_REST_BASE + "/list"
url: KONTAKT_REST_BASE + "/list",
params: params
}).then((result) => result as iKontakt[])
}
+25 -70
View File
@@ -2,15 +2,24 @@ import m from "mithril"
/**
* Datenmodell für eine Kontaktadresse (Akquise-Ziel innerhalb eines Projekts).
* Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projektId).
* Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projekt_id).
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* 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
projektId: number
projektName: string
projekt_id: number
projekt_name: string
projekt_zweck: string
kunden_name: string
anrede: string
name: string
firma: string
position: string
telefon: string
email: string
strasse: string
@@ -18,77 +27,15 @@ export interface iKontaktadresse {
ort: string
land: string
notiz: string
}
/**
* In-Memory-Datenspeicher für Kontaktadressen. Wird als Fallback solange verwendet,
* bis die serverseitigen REST-Endpunkte verfügbar sind.
*/
export const kontaktadresseDaten: iKontaktadresse[] = [
{id: 1, projektId: 1, projektName: "Website-Relaunch", anrede: "Herr", name: "Thomas Becker", firma: "Becker Webdesign GmbH", telefon: "+49 201 123456", email: "t.becker@becker-webdesign.de", strasse: "Industriestr. 12", plz: "45131", ort: "Essen", land: "Deutschland", notiz: "Interessent für Website-Relaunch, Erstkontakt auf Messe."},
{id: 2, projektId: 1, projektName: "Website-Relaunch", anrede: "Frau", name: "Sandra Klein", firma: "Klein Marketing", telefon: "+49 211 987654", email: "s.klein@klein-marketing.de", strasse: "Königsallee 5", plz: "40212", ort: "Düsseldorf", land: "Deutschland", notiz: ""},
{id: 3, projektId: 2, projektName: "E-Commerce Plattform", anrede: "Herr", name: "Michael Wagner", firma: "Wagner Online Shop", telefon: "+49 221 555123", email: "m.wagner@wagner-shop.de", strasse: "Hauptstr. 88", plz: "50667", ort: "Köln", land: "Deutschland", notiz: "Sucht E-Commerce-Lösung, Budget geklärt."},
{id: 4, projektId: 3, projektName: "CRM-System", anrede: "Frau", name: "Julia Hoffmann", firma: "Hoffmann Consulting", telefon: "+49 89 444333", email: "j.hoffmann@hoffmann-consulting.de", strasse: "Marienplatz 3", plz: "80331", ort: "München", land: "Deutschland", notiz: "Wiedervorlage im nächsten Quartal."}
]
/**
* Liefert die nächste freie Kontaktadressen-ID auf Basis des In-Memory-Speichers.
* Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion.
*/
export function getNewKontaktadresseId(): number {
if (kontaktadresseDaten.length === 0) {
return 1
}
return Math.max(...kontaktadresseDaten.map((ka: iKontaktadresse) => ka.id)) + 1
}
/**
* Fügt eine neue Kontaktadresse dem In-Memory-Speicher hinzu.
*/
export function addKontaktadresse(kontaktadresse: iKontaktadresse): void {
kontaktadresseDaten.push(kontaktadresse)
}
/**
* Aktualisiert eine bestehende Kontaktadresse im In-Memory-Speicher.
*/
export function updateKontaktadresse(kontaktadresse: iKontaktadresse): void {
const index = kontaktadresseDaten.findIndex((k: iKontaktadresse) => k.id === kontaktadresse.id)
if (index !== -1) {
kontaktadresseDaten[index] = kontaktadresse
}
}
/**
* Liefert eine Kopie aller Kontaktadressen aus dem In-Memory-Speicher.
*/
export function getKontaktadressen(): iKontaktadresse[] {
return [...kontaktadresseDaten]
}
/**
* Liefert die Kontaktadressen eines bestimmten Projekts. Ist projektId null,
* werden alle Kontaktadressen zurückgegeben.
*/
export function getKontaktadressenByProjektId(projektId: number | null): iKontaktadresse[] {
return projektId
? kontaktadresseDaten.filter((k: iKontaktadresse) => k.projektId === projektId)
: [...kontaktadresseDaten]
}
/**
* Liefert eine einzelne Kontaktadresse anhand ihrer ID oder undefined.
*/
export function getKontaktadresseById(id: number): iKontaktadresse | undefined {
return kontaktadresseDaten.find((k: iKontaktadresse) => k.id === id)
/** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */
is_erledigt: boolean
}
/* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten. Die Endpunkte sind noch nicht */
/* implementiert und werden später nachgereicht. */
/* serverseitigen REST-Endpunkten (/xhr/kontaktadresse/*). */
/* ------------------------------------------------------------------ */
/**
@@ -98,11 +45,19 @@ const KONTAKTADRESSE_REST_BASE = "/xhr/kontaktadresse"
/**
* Lädt die Kontaktadressenliste vom Server.
* Ist projektId gesetzt (>= 0), werden nur die Kontaktadressen dieses
* Projekts geliefert (serverseitige Filterung). Ohne Parameter werden
* alle Kontaktadressen geliefert.
*/
export function fetchKontaktadressen(): Promise<iKontaktadresse[]> {
export function fetchKontaktadressen(projektId?: number): Promise<iKontaktadresse[]> {
const params: Record<string, any> = {}
if (projektId !== undefined && projektId >= 0) {
params.projekt_id = projektId
}
return m.request({
method: "GET",
url: KONTAKTADRESSE_REST_BASE + "/list"
url: KONTAKTADRESSE_REST_BASE + "/list",
params: params
}).then((result) => result as iKontaktadresse[])
}
+3 -48
View File
@@ -12,57 +12,11 @@ export interface iKunde {
land: string
}
/**
* In-Memory-Datenspeicher für Kunden. Wird als Fallback solange verwendet,
* bis die serverseitigen REST-Endpunkte verfügbar sind.
*/
export const kundenDaten: iKunde[] = [
{id: 1, name: "Musterfirma GmbH", strasse: "Musterstraße 1", plz: "12345", ort: "Musterstadt", land: "Deutschland"},
{id: 2, name: "Beispiel AG", strasse: "Beispielweg 2", plz: "54321", ort: "Beispielstadt", land: "Deutschland"},
{id: 3, name: "Test Unternehmen", strasse: "Testallee 3", plz: "67890", ort: "Testdorf", land: "Österreich"}
]
/**
* Liefert die nächste freie Kunden-ID auf Basis des In-Memory-Speichers.
* Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion.
*/
export function getNewKundeId(): number {
if (kundenDaten.length === 0) {
return 1
}
return Math.max(...kundenDaten.map((kunde: iKunde) => kunde.id)) + 1
}
/**
* Fügt einen neuen Kunden dem In-Memory-Speicher hinzu.
*/
export function addKunde(kunde: iKunde): void {
kundenDaten.push(kunde)
}
/**
* Aktualisiert einen bestehenden Kunden im In-Memory-Speicher.
*/
export function updateKunde(kunde: iKunde): void {
const index = kundenDaten.findIndex((k: iKunde) => k.id === kunde.id)
if (index !== -1) {
kundenDaten[index] = kunde
}
}
/**
* Liefert eine Kopie aller Kunden aus dem In-Memory-Speicher.
*/
export function getKunden(): iKunde[] {
return [...kundenDaten]
}
/* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten. Die Endpunkte sind noch nicht */
/* implementiert und werden später nachgereicht. */
/* serverseitigen REST-Endpunkten (/xhr/kunde/*). */
/* ------------------------------------------------------------------ */
/**
@@ -92,7 +46,8 @@ export function fetchKundeById(id: number): Promise<iKunde> {
}
/**
* Legt einen neuen Kunden serverseitig an.
* Legt einen neuen Kunden serverseitig an. Die ID wird vom Server
* vergeben und im zurückgegebenen Kundenobjekt gesetzt.
*/
export function createKundeREST(kunde: iKunde): Promise<iKunde> {
return m.request({
+20 -69
View File
@@ -2,86 +2,29 @@ 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. 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
kundenId: number
kundenName: string
kunden_id: number
kunden_name: string
projektname: string
zweck: string
verantwortlicher: string
startdatum: string
}
/**
* In-Memory-Datenspeicher für Projekte. Wird als Fallback solange verwendet,
* bis die serverseitigen REST-Endpunkte verfügbar sind.
*/
export const projektDaten: iProjekt[] = [
{id: 1, kundenId: 1, kundenName: "Musterfirma GmbH", projektname: "Website-Relaunch", zweck: "Neugestaltung der Unternehmenswebsite", verantwortlicher: "Max Mustermann", startdatum: "2024-01-15"},
{id: 2, kundenId: 1, kundenName: "Musterfirma GmbH", projektname: "E-Commerce Plattform", zweck: "Online-Shop Entwicklung", verantwortlicher: "Anna Schmidt", startdatum: "2024-03-01"},
{id: 3, kundenId: 2, kundenName: "Beispiel AG", projektname: "CRM-System", zweck: "Kundenbeziehungsmanagement", verantwortlicher: "Peter Müller", startdatum: "2024-02-20"},
{id: 4, kundenId: 3, kundenName: "Test Unternehmen", projektname: "Mobile App", zweck: "iOS und Android Entwicklung", verantwortlicher: "Lisa Bauer", startdatum: "2024-04-10"}
]
/**
* Liefert die nächste freie Projekt-ID auf Basis des In-Memory-Speichers.
* Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion.
*/
export function getNewProjektId(): number {
if (projektDaten.length === 0) {
return 1
}
return Math.max(...projektDaten.map((projekt: iProjekt) => projekt.id)) + 1
}
/**
* Fügt ein neues Projekt dem In-Memory-Speicher hinzu.
*/
export function addProjekt(projekt: iProjekt): void {
projektDaten.push(projekt)
}
/**
* Aktualisiert ein bestehendes Projekt im In-Memory-Speicher.
*/
export function updateProjekt(projekt: iProjekt): void {
const index = projektDaten.findIndex((p: iProjekt) => p.id === projekt.id)
if (index !== -1) {
projektDaten[index] = projekt
}
}
/**
* Liefert eine Kopie aller Projekte aus dem In-Memory-Speicher.
*/
export function getProjekte(): iProjekt[] {
return [...projektDaten]
}
/**
* Liefert die Projekte eines bestimmten Kunden. Ist kundenId null, werden
* alle Projekte zurückgegeben.
*/
export function getProjekteByKundenId(kundenId: number | null): iProjekt[] {
return kundenId
? projektDaten.filter((p: iProjekt) => p.kundenId === kundenId)
: [...projektDaten]
}
/**
* Liefert ein einzelnes Projekt anhand seiner ID oder undefined.
*/
export function getProjektById(id: number): iProjekt | undefined {
return projektDaten.find((p: iProjekt) => p.id === id)
/** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */
is_erledigt: boolean
}
/* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten. Die Endpunkte sind noch nicht */
/* implementiert und werden später nachgereicht. */
/* serverseitigen REST-Endpunkten (/xhr/projekt/*). */
/* ------------------------------------------------------------------ */
/**
@@ -91,11 +34,19 @@ const PROJEKT_REST_BASE = "/xhr/projekt"
/**
* Lädt die Projektliste vom Server.
* Ist kundenId gesetzt (>= 0), werden nur die Projekte dieses Kunden
* geliefert (serverseitige Filterung). Ohne Parameter werden alle
* Projekte geliefert.
*/
export function fetchProjekte(): Promise<iProjekt[]> {
export function fetchProjekte(kundenId?: number): Promise<iProjekt[]> {
const params: Record<string, any> = {}
if (kundenId !== undefined && kundenId >= 0) {
params.kunden_id = kundenId
}
return m.request({
method: "GET",
url: PROJEKT_REST_BASE + "/list"
url: PROJEKT_REST_BASE + "/list",
params: params
}).then((result) => result as iProjekt[])
}
+83
View File
@@ -0,0 +1,83 @@
import m from "mithril"
/**
* Datenmodell für eine Projekt-User-Zuordnung (Sichtbarkeit).
*
* Die Felder id, projekt_id und user_id sind die ORM-Spalten der
* Verknüpfungstabelle 'projekt_users'. Die Felder userid, username und
* email werden serverseitig aus der Auth-Datenbank aufgelöst und im JSON
* ergänzt.
*/
export interface iProjektUser {
id: number
projekt_id: number
user_id: number
userid: string
username: string
email: string
}
/**
* Erweitertes Interface für die Übersichtssicht "Nutzer-Zugriffe".
* Enthält zusätzlich den Projektnamen, der serverseitig aus der
* Projekt-Relationship aufgelöst wird.
*/
export interface iProjektUserWithProjekt extends iProjektUser {
projektname: string
}
/* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten (/xhr/projektuser/*). */
/* ------------------------------------------------------------------ */
/**
* Basis-Pfad der ProjektUser-REST-Endpunkte.
*/
const PROJEKTUSER_REST_BASE = "/xhr/projektuser"
/**
* Lädt die Liste der einem Projekt zugeordneten Nutzer vom Server.
*/
export function fetchProjektUsers(projektId: number): Promise<iProjektUser[]> {
return m.request({
method: "GET",
url: PROJEKTUSER_REST_BASE + "/list",
params: {projekt_id: projektId}
}).then((result) => result as iProjektUser[])
}
/**
* Ordnet einen Nutzer einem Projekt zu.
*/
export function addProjektUserREST(projektId: number, userId: number): Promise<void> {
return m.request({
method: "POST",
url: PROJEKTUSER_REST_BASE + "/add",
body: {projekt_id: projektId, user_id: userId}
}).then(() => undefined)
}
/**
* Entfernt die Zuordnung eines Nutzers zu einem Projekt.
*/
export function removeProjektUserREST(projektId: number, userId: number): Promise<void> {
return m.request({
method: "DELETE",
url: PROJEKTUSER_REST_BASE + "/remove",
params: {projekt_id: projektId, user_id: userId}
}).then(() => undefined)
}
/**
* Lädt alle Projekt-User-Zuordnungen inkl. projektname und Nutzerdaten.
* Nur für AppAdmin/SysAdmin.
*/
export function fetchAllProjektUsers(): Promise<iProjektUserWithProjekt[]> {
return m.request({
method: "GET",
url: PROJEKTUSER_REST_BASE + "/listall"
}).then((result) => result as iProjektUserWithProjekt[])
}
+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
@@ -1,4 +1,7 @@
{
"scripts": {
"build": "tsc && rollup -c rollup.config.mjs"
},
"dependencies": {
"mithril": "^2.2.2"
},
+229
View File
@@ -0,0 +1,229 @@
# Anleitung: Deploy-Prozess lokal auf dem Entwicklungsrechner testen
Diese Anleitung beschreibt, wie der komplette Deploy-Prozess (test-prod
und Prod-Promotion) lokal auf dem Entwicklungsrechner getestet werden kann —
ohne Git-Hook, ohne Remote-Server. Voraussetzung: Docker und Docker Compose
sind auf dem Host installiert (nicht im Dev-Container, sondern auf dem
Betriebssystem des Rechners).
---
## 1. Voraussetzungen
- Docker und Docker Compose auf dem Host installiert
- Git-Repo lokal vorhanden (oder Remote-URL fuer Clone)
- Freie Ports: 8080 (test-prod) und 8081 (prod)
---
## 2. Verzeichnisstruktur anlegen
```bash
mkdir -p /tmp/deploy-test/{test-prod/repo,scripts,test-prod/data,prod/data,test-prod/certs,prod/certs}
```
---
## 3. Repository klonen
```bash
# Entwender vom Remote-Server:
git clone https://git.everdc.eu/chmln-app-dev/kbcrm /tmp/deploy-test/test-prod/repo
# Oder vom lokalen Repo (schneller, kein Netzwerk noetig):
git clone /pfad/zum/kbcrm /tmp/deploy-test/test-prod/repo
```
---
## 4. `.env` fuer Test-Prod anlegen
```bash
cp /tmp/deploy-test/test-prod/repo/env-sample /tmp/deploy-test/test-prod/.env
vi /tmp/deploy-test/test-prod/.env
```
Minimal-Konfiguration fuer lokales Testen (SSL aus, keine Zertifikate noetig):
```bash
# NGINX-Server
NGINX_USE_SSL=false
NGINX_HOST_PORT=8080
NGINX_SERVER_NAME=localhost
# Auch bei SSL=false muessen die Variablen gesetzt sein (Compose bindet den Pfad ein)
NGINX_CERTS=/tmp/deploy-test/test-prod/certs
NGINX_CERT=placeholder
NGINX_CERT_KEY=placeholder
# Python App-Server
PYTHON_DB_DIR=/tmp/deploy-test/test-prod/data
# OIDC-Settings (ggf. anpassen oder Platzhalter fuer lokales Testen)
ZITADEL_ISSUER=https://id.vmlx.de
ZITADEL_CLIENT_ID=local-test
ZITADEL_CLIENT_SECRET=local-test
ZITADEL_REDIRECT_URI=http://localhost:8080/auth/callback
ZITADEL_SCOPES=openid,profile,email
```
---
## 5. `.env` fuer Prod anlegen
```bash
cp /tmp/deploy-test/test-prod/repo/env-sample /tmp/deploy-test/prod/.env
vi /tmp/deploy-test/prod/.env
```
```bash
NGINX_USE_SSL=false
NGINX_HOST_PORT=8081
NGINX_SERVER_NAME=localhost
NGINX_CERTS=/tmp/deploy-test/prod/certs
NGINX_CERT=placeholder
NGINX_CERT_KEY=placeholder
PYTHON_DB_DIR=/tmp/deploy-test/prod/data
ZITADEL_ISSUER=https://id.vmlx.de
ZITADEL_CLIENT_ID=local-test
ZITADEL_CLIENT_SECRET=local-test
ZITADEL_REDIRECT_URI=http://localhost:8081/auth/callback
ZITADEL_SCOPES=openid,profile,email
```
---
## 6. Deploy-Scripts und deploy.env kopieren
```bash
cp /tmp/deploy-test/test-prod/repo/scripts/deploy-kbcrm-test.sh /tmp/deploy-test/scripts/
cp /tmp/deploy-test/test-prod/repo/scripts/deploy-kbcrm-prod.sh /tmp/deploy-test/scripts/
cp /tmp/deploy-test/test-prod/repo/scripts/deploy.env.example /tmp/deploy-test/scripts/deploy.env
chmod +x /tmp/deploy-test/scripts/*.sh
```
`deploy.env` anpassen:
```bash
vi /tmp/deploy-test/scripts/deploy.env
```
```bash
# --- Test-Prod Umgebung ---
DEPLOY_REPO_DIR=/tmp/deploy-test/test-prod/repo
DEPLOY_ENV_FILE=/tmp/deploy-test/test-prod/.env
DEPLOY_PROJECT_NAME=kbptest
DEPLOY_HEALTH_PORT=8080
# --- Prod Umgebung ---
DEPLOY_PROD_COMPOSE_FILE=/tmp/deploy-test/test-prod/repo/docker-compose.prod.yaml
DEPLOY_PROD_ENV_FILE=/tmp/deploy-test/prod/.env
DEPLOY_PROD_PROJECT_NAME=kbpprod
DEPLOY_PROD_TEST_PROJECT_NAME=kbptest
DEPLOY_PROD_HEALTH_PORT=8081
```
---
## 7. Test-Deploy ausfuehren
```bash
/tmp/deploy-test/scripts/deploy-kbcrm-test.sh
```
Das Script:
1. Pullt den aktuellen Code (falls vom Remote geklont) oder nutzt den lokalen Stand
2. Baut beide Images (NGINX mit TypeScript-Build, Python mit Anwendungscode)
3. Startet die Container
4. Fuehrt einen Health-Check durch
Erster Build dauert einige Minuten (Node.js-Stage, npm install, tsc+rollup,
pip install). Folge-Builds sind durch Docker-Layer-Caching schneller.
Health-Check manuell:
```bash
curl http://localhost:8080/xhr/health-check
```
---
## 8. Prod-Promotion testen
Voraussetzung: der Test-Deploy wurde erfolgreich durchgefuehrt — die
Images `kbptest-pythonapp` und `kbptest-nginx-proxy` existieren.
Die Promotion braucht kein eigenes Prod-Repo — die Compose-Datei
wird aus dem test-prod-Repo gelesen.
Dann die Promotion ausfuehren:
```bash
/tmp/deploy-test/scripts/deploy-kbcrm-prod.sh
```
Das Script:
1. Fragt "yes/no" (mit "yes" bestaetigen)
2. Taggt die Images von `kbptest-*` nach `kbpprod-*`
3. Startet die Prod-Container (ohne Rebuild)
4. Health-Check
Health-Check manuell:
```bash
curl http://localhost:8081/xhr/health-check
```
---
## 9. Container pruefen
```bash
# Laufende Container anzeigen
docker compose -p kbptest --env-file /tmp/deploy-test/test-prod/.env \
-f /tmp/deploy-test/test-prod/repo/docker-compose.prod.yaml ps
docker compose -p kbpprod --env-file /tmp/deploy-test/prod/.env \
-f /tmp/deploy-test/test-prod/repo/docker-compose.prod.yaml ps
# Logs anzeigen
docker compose -p kbptest --env-file /tmp/deploy-test/test-prod/.env \
-f /tmp/deploy-test/test-prod/repo/docker-compose.prod.yaml logs
docker compose -p kbpprod --env-file /tmp/deploy-test/prod/.env \
-f /tmp/deploy-test/test-prod/repo/docker-compose.prod.yaml logs
```
---
## 10. Aufraeumen
Nach dem Testen alle Container und Images entfernen:
```bash
# Test-Container stoppen und entfernen
docker compose -p kbptest --env-file /tmp/deploy-test/test-prod/.env \
-f /tmp/deploy-test/test-prod/repo/docker-compose.prod.yaml down
# Prod-Container stoppen und entfernen
docker compose -p kbpprod --env-file /tmp/deploy-test/prod/.env \
-f /tmp/deploy-test/test-prod/repo/docker-compose.prod.yaml down
# Images entfernen
docker rmi kbptest-pythonapp kbptest-nginx-proxy kbpprod-pythonapp kbpprod-nginx-proxy 2>/dev/null
# Verzeichnis loeschen
rm -rf /tmp/deploy-test
```
---
## 11. Typische Probleme
| Problem | Ursache | Loesung |
|---|---|---|
| `docker compose` not found | Docker Compose Plugin fehlt | `docker compose` muss als Plugin installiert sein (nicht `docker-compose`) |
| Port bereits belegt | Anderer Dienst auf 8080/8081 | Anderen Port in `.env` und `deploy.env` setzen |
| `COPY failed` im Build | `.dockerignore` schliesst benoetigte Dateien aus | `.dockerignore` im Repo-Root pruefen |
| Health-Check schlaegt fehl | Container braucht laenger als 10 Sek | `sleep 10` im Script erhoehen oder Logs pruefen |
| OIDC-Login schlaegt fehl | Redirect-URI stimmt nicht | `ZITADEL_REDIRECT_URI` in `.env` an Port anpassen |
| `git fetch` schlaegt fehl | Keine Zugangsdaten konfiguriert | Lokales Repo verwenden oder Credentials einrichten |
+501
View File
@@ -0,0 +1,501 @@
# Anleitung: Automatisches Deployment einrichten
Diese Anleitung richtet sich an den Administrator des Deploy-Servers.
Sie beschreibt, wie der automatische Deploy-Mechanismus fuer KB-CRM
eingerichtet und betrieben wird.
---
## 1. Architektur ueberblick
```
Entwickler
│ git push origin main
▼
┌─────────────────┐
│ Git-Remote │ (z.B. Gitea auf git.everdc.eu)
└────────┬────────┘
│ Webhook oder post-receive Hook
▼
┌─────────────────┐
│ Deploy-Server │
│ (Deploy-Script) │
└────────┬────────┘
│
┌──────────┴──────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Stufe 1: TEST │ │ Stufe 2: PROD │
│ (automatisch) │ │ (manuell) │
└─────────────────┘ └─────────────────┘
```
**Prinzip:**
- Jeder `git push` auf `main` loest automatisch ein Deployment auf dem
Test-Server aus (Stufe 1).
- Nach erfolgreicher Verifikation wird manuell auf den Prod-Server
deployt (Stufe 2).
- In Prod wird der Python-Code in das App-Image kopiert. Der TypeScript-Quellcode
wird waehrend des Docker-Builds in einer Multi-Stage-Build-Stage kompiliert
(tsc + rollup); in das finale NGINX-Image wird nur das fertige JS-Bundle
uebernommen, nicht der TypeScript-Quellcode. Es wird kein Node.js, npm oder
Python-venv auf dem Server benoetigt.
- Der Deploy-Server benoetigt nur: `git`, `docker` und `docker compose`.
---
## 2. Voraussetzungen auf dem Deploy-Server
Folgende Software muss installiert sein:
| Software | Version | Zweck |
|---|---|---|
| `git` | aktuell | Code vom Remote-Repository holen |
| `docker` | aktuell | Container bauen und starten |
| `docker compose` | v2+ | Multi-Container-Orchestrierung |
| `curl` | aktuell | Health-Check nach Deploy |
Docker Compose muss als Plugin installiert sein (Befehl: `docker compose`,
nicht `docker-compose`).
### Git-Zugangsdaten konfigurieren
Das `git clone` und `git fetch` in den Deploy-Scripts nutzen die
Git-Credentials, die auf dem Deploy-Server konfiguriert sind. Die
Zugangsdaten stehen nicht in den Scripts oder Konfigurationsdateien.
Drei Moeglichkeiten:
| Methode | Einrichtung | Wo hinterlegt |
|---|---|---|
| SSH-Key | `git clone git@git.everdc.eu:.../kbcrm` (SSH-URL verwenden) | `~/.ssh/id_rsa` auf dem Deploy-Server |
| Git-Credential-Store | `git config --global credential.helper store` | `~/.git-credentials` auf dem Deploy-Server |
| Deploy-Token (Gitea) | Token in Remote-URL: `https://token:secret@git.everdc.eu/...` | In der `origin`-URL des geklonten Repos |
Empfohlen: SSH-Key oder Deploy-Token. Der Credential-Store speichert
Passwoerter im Klartext und sollte nur fuer Test-Umgebungen verwendet werden.
---
## 3. Verzeichnisstruktur auf dem Deploy-Server
Es werden zwei Umgebungen (test-prod und prod) parallel betrieben:
```
/opt/kbcrm/
├── test-prod/
│ ├── repo/ ← git clone des Repos (einziges Repo auf dem Server)
│ └── .env ← test-prod Umgebung (eigene Ports, eigene DB)
├── prod/
│ └── .env ← prod Umgebung (kein eigenes Repo — Images per docker tag)
└── scripts/
├── deploy-kbcrm-test.sh ← aus dem Repo (scripts/)
├── deploy-kbcrm-prod.sh ← aus dem Repo (scripts/)
└── deploy.env ← Server-spezifische Konfiguration (nicht im Repo)
```
Die SQLite-Datenbanken liegen ausserhalb des Repos in einem bind-mount
(`PYTHON_DB_DIR`), der beim Deploy nicht beruehrt wird.
---
## 4. Einrichtung: Schritt fuer Schritt
### 4.1. Repositories klonen
```bash
sudo mkdir -p /opt/kbcrm/test-prod /opt/kbcrm/prod
# Repository klonen (nur einmal — prod nutzt die Images aus test-prod)
# WICHTIG: git clone wird nur beim ersten Mal ausgefuehrt. Wenn das Verzeichnis
# bereits existiert (z.B. .env wurde bereits abgelegt), schlaegt clone fehl.
# Weitere Updates werden durch das deploy-kbcrm-test.sh Script via
# git fetch + git reset --hard durchgefuehrt — kein manueller clone mehr.
sudo git clone https://git.everdc.eu/chmln-app-dev/kbcrm /opt/kbcrm/test-prod/repo
# Falls das Verzeichnis bereits existiert und aktualisiert werden soll:
# cd /opt/kbcrm/test-prod/repo && git fetch origin main && git reset --hard origin/main
# Oder einfach: /opt/kbcrm/scripts/deploy-kbcrm-test.sh
```
### 4.2. `.env`-Dateien anlegen
Kopieren Sie die Vorlage aus dem Repo und passen Sie die Werte an:
```bash
# Test-Prod
cp /opt/kbcrm/test-prod/repo/env-sample /opt/kbcrm/test-prod/.env
# Prod (Vorlage aus test-prod-Repo)
cp /opt/kbcrm/test-prod/repo/env-sample /opt/kbcrm/prod/.env
```
### 4.3. `.env` fuer Test-Prod anpassen
```bash
vi /opt/kbcrm/test-prod/.env
```
Folgende Einstellungen muessen angepasst werden:
```bash
# WICHTIG: BUILD_ENVIRONMENT muss in den .env-Dateien auf dem Deploy-Server
# NICHT gesetzt werden. Die Deploy-Scripts rufen docker compose explizit
# mit -f docker-compose.prod.yaml auf und ignorieren COMPOSE_FILE.
# Daher kann BUILD_ENVIRONMENT und COMPOSE_FILE aus der .env geloescht
# oder ignoriert werden.
# NGINX-Server
NGINX_USE_SSL=true
NGINX_HOST_PORT=8443 # eigener Port fuer Test-Prod
NGINX_SERVER_NAME=test.kbp_crm.mylocal.de
# NGINX_STATIC_DIR und NGINX_CONFIG_TEMPLATE_DIR werden in Prod nicht
# benoetigt (statische Dateien und Templates sind im Image).
# WICHTIG: Auch bei NGINX_USE_SSL=false duerfen NGINX_CERTS, NGINX_CERT und
# NGINX_CERT_KEY NICHT geloescht werden. docker-compose.base.yaml bindet
# ${NGINX_CERTS} immer als Volume (/etc/certs) ein — ein fehlender Wert
# fuehrt zu einem Compose-Fehler. Bei SSL=false reicht es, Platzhalterwerte
# zu setzen (z.B. NGINX_CERTS auf ein beliebiges Verzeichnis zeigen lassen).
NGINX_CERTS=/opt/kbcrm/test-prod/certs # Pfad zu SSL-Zertifikaten
NGINX_CERT=test-wildcard.mylocal.crt
NGINX_CERT_KEY=test-wildcard.mylocal.key
# Python App-Server
PYTHON_DB_DIR=/opt/kbcrm/test-prod/data # eigene DB fuer Test-Prod
# OIDC-Settings anpassen (ggf. separater OIDC-Client fuer Test)
ZITADEL_ISSUER=https://id.vmlx.de
ZITADEL_CLIENT_ID=<test-client-id>
ZITADEL_CLIENT_SECRET=<test-client-secret>
ZITADEL_REDIRECT_URI=https://test.kbp_crm.mylocal.de/auth/callback
```
### 4.4. `.env` fuer Prod anpassen
```bash
vi /opt/kbcrm/prod/.env
```
```bash
NGINX_HOST_PORT=443 # Produktions-Port
NGINX_SERVER_NAME=kbp_crm.mylocal.de
NGINX_CERTS=/opt/kbcrm/prod/certs
NGINX_CERT=wildcard.mylocal.crt
NGINX_CERT_KEY=wildcard.mylocal.key
# Auch hier gilt: Bei NGINX_USE_SSL=false die Zertifikats-Variablen nicht
# loeschen, sondern Platzhalterwerte stehen lassen (s. Hinweis oben).
PYTHON_DB_DIR=/opt/kbcrm/prod/data # eigene DB fuer Prod
ZITADEL_REDIRECT_URI=https://kbp_crm.mylocal.de/auth/callback
```
### 4.5. Datenbank-Verzeichnisse erstellen
```bash
sudo mkdir -p /opt/kbcrm/test-prod/data
sudo mkdir -p /opt/kbcrm/prod/data
```
### 4.6. SSL-Zertifikate ablegen
Kopieren Sie die Zertifikate in die in `.env` angegebenen Verzeichnisse:
```bash
sudo mkdir -p /opt/kbcrm/test-prod/certs /opt/kbcrm/prod/certs
sudo cp wildcard.mylocal.crt wildcard.mylocal.key /opt/kbcrm/test-prod/certs/
sudo cp wildcard.mylocal.crt wildcard.mylocal.key /opt/kbcrm/prod/certs/
```
### 4.7. Deploy-Scripts kopieren
Die Scripts liegen im Repo unter `scripts/`. Kopieren Sie sie an einen
zentralen Ort:
```bash
sudo mkdir -p /opt/kbcrm/scripts
sudo cp /opt/kbcrm/test-prod/repo/scripts/deploy-kbcrm-test.sh /opt/kbcrm/scripts/
sudo cp /opt/kbcrm/test-prod/repo/scripts/deploy-kbcrm-prod.sh /opt/kbcrm/scripts/
sudo chmod +x /opt/kbcrm/scripts/*.sh
```
### 4.8. `deploy.env` anlegen (Server-spezifische Konfiguration)
Die Deploy-Scripts laden ihre Server-spezifische Konfiguration (Pfade, Ports,
Projekt-Namen) aus einer Datei `deploy.env`, die im gleichen Verzeichnis wie
die Scripts liegen muss. Diese Datei ist **nicht Teil des Repos** und wird
nicht per `git pull` ueberschrieben.
Kopieren Sie die Vorlage und passen Sie die Werte an:
```bash
cp /opt/kbcrm/test-prod/repo/scripts/deploy.env.example /opt/kbcrm/scripts/deploy.env
vi /opt/kbcrm/scripts/deploy.env
```
Inhalt der `deploy.env`:
```bash
# --- Test-Prod Umgebung (fuer deploy-kbcrm-test.sh) ---
DEPLOY_REPO_DIR=/opt/kbcrm/test-prod/repo
DEPLOY_ENV_FILE=/opt/kbcrm/test-prod/.env
DEPLOY_PROJECT_NAME=kbptest
DEPLOY_HEALTH_PORT=8443
# --- Prod Umgebung (fuer deploy-kbcrm-prod.sh) ---
DEPLOY_PROD_COMPOSE_FILE=/opt/kbcrm/test-prod/repo/docker-compose.prod.yaml
DEPLOY_PROD_ENV_FILE=/opt/kbcrm/prod/.env
DEPLOY_PROD_PROJECT_NAME=kbpprod
DEPLOY_PROD_TEST_PROJECT_NAME=kbptest
DEPLOY_PROD_HEALTH_PORT=443
```
Wenn `deploy.env` fehlt, verwenden die Scripts ihre eingebauten Defaults
(die den obigen Werten entsprechen). Die `deploy.env` ist nur noetig, wenn
Ihre Verzeichnisstruktur oder Ports abweichen.
---
## 5. Git-Hook einrichten (automatisches Test-Deploy)
### Variante A: post-receive Hook (direkt auf dem Git-Server)
Wenn Shell-Zugang zum Git-Server besteht:
```bash
# Auf dem Git-Server, im bare Repository:
vi /srv/git/kbcrm.git/hooks/post-receive
```
```bash
#!/bin/bash
while read oldrev newrev ref; do
if [ "$ref" = "refs/heads/main" ]; then
ssh deploy-user@deploy-server "/opt/kbcrm/scripts/deploy-kbcrm-test.sh"
fi
done
```
```bash
chmod +x /srv/git/kbcrm.git/hooks/post-receive
```
### Variante B: Webhook (empfohlen fuer Gitea)
1. In Gitea: Repository-Einstellungen -> Webhooks -> Webhook hinzufuegen
2. URL: `http://deploy-server:9000/webhook` (Beispiel)
3. Content-Type: `application/json`
4. Trigger: "Push Events" (nur auf `main`)
5. Auf dem Deploy-Server: kleiner HTTP-Listener, der den Webhook entgegennimmt
und das Deploy-Script startet.
Beispiel fuer einen einfachen Webhook-Listener (systemd-Service mit Python):
```python
# /opt/kbcrm/scripts/webhook-listener.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import subprocess
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
subprocess.run(["/opt/kbcrm/scripts/deploy-kbcrm-test.sh"])
self.send_response(200)
self.end_headers()
HTTPServer(("", 9000), Handler).serve_forever()
```
Systemd-Service:
```ini
# /etc/systemd/system/kbcrm-webhook.service
[Unit]
Description=KB-CRM Deploy Webhook Listener
After=network.target
[Service]
ExecStart=/usr/bin/python3 /opt/kbcrm/scripts/webhook-listener.py
Restart=always
User=deploy-user
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now kbcrm-webhook
```
---
## 6. Deployment durchfuehren
### 6.1. Automatisches Deploy nach Test-Prod
Wird durch `git push` ausgeloest (sofern Hook eingerichtet). Manuell:
```bash
/opt/kbcrm/scripts/deploy-kbcrm-test.sh
```
Das Script fuehrt folgende Schritte aus:
1. `git pull` (latest main)
2. `docker compose up -d --build` (baut beide Images: NGINX mit TypeScript-
Build und Python mit Anwendungscode)
3. Health-Check
### 6.2. Manuelle Promotion nach Prod (Image-Promotion)
Nach Verifikation auf dem Test-Server:
```bash
/opt/kbcrm/scripts/deploy-kbcrm-prod.sh
```
Das Script fragt eine Bestaetigung ("yes/no") und uebernimmt dann die
getesteten Images aus test-prod per `docker tag` — kein Rebuild, kein git pull.
Prod startet mit exakt dem Stand, der in test-prod verifiziert wurde. Nur
die `.env` unterscheidet sich (Ports, Zertifikate, DB-Pfad, OIDC).
Ablauf:
1. Sicherheitsabfrage ("yes/no")
2. `docker tag` — Images von `kbptest-*` nach `kbpprod-*` kopieren
3. `docker compose up -d` — Container starten (ohne `--build`)
4. Health-Check
**Wichtig:** Das Prod-Script fuehrt kein `git pull` aus. Eine Aenderung an
der `docker-compose.prod.yaml` zwischen test-prod und Promotion wuerde
bedeuten, dass prod mit einer anderen Konfiguration startet als verifiziert.
Bei Aenderungen an der Compose-Datei muss erst ein neues test-prod-Deploy
durchgefuehrt werden.
### 6.3. Alternative: Git-Tag-basierte Promotion
Statt das Prod-Script manuell aufzurufen, kann ein Git-Tag das Prod-Deploy
ausloesen:
```bash
# Auf dem Entwickler-Rechner:
git tag -a v00.00.02 -m "Release: deploy to prod"
git push origin v00.00.02
```
Der `post-receive`-Hook prueft auf Tags:
```bash
#!/bin/bash
while read oldrev newrev ref; do
if [ "$ref" = "refs/heads/main" ]; then
ssh deploy-user@deploy-server "/opt/kbcrm/scripts/deploy-kbcrm-test.sh"
elif [[ "$ref" = refs/tags/v* ]]; then
ssh deploy-user@deploy-server "/opt/kbcrm/scripts/deploy-kbcrm-prod.sh"
fi
done
```
---
## 7. Wartung und Fehlersuche
### Logs anzeigen
```bash
# Test-Prod
docker compose -p kbptest --env-file /opt/kbcrm/test-prod/.env \
-f /opt/kbcrm/test-prod/repo/docker-compose.prod.yaml logs
# Prod
docker compose -p kbpprod --env-file /opt/kbcrm/prod/.env \
-f /opt/kbcrm/test-prod/repo/docker-compose.prod.yaml logs
# Nur Python-Container
docker compose -p kbpprod --env-file /opt/kbcrm/prod/.env \
-f /opt/kbcrm/test-prod/repo/docker-compose.prod.yaml logs pythonapp
# Nur NGINX-Container
docker compose -p kbpprod --env-file /opt/kbcrm/prod/.env \
-f /opt/kbcrm/test-prod/repo/docker-compose.prod.yaml logs nginx-proxy
```
### Container-Status pruefen
```bash
docker compose -p kbpprod --env-file /opt/kbcrm/prod/.env \
-f /opt/kbcrm/test-prod/repo/docker-compose.prod.yaml ps
```
### Manuell in den Container wechseln
```bash
docker compose -p kbpprod --env-file /opt/kbcrm/prod/.env \
-f /opt/kbcrm/test-prod/repo/docker-compose.prod.yaml exec pythonapp bash
```
### Rollback
Bei Problemen kann auf einen frueheren Stand zurueckgekehrt werden:
```bash
# Auf dem Deploy-Server:
cd /opt/kbcrm/test-prod/repo
git log --oneline -10 # frueheren Commit finden
git checkout <commit-hash>
docker compose -p kbpprod --env-file /opt/kbcrm/prod/.env \
-f docker-compose.prod.yaml up -d --build
```
### Datenbank sichern
Die SQLite-Datenbanken liegen im bind-mount (`PYTHON_DB_DIR`).
Sicherung:
```bash
cp /opt/kbcrm/prod/data/*.db /backup/kbcrm-prod-$(date +%Y%m%d).db
```
### Docker-Images aufraeumen
Nach mehreren Deploys koennen alte Images Speicher belegen:
```bash
docker image prune -f
```
---
## 8. Uebersicht: `.env`-Variablen fuer Prod
| Variable | Erforderlich | Beschreibung |
|---|---|---|
| `NGINX_USE_SSL` | ja | SSL aktivieren (true/false) |
| `NGINX_HOST_PORT` | ja | Externer Port (443 fuer SSL, 8080 fuer no-SSL) |
| `NGINX_SERVER_NAME` | ja | Server-Name fuer NGINX-Konfiguration |
| `NGINX_CERTS` | ja | Pfad zu SSL-Zertifikaten (bind-mount). Auch bei SSL=false erforderlich — Compose bindet diesen Pfad immer als Volume ein |
| `NGINX_CERT` | ja | Zertifikatsdatei. Bei SSL=false nicht verwendet, darf aber nicht geloescht werden (Platzhalter reicht) |
| `NGINX_CERT_KEY` | ja | Privater Schluessel. Bei SSL=false nicht verwendet, darf aber nicht geloescht werden (Platzhalter reicht) |
| `NGINX_STATIC_DIR` | nein | Nur in Dev; in Prod im Image |
| `NGINX_CONFIG_TEMPLATE_DIR` | nein | Nur in Dev; in Prod im Image |
| `PYTHON_DB_DIR` | ja | Pfad zu SQLite-Datenbanken (bind-mount) |
| `PYTHON_DEV_DIR` | nein | Nur in Dev |
| `APP_DEBUG` | nein | Nur in Dev; in Prod nicht setzen |
| `BUILD_ENVIRONMENT` | nein | Nicht relevant — Deploy-Scripts geben Compose-Datei explizit an. In .env auf Deploy-Server nicht erforderlich |
| `ZITADEL_*` | ja | OIDC-Konfiguration |
| `COMPOSE_FILE` | nein | Wird von Deploy-Scripts ignoriert (s. `BUILD_ENVIRONMENT`)|
---
## 9. Checkliste fuer den Admin
- [ ] Docker und Docker Compose installiert
- [ ] Git installiert, Repository geklont (test-prod + prod)
- [ ] `.env`-Dateien erstellt und angepasst (Ports, DB, Zertifikate, OIDC)
- [ ] Datenbank-Verzeichnisse erstellt (`PYTHON_DB_DIR`)
- [ ] SSL-Zertifikate abgelegt (`NGINX_CERTS`)
- [ ] Deploy-Scripts kopiert und ausfuehrbar gemacht
- [ ] `deploy.env` angelegt und angepasst (Pfade, Ports, Projekt-Namen)
- [ ] Git-Hook oder Webhook eingerichtet (fuer automatisches Test-Deploy)
- [ ] Erster manueller Deploy-Test: `deploy-kbcrm-test.sh`
- [ ] Health-Check erfolgreich: `curl -k https://localhost:8443/health-check`
- [ ] Erster Prod-Deploy: `deploy-kbcrm-prod.sh`
- [ ] Health-Check erfolgreich: `curl -k https://localhost:443/health-check`
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Wählt das NGINX-Template abhängig von NGINX_USE_SSL aus.
# Kopiert das passende Template aus dem Staging-Mount (read-only) in das
# container-interne Verzeichnis, das vom envsubst-Skript verarbeitet wird.
# Läuft als 10-… vor dem eingebauten 20-envsubst-on-templates.sh.
src_dir="/etc/nginx/configtemplates"
dst_dir="/etc/nginx/templates"
# Falls noch ein Template aus einem vorherigen Start im dst_dir liegt, entfernen.
rm -f "$dst_dir"/kbp_crm.*.conf.template
if [ "$NGINX_USE_SSL" = "false" ]; then
cp "$src_dir/kbp_crm.nossl.conf.template" "$dst_dir/"
else
cp "$src_dir/kbp_crm.ssl.conf.template" "$dst_dir/"
fi
+27 -3
View File
@@ -1,4 +1,28 @@
ARG NGINX_VERSION
FROM nginx:${NGINX_VERSION}
# ── Stage: frontend — baut das TypeScript-JS-Bundle ──────────────
# Node.js, npm und node_modules landen NICHT im Prod-Image.
FROM node:lts-bookworm-slim AS frontend
# Zielverzeichnis fuer rollup-Output erstellen (absoluter Pfad wie in rollup.config.mjs)
RUN mkdir -p /usr/opt/pythonapp/NGINX_Server/html/static
WORKDIR /usr/opt/pythonapp/Client
# Erst package.json + lockfile kopieren (besseres Layer-Caching)
COPY Client/package.json Client/package-lock.json* ./
RUN npm ci || npm install
# Rest des Client-Quellcodes kopieren
COPY Client/ ./
# config.ini wird fuer rollup benoetigt (inireader.mjs liest Version/Name)
COPY App_Server/root/config.ini /usr/opt/pythonapp/App_Server/root/config.ini
# tsc + rollup ausfuehren — Output geht nach /usr/opt/pythonapp/NGINX_Server/html/static/
RUN npm run build
# ── Stage: nginx ────────────────────────────────────────────────
FROM nginx:mainline-alpine
RUN ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime
RUN mkdir /etc/certs
RUN mkdir -p /etc/certs /etc/nginx/templates
COPY NGINX_Server/10-select-ssl.sh /docker-entrypoint.d/10-select-ssl.sh
RUN chmod +x /docker-entrypoint.d/10-select-ssl.sh
# NGINX Config-Templates ins Image kopieren (in Prod; Dev ueberschreibt per bind-mount)
COPY NGINX_Server/configtemplates/ /etc/nginx/configtemplates/
# Alle statischen Dateien aus dem Repo kopieren (CSS, favicon, fonts, logo)
COPY NGINX_Server/html/static/ /usr/share/nginx/html/static/
# JS-Bundle aus der frontend-Stage ueberschreibt ggf. vorhandene alte JS-Dateien
COPY --from=frontend /usr/opt/pythonapp/NGINX_Server/html/static/ /usr/share/nginx/html/static/
@@ -0,0 +1,35 @@
# Zunächst wird ein Default-Server definiert.
# Das Standardverhalten von NGINX ist nämlich wie folgt:
# Wenn im Request ein Server-Name reinkommt und NGINX keinen Server-Block findet, in dem dieser
# Servername definiert wird, wird einfach der erste definierte Serverblock genommen.
# Dadurch, dass ein expliziter Default-Server definiert wird, wird dieser verwendet.
# Falls jetzt also ein Servername übergeben wird, den es nicht gibt, gibt NGINX den Error-Code 404 zurück
server {
listen 4430 default_server;
return 404;
}
server {
listen 4430;
server_name ${NGINX_SERVER_NAME};
resolver 127.0.0.11 valid=30s; # notwendig, damit der Upstream (s.u.) nicht direkt den nginx-Server stoppt, falls pythonapp noch nicht soweit ist
# https://sandro-keil.de/blog/let-nginx-start-if-upstream-host-is-unavailable-or-down/
location /static {
root /usr/share/nginx/html;
}
location / {
# Variable muss benutzt werden (s.o. bei resolver Direktive)
set $upstream pythonapp:8080;
proxy_pass http://$upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /health-check {
add_header Content-Type text/plain;
return 200 "success $time_iso8601";
}
}
+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**. |
+10 -16
View File
@@ -2,31 +2,25 @@ name: kbpbase
services:
nginx-proxy:
build:
context: NGINX_Server
args: # Übergibt die ARGS an das dockerfile
NGINX_VERSION: ${NGINX_VERSION}
context: . # Repo-Root (fuer frontend-Stage im NGINX-Dockerfile)
dockerfile: NGINX_Server/Dockerfile # explizit, da Context nicht mehr NGINX_Server/
ports:
- ${NGINX_HOST_PORT}:4430
volumes:
- ${NGINX_STATIC_DIR}:/usr/share/nginx/html:ro
- ${NGINX_CONFIG_TEMPLATE_DIR}:/etc/nginx/templates
# NGINX_STATIC_DIR entfaellt in Prod — statische Dateien sind im NGINX-Image
# NGINX_CONFIG_TEMPLATE_DIR entfaellt in Prod — Templates sind im Image
# Beide werden in Dev per bind-mount ueberschrieben (siehe docker-compose.dev.yaml)
- ${NGINX_CERTS}:/etc/certs
environment:
- NGINX_USE_SSL=${NGINX_USE_SSL:-true}
- NGINX_CERT=${NGINX_CERT}
- NGINX_CERT_KEY=${NGINX_CERT_KEY}
- NGINX_SERVER_NAME=${NGINX_SERVER_NAME}
pythonapp:
build:
context: App_Server
# target: kommt aus dem prod oder dev dockerfile
args: # Übergibt die ARGS an das dockerfile
PYTHON_VERSION: ${PYTHON_VERSION}
context: . # Repo-Root (fuer COPY-Pfade im Dockerfile)
dockerfile: App_Server/Dockerfile # explizit, da Context nicht mehr App_Server/
volumes:
#- ${PYTHON_APP_DIR}:/usr/opt/pythonapp/root
- ${PYTHON_DB_DIR}:/usr/var/pythonappdb
- python-venv:/usr/opt/pythonapp/env
# - ${PYTHON_ENV_DIR}:/usr/opt/pythonapp/env
volumes:
python-venv:
# python-venv Volume entfaellt — venv ist im Image
# (dev-Stage definiert eigenen Volume in docker-compose.dev.yaml)
+18 -5
View File
@@ -6,14 +6,27 @@ services:
service: pythonapp
build:
target: dev
args: # Übergibt die ARGS an das dockerfile
# PYTHON_VERSION: ${PYTHON_VERSION}
NODE_VERSION: ${NODE_VERSION:-18}
NPM_VERSION: ${NPM_VERSION:-8}
environment:
# Globales Vibe-Home innerhalb des Workspace-Bind-Mounts, damit es auf dem Host persistiert
- VIBE_HOME=/usr/opt/pythonapp/.vibe-global
# Debug-Modus aktivieren — Werkzeug-Reloader statt Waitress
- APP_DEBUG=true
volumes:
- ${PYTHON_DEV_DIR}:/usr/opt/pythonapp
# - ${TYPESCRIPT_SRC_DIR}:/usr/opt/typescript
# python-venv-dev: venv als Volume, da der bind-mount das Image-venv ueberlagert
- python-venv-dev:/usr/opt/pythonapp/env
- node-modules:/usr/opt/typescript/node_modules
nginx-proxy:
extends:
file: docker-compose.base.yaml
service: nginx-proxy
volumes:
# Statische Dateien per bind-mount — Aenderungen (tsc/rollup, CSS) sofort sichtbar
- ${NGINX_STATIC_DIR}:/usr/share/nginx/html:ro
# Config-Templates per bind-mount — Aenderungen an NGINX-Config sofort sichtbar
- ${NGINX_CONFIG_TEMPLATE_DIR}:/etc/nginx/configtemplates:ro
volumes:
python-venv-dev:
node-modules:
+9 -3
View File
@@ -1,4 +1,6 @@
name: kbpprod
# Kein name:-Feld — Projekt-Name wird per -p in den Deploy-Scripts gesetzt
# (kbptest fuer test-prod, kbpprod fuer prod), damit beide Umgebungen
# parallel auf dem gleichen Server laufen koennen.
services:
pythonapp:
extends:
@@ -6,5 +8,9 @@ services:
service: pythonapp
build:
target: prod
volumes:
- ${PYTHON_APP_DIR}:/usr/opt/pythonapp
# Kein Code-bind-mount mehr — Code ist im Image
nginx-proxy:
extends:
file: docker-compose.base.yaml
service: nginx-proxy
# Kein STATIC_DIR bind-mount mehr — statische Dateien sind im NGINX-Image
-26
View File
@@ -1,26 +0,0 @@
services:
nginx-proxy:
build:
context: NGINX_Server
args: # Übergibt die ARGS an das dockerfile
NGINX_VERSION: ${NGINX_VERSION}
ports:
- ${NGINX_HOST_PORT}:4430
volumes:
- ${NGINX_STATIC_DIR}:/usr/share/nginx/html:ro
- ${NGINX_CONFIG_TEMPLATE_DIR}:/etc/nginx/templates
- ${NGINX_CERTS}:/etc/certs
environment:
- NGINX_CERT=${NGINX_CERT}
- NGINX_CERT_KEY=${NGINX_CERT_KEY}
- NGINX_SERVER_NAME=${NGINX_SERVER_NAME}
pythonapp:
build:
context: App_Server
args: # Übergibt die ARGS an das dockerfile
PYTHON_VERSION: ${PYTHON_VERSION}
volumes:
- ${PYTHON_APP_DIR}:/usr/opt/pythonapp/root
- ${PYTHON_DB_DIR}:/usr/var/pythonappdb
# - ${PYTHON_ENV_DIR}:/usr/opt/pythonapp/env
+75
View File
@@ -0,0 +1,75 @@
# =============================================================================
# BUILD-UMGEBUNG (zentrale Einstellung — hier dev/prod umschalten)
# =============================================================================
# Bestimmt, welche docker-compose-*.yaml neben der base-Datei geladen wird.
# COMPOSE_FILE wird von Docker Compose automatisch ausgewertet (auch via .env);
# mehrere Dateien werden durch ':' getrennt und nacheinander gemerged.
BUILD_ENVIRONMENT=dev
COMPOSE_FILE=docker-compose.base.yaml:docker-compose.${BUILD_ENVIRONMENT}.yaml
# Parameter für NGINX-Server
# SSL für NGINX aktivieren (true) oder deaktivieren (false).
# Bei false muss NGINX_HOST_PORT angepasst werden (z.B. 8080 statt 443).
#
# WICHTIG: Auch wenn SSL deaktiviert wird (NGINX_USE_SSL=false), duerfen die
# Zertifikats-Variablen (NGINX_CERTS, NGINX_CERT, NGINX_CERT_KEY) NICHT geloescht
# werden. docker-compose.base.yaml bindet ${NGINX_CERTS} immer als Volume
# (/etc/certs) ein — ein fehlender Wert fuehrt zu einem Compose-Fehler.
# NGINX_CERT und NGINX_CERT_KEY werden ebenfalls immer als Umgebungsvariablen
# an den Container uebergeben; bei SSL=false werden sie nicht verwendet, muessen
# aber als Platzhalter erhalten bleiben. Es reicht, gueltige Platzhalterwerte
# einzutragen (z.B. NGINX_CERTS auf ein beliebiges Verzeichnis zeigen lassen).
NGINX_USE_SSL=true
NGINX_HOST_PORT=443
NGINX_SERVER_NAME=kbp_crm.mylocal.de
# NGINX_STATIC_DIR: nur in Dev benoetigt (bind-mount fuer statische Dateien).
# In Prod liegen die statischen Dateien im NGINX-Image.
NGINX_STATIC_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/NGINX_Server/html
# NGINX_CONFIG_TEMPLATE_DIR: nur in Dev benoetigt (bind-mount fuer NGINX-Templates).
# In Prod liegen die Templates im NGINX-Image.
NGINX_CONFIG_TEMPLATE_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/NGINX_Server/configtemplates
NGINX_CERTS=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/NGINX_Server/certs
NGINX_CERT=wildcard.mylocal.crt
NGINX_CERT_KEY=wildcard.mylocal.key
# Parameter for Python App-Server
# PYTHON_APP_DIR entfaellt — Code wird in Prod ins Image gebaut (Option B).
# In Dev wird PYTHON_DEV_DIR als bind-mount verwendet (siehe docker-compose.dev.yaml).
PYTHON_DEV_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm
PYTHON_DB_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/App_Server/data
# PYTHON_ENV_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/App_Server/env
# =============================================================================
# ZITADEL OIDC SETTINGS
# =============================================================================
# Zitadel Issuer URL (OIDC Provider)
# Example: https://your-zitadel-instance.zitadel.cloud
ZITADEL_ISSUER=https://id.vmlx.de
# OIDC Client ID from Zitadel
ZITADEL_CLIENT_ID=###client##
# OIDC Client Secret from Zitadel # not for PKCE
ZITADEL_CLIENT_SECRET=####secret ###
# Redirect URI for OIDC callback
# Default: http://localhost:5000/auth/callback
#ZITADEL_REDIRECT_URI=http://localhost:8000/auth/callback
ZITADEL_REDIRECT_URI=https://kbp_crm.mylocal.de/auth/callback
# OIDC Scopes (comma-separated)
ZITADEL_SCOPES=openid,profile,email
# =============================================================================
# EMAIL SETTINGS (for notifications)
# =============================================================================
# SMTP Server
# SMTP_SERVER=localhost
# SMTP_PORT=587
# SMTP_USERNAME=
# SMTP_PASSWORD=
# SMTP_USE_TLS=true
# EMAIL_FROM=formeleon@yourdomain.com
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
# Deploy-Script: Promotion nach PROD (manuelles OK erforderlich)
# Wird manuell aufgerufen, nachdem test-prod verifiziert wurde.
# Uebernimmt die getesteten Images aus test-prod per docker tag (kein Rebuild,
# kein git pull). Die Compose-Datei und .env werden nicht aktualisiert —
# prod startet mit exakt dem Stand, der in test-prod verifiziert wurde.
# Voraussetzungen auf dem Server: git, docker, docker compose.
#
# Server-spezifische Konfiguration (Pfade, Ports, Projekt-Namen) wird aus
# einer Datei deploy.env geladen, die im gleichen Verzeichnis wie dieses
# Script liegen muss. Diese Datei ist NICHT Teil des Repos und wird nicht
# per git pull ueberschrieben. Die Defaults unten greifen, falls deploy.env
# fehlt oder einzelne Werte nicht setzt.
set -euo pipefail
# === Script-Verzeichnis ermitteln (fuer deploy.env) ===
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# === Server-Konfiguration laden (ausserhalb des Repos, nicht per git pull ueberschreibbar) ===
DEPLOY_CONFIG="$SCRIPT_DIR/deploy.env"
if [ -f "$DEPLOY_CONFIG" ]; then
source "$DEPLOY_CONFIG"
fi
# === Defaults — werden verwendet, falls deploy.env fehlt oder Werte nicht setzt ===
# COMPOSE_FILE verweist auf das test-prod-Repo — die Compose-Datei wurde
# dort verifiziert und soll in prod identisch sein. Prod hat kein eigenes Repo.
COMPOSE_FILE="${DEPLOY_PROD_COMPOSE_FILE:-/opt/kbcrm/test-prod/repo/docker-compose.prod.yaml}"
ENV_FILE="${DEPLOY_PROD_ENV_FILE:-/opt/kbcrm/prod/.env}"
PROJECT_NAME="${DEPLOY_PROD_PROJECT_NAME:-kbpprod}"
TEST_PROJECT_NAME="${DEPLOY_PROD_TEST_PROJECT_NAME:-kbptest}"
HEALTH_PORT="${DEPLOY_PROD_HEALTH_PORT:-443}"
# === NGINX_SERVER_NAME aus .env laden (fuer Health-Check Host-Header) ===
# Die .env wird per --env-file an Docker Compose uebergeben, aber die Variablen
# stehen nicht als Shell-Variablen im Script zur Verfuegung. Daher laden wir
# NGINX_SERVER_NAME manuell, damit der Health-Check den richtigen Host-Header
# sendet (sonst liefert NGINX 404 wegen server_name-Mismatch).
NGINX_SERVER_NAME=""
if [ -f "$ENV_FILE" ]; then
NGINX_SERVER_NAME=$(grep -E "^NGINX_SERVER_NAME=" "$ENV_FILE" | cut -d= -f2- | tr -d '\r\n')
fi
echo "=== Deploy KB-CRM PROD ==="
# Sicherheitsabfrage — nur bei manueller Eingabe von "yes" wird fortgesetzt
echo "This will deploy to PRODUCTION. Continue? (yes/no)"
read -r confirmation
if [ "$confirmation" != "yes" ]; then
echo "Aborted."
exit 1
fi
# 1. Getestete Images aus test-prod uebernehmen (kein Rebuild, kein git pull)
# docker tag erzeugt eine zusaetzliche Referenz auf das gleiche Image —
# die Original-Tags (kbptest-*) bleiben unangetastet.
echo "=== Promoting tested images from test-prod to prod ==="
docker tag "${TEST_PROJECT_NAME}-pythonapp" "${PROJECT_NAME}-pythonapp"
docker tag "${TEST_PROJECT_NAME}-nginx-proxy" "${PROJECT_NAME}-nginx-proxy"
# 2. Container starten (ohne --build — Images wurden per tag uebernommen)
# -p setzt den Projekt-Namen (fuer Container-Namen)
# --env-file laedt die Container-Variablen (Ports, Zertifikate, DB, OIDC)
# -f gibt die Compose-Datei explizit an (ignoriert COMPOSE_FILE aus .env)
echo "=== Starting prod containers ==="
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" \
-f "$COMPOSE_FILE" up -d
# 3. Health-Check
echo "=== Waiting for container startup ==="
sleep 10
if curl -sf -H "Host: ${NGINX_SERVER_NAME}" "http://localhost:${HEALTH_PORT}/xhr/health-check" >/dev/null 2>&1; then
echo "Health check: OK"
else
echo "WARNING: Health check failed — check container logs with:"
echo " docker compose -p $PROJECT_NAME --env-file $ENV_FILE -f $COMPOSE_FILE logs"
fi
echo "=== Deploy PROD complete ==="
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
# Deploy-Script: Automatisches Deployment nach TEST (test-prod)
# Wird durch Git-Hook/Webhook oder manuell aufgerufen.
# Voraussetzungen auf dem Server: git, docker, docker compose.
#
# Server-spezifische Konfiguration (Pfade, Ports, Projekt-Namen) wird aus
# einer Datei deploy.env geladen, die im gleichen Verzeichnis wie dieses
# Script liegen muss. Diese Datei ist NICHT Teil des Repos und wird nicht
# per git pull ueberschrieben. Die Defaults unten greifen, falls deploy.env
# fehlt oder einzelne Werte nicht setzt.
set -euo pipefail
# === Script-Verzeichnis ermitteln (fuer deploy.env) ===
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# === Server-Konfiguration laden (ausserhalb des Repos, nicht per git pull ueberschreibbar) ===
DEPLOY_CONFIG="$SCRIPT_DIR/deploy.env"
if [ -f "$DEPLOY_CONFIG" ]; then
source "$DEPLOY_CONFIG"
fi
# === Defaults — werden verwendet, falls deploy.env fehlt oder Werte nicht setzt ===
REPO_DIR="${DEPLOY_REPO_DIR:-/opt/kbcrm/test-prod/repo}"
COMPOSE_FILE=docker-compose.prod.yaml
ENV_FILE="${DEPLOY_ENV_FILE:-/opt/kbcrm/test-prod/.env}"
PROJECT_NAME="${DEPLOY_PROJECT_NAME:-kbptest}"
HEALTH_PORT="${DEPLOY_HEALTH_PORT:-8443}"
# === NGINX_SERVER_NAME aus .env laden (fuer Health-Check Host-Header) ===
# Die .env wird per --env-file an Docker Compose uebergeben, aber die Variablen
# stehen nicht als Shell-Variablen im Script zur Verfuegung. Daher laden wir
# NGINX_SERVER_NAME manuell, damit der Health-Check den richtigen Host-Header
# sendet (sonst liefert NGINX 404 wegen server_name-Mismatch).
NGINX_SERVER_NAME=""
if [ -f "$ENV_FILE" ]; then
NGINX_SERVER_NAME=$(grep -E "^NGINX_SERVER_NAME=" "$ENV_FILE" | cut -d= -f2- | tr -d '\r\n')
fi
echo "=== Deploy KB-CRM TEST ==="
# 1. Code aktualisieren
cd "$REPO_DIR"
echo "=== Pulling latest code ==="
git fetch origin main
git checkout main
git reset --hard origin/main
# 2. Images neu bauen und Container starten
# -p setzt den Projekt-Namen (fuer Image-Namen und Container-Namen)
# --env-file laedt die Container-Variablen (Ports, Zertifikate, DB, OIDC)
# -f gibt die Compose-Datei explizit an (ignoriert COMPOSE_FILE aus .env)
echo "=== Building and starting containers ==="
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" \
-f "$COMPOSE_FILE" up -d --build
# 3. Health-Check
echo "=== Waiting for container startup ==="
sleep 10
if curl -sf -H "Host: ${NGINX_SERVER_NAME}" "http://localhost:${HEALTH_PORT}/xhr/health-check" >/dev/null 2>&1; then
echo "Health check: OK"
else
echo "WARNING: Health check failed — check container logs with:"
echo " docker compose -p $PROJECT_NAME --env-file $ENV_FILE -f $COMPOSE_FILE logs"
fi
echo "=== Deploy TEST complete ==="
+23
View File
@@ -0,0 +1,23 @@
# =============================================================================
# deploy.env — Server-spezifische Konfiguration fuer die Deploy-Scripts
# =============================================================================
# Diese Datei liegt NICHT im Git-Repo und wird NICHT per git pull ueberschrieben.
# Kopieren Sie diese Vorlage nach deploy.env (im gleichen Verzeichnis wie die
# Scripts) und passen Sie die Werte an Ihre Server-Umgebung an.
#
# Wenn deploy.env fehlt, verwenden die Scripts ihre eingebauten Defaults.
# =============================================================================
# --- Test-Prod Umgebung (fuer deploy-kbcrm-test.sh) ---
DEPLOY_REPO_DIR=/opt/kbcrm/test-prod/repo
DEPLOY_ENV_FILE=/opt/kbcrm/test-prod/.env
DEPLOY_PROJECT_NAME=kbptest
DEPLOY_HEALTH_PORT=8443
# --- Prod Umgebung (fuer deploy-kbcrm-prod.sh) ---
# COMPOSE_FILE muss auf das test-prod-Repo verweisen (prod hat kein eigenes Repo)
DEPLOY_PROD_COMPOSE_FILE=/opt/kbcrm/test-prod/repo/docker-compose.prod.yaml
DEPLOY_PROD_ENV_FILE=/opt/kbcrm/prod/.env
DEPLOY_PROD_PROJECT_NAME=kbpprod
DEPLOY_PROD_TEST_PROJECT_NAME=kbptest
DEPLOY_PROD_HEALTH_PORT=443