Compare commits
48
Commits
9ed5e88e01
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc9b337696 | ||
|
|
2ff4272ecd | ||
|
|
70f6101761 | ||
|
|
f6588dec79 | ||
|
|
a23884bf02 | ||
|
|
6e3cc7375d | ||
|
|
929664453e | ||
|
|
32afb35840 | ||
|
|
4d8febcc3f | ||
|
|
3ba9b0a07f | ||
|
|
c96ed6e97d | ||
|
|
981b28df03 | ||
|
|
3a6732e36b | ||
|
|
977b64292c | ||
|
|
24b492c5c1 | ||
|
|
4e721e9a47 | ||
|
|
5a39c64880 | ||
|
|
6f86bc9808 | ||
|
|
1d8201f46a | ||
|
|
d7e5c014a8 | ||
|
|
a67921ec74 | ||
|
|
fd30049a32 | ||
|
|
f1e5938ae1 | ||
|
|
d30adf1579 | ||
|
|
5abcb2d96c | ||
|
|
675d7775c1 | ||
|
|
b44ea806ef | ||
|
|
3361ef16dd | ||
|
|
947f299543 | ||
|
|
44c411e5d7 | ||
|
|
efef212544 | ||
|
|
e824b545bb | ||
|
|
bde45871f6 | ||
|
|
4015f83ba0 | ||
|
|
fffa9d4a28 | ||
|
|
b268b301c1 | ||
|
|
40ad0e1ba9 | ||
|
|
3b3e2893e5 | ||
|
|
31ef206bd8 | ||
|
|
b1795e2f41 | ||
|
|
9ee0433c4d | ||
|
|
55d03ddeb3 | ||
|
|
b6268f7d22 | ||
|
|
bdb4329240 | ||
|
|
96c9ceafc1 | ||
|
|
ef563bad2f | ||
|
|
a6876fcc55 | ||
|
|
c1c92d1169 |
No files matched your search
@@ -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.
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
"ms-python.python",
|
"ms-python.python",
|
||||||
"ms-python.vscode-pylance",
|
"ms-python.vscode-pylance",
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"esbenp.prettier-vscode"
|
"esbenp.prettier-vscode",
|
||||||
|
"mistralai.mistral-vibe-code"
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"python.defaultInterpreterPath": "/usr/opt/pythonapp/env/bin/python",
|
"python.defaultInterpreterPath": "/usr/opt/pythonapp/env/bin/python",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.git/
|
||||||
|
Client/node_modules/
|
||||||
|
Client/tsout/
|
||||||
|
App_Server/data/
|
||||||
|
App_Server/env/
|
||||||
|
oidc-test/
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
@@ -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
@@ -1,6 +1,11 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
env
|
.env
|
||||||
node_modules
|
env/
|
||||||
tsout
|
node_modules/
|
||||||
|
tsout/
|
||||||
kbp_crm*.js
|
kbp_crm*.js
|
||||||
|
kbcrm/App_Server/data
|
||||||
App_Server/data/
|
App_Server/data/
|
||||||
|
oidc-test/
|
||||||
|
.vibe/
|
||||||
|
.vibe-global/
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
---
|
|
||||||
name: client-build
|
|
||||||
description: Load this skill after modifying TypeScript files under Client/ to compile and bundle the frontend. Runs tsc and rollup to produce the deployable JS bundle in NGINX_Server/html/static/.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Client Build
|
|
||||||
|
|
||||||
Führt den TypeScript- und Rollup-Build für die Client-Seite aus. Wird benötigt,
|
|
||||||
nachdem `.ts`-Dateien unter `Client/` geändert wurden, damit die kompilierten
|
|
||||||
Bundles in `NGINX_Server/html/static/` aktualisiert werden.
|
|
||||||
|
|
||||||
## Build-Schritte
|
|
||||||
|
|
||||||
1. **TypeScript kompilieren** — kompiliert alle `.ts`-Dateien nach `Client/tsout/`:
|
|
||||||
```bash
|
|
||||||
cd /usr/opt/pythonapp/Client && npx tsc
|
|
||||||
```
|
|
||||||
Bei Fehlern: Ausgabe lesen, Fehler beheben, erneut ausführen.
|
|
||||||
|
|
||||||
2. **Rollup bundle** — bündelt `tsout/index.js` zu den statischen JS-Dateien:
|
|
||||||
```bash
|
|
||||||
cd /usr/opt/pythonapp/Client && npx rollup -c
|
|
||||||
```
|
|
||||||
`rollup -c` liest die Konfiguration aus `rollup.config.mjs`. Es fasst alle
|
|
||||||
kompilierten `.js`-Dateien plus Mithril zu einem selbstausführenden Bundle
|
|
||||||
(Format: iife) zusammen und schreibt zwei Dateien nach
|
|
||||||
`NGINX_Server/html/static/`:
|
|
||||||
- `kbp_crm_00.00.01.js` — unminifiziert (für Debug)
|
|
||||||
- `kbp_crm_00.00.01.min.js` — minifiziert via terser (für Produktion)
|
|
||||||
|
|
||||||
Die Dateinamen setzen sich aus `projectname` und `version` aus `config.ini`
|
|
||||||
zusammen (wird in `rollup.config.mjs` via `inireader.mjs` gelesen).
|
|
||||||
|
|
||||||
## Beide Schritte in einem Aufruf
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /usr/opt/pythonapp/Client && npx tsc && npx rollup -c
|
|
||||||
```
|
|
||||||
|
|
||||||
## Wann ausführen
|
|
||||||
|
|
||||||
- Nach jeder Änderung an `.ts`-Dateien unter `Client/` (Modelle, Komponenten,
|
|
||||||
Widgets, index.ts)
|
|
||||||
- Vor einem Commit, der Client-Änderungen enthält, damit die deploybaren
|
|
||||||
Bundles aktuell sind
|
|
||||||
- Nicht nötig bei reinen Server-Änderungen (`App_Server/`)
|
|
||||||
|
|
||||||
## Fehlerbehandlung
|
|
||||||
|
|
||||||
- **tsc-Fehler:** TypeScript-Compiler-Fehler müssen vor dem Rollup-Schritt
|
|
||||||
behoben werden — Rollup kann nicht ohne erfolgreiche Kompilierung bündeln.
|
|
||||||
- **rollup-Fehler:** Meist fehlende Imports oder Pfadprobleme. Die
|
|
||||||
`rollup.config.mjs` erwartet `tsout/index.js` als Input — sicherstellen,
|
|
||||||
dass tsc erfolgreich war.
|
|
||||||
@@ -5,12 +5,12 @@ In den Programmen sollen aussagekräftige Kommentare in deutsch erstellt werden.
|
|||||||
## Vorgaben git Versionsverwaltung
|
## Vorgaben git Versionsverwaltung
|
||||||
Ein neuer Branch darf nur auf ausdrückliche Anweisung hin erstellt werden. Ohne diese Anweisung 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
|
## Vorgaben für die Typescript und mithril.js 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.
|
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.
|
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
|
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:
|
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
|
```typescript
|
||||||
@@ -25,7 +25,7 @@ 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.
|
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.
|
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.
|
||||||
@@ -64,6 +64,8 @@ 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.
|
- `_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.
|
- `_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:
|
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.
|
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.
|
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.
|
||||||
@@ -72,3 +74,8 @@ Bei neuen projektbezogenen Entitäten muss:
|
|||||||
|
|
||||||
## Generierte Dateien nicht beachten
|
## 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.
|
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
@@ -1,7 +1,8 @@
|
|||||||
ARG PYTHON_VERSION
|
# ── Stage: prod ──────────────────────────────────────────────────
|
||||||
ARG NODE_VERSION
|
# Build-Context ist das Repo-Root (.), daher muessen alle COPY-Pfade
|
||||||
ARG NPM_VERSION
|
# mit App_Server/ praefigiert werden.
|
||||||
FROM python:${PYTHON_VERSION} AS prod
|
# 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 ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime
|
||||||
RUN apt-get clean && apt-get update && apt-get install -y locales
|
RUN apt-get clean && apt-get update && apt-get install -y locales
|
||||||
RUN echo "de_DE.UTF-8 UTF-8" >> /etc/locale.gen
|
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
|
RUN mkdir -p /usr/opt/pythonapp/App_Server/root /usr/opt/pythonapp/env /usr/var/pythonappdata
|
||||||
WORKDIR /usr/opt/pythonapp
|
WORKDIR /usr/opt/pythonapp
|
||||||
RUN python -m venv --upgrade-deps /usr/opt/pythonapp/env
|
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
|
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" ]
|
# Python-Anwendungscode ins Image kopieren (inkl. templates/index.html)
|
||||||
# ENTRYPOINT [ "sh", "/init.sh" ]
|
COPY App_Server/root/ /usr/opt/pythonapp/App_Server/root/
|
||||||
# CMD []
|
|
||||||
|
|
||||||
|
# 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
|
FROM prod AS dev
|
||||||
ARG NODE_VERSION
|
# Node.js-Version fest im Dockerfile verankert.
|
||||||
ARG NPM_VERSION
|
|
||||||
|
|
||||||
# --- 2. Systemabhängigkeiten (für Python + Node.js) ---
|
# --- 2. Systemabhängigkeiten (für Python + Node.js) ---
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
@@ -41,19 +45,14 @@ RUN apt-get update && \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# --- 3. Node.js + npm installieren ---
|
# --- 3. Node.js + npm installieren ---
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
|
||||||
apt-get install -y nodejs && \
|
apt-get install -y nodejs && \
|
||||||
npm install -g npm@${NPM_VERSION}
|
npm install -g npm@latest
|
||||||
|
|
||||||
# --- 8. Arbeitsverzeichnis für TypeScript ---
|
# --- 4. Dev-Start-Script kopieren (fuer Konsistenz, wird sonst per bind-mount bereitgestellt) ---
|
||||||
# In dem Entwicklungsverzeichnis (PYTHON_DEV_DIR) existiert dies schon als Unterverzeichnis "Client", daher wird es hier nicht erstellt
|
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"]
|
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"]
|
||||||
|
|
||||||
@@ -4,15 +4,15 @@ from settings import Configuration
|
|||||||
from db import dbauth
|
from db import dbauth
|
||||||
from db import dbapp
|
from db import dbapp
|
||||||
from auth.model.checkdb import checkupdates as checkupdatesauth
|
from auth.model.checkdb import checkupdates as checkupdatesauth
|
||||||
from os import path
|
import os
|
||||||
|
|
||||||
login_manager = LoginManager()
|
login_manager = LoginManager()
|
||||||
|
|
||||||
|
|
||||||
def init_app():
|
def init_app(debug: bool | None = None):
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
config: Configuration = Configuration("root/config.ini")
|
config: Configuration = Configuration("/usr/opt/pythonapp/App_Server/root/config.ini")
|
||||||
_projektname = config.app_projectname
|
_projektname = config.app_projectname
|
||||||
|
|
||||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////usr/var/pythonappdb/{}.db".format(_projektname)
|
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",
|
"auth": "sqlite:////usr/var/pythonappdb/auth.db",
|
||||||
"appdb": "sqlite:////usr/var/pythonappdb/{}.db".format(_projektname)
|
"appdb": "sqlite:////usr/var/pythonappdb/{}.db".format(_projektname)
|
||||||
}
|
}
|
||||||
app.config["DEBUG"] = 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["PROJEKTNAME"] = config.app_projectname
|
||||||
app.config["APPID"] = config.app_appid
|
app.config["APPID"] = config.app_appid
|
||||||
app.config["APPVERSION"] = config.clientapp_version
|
app.config["APPVERSION"] = config.clientapp_version
|
||||||
|
|||||||
+568
-167
@@ -2,17 +2,23 @@
|
|||||||
Flask CLI-Befehle zur Datenbank-Initialisierung und zum Reset.
|
Flask CLI-Befehle zur Datenbank-Initialisierung und zum Reset.
|
||||||
|
|
||||||
Befehle (aufgerufen via Flask CLI):
|
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
|
flask --app wsgi:app reset-db -- alles bis auf SysAdmin löschen
|
||||||
|
|
||||||
init-db ist idempotent: bereits vorhandene Rollen/Kunden werden nicht
|
init-db legt das Schema an bzw. migriert es und setzt danach die
|
||||||
doppelt angelegt. reset-db fragt standardmäßig zur Bestätigung; mit --yes
|
CRM-Geschäftsdaten zurück: Sämtliche Kunden samt aller abhängigen Daten
|
||||||
wird die Abfrage übersprungen.
|
(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>
|
:author: Mistral Vibe <vibe@mistral.ai>
|
||||||
:date: 2026-09-02
|
:date: 2026-09-02
|
||||||
:version: 1.0
|
:version: 1.1
|
||||||
"""
|
"""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
@@ -33,10 +39,10 @@ def register_cli_commands(app: Flask):
|
|||||||
click.echo("Initialisiere kbp_crm.db ...")
|
click.echo("Initialisiere kbp_crm.db ...")
|
||||||
from crm.model import checkdb as crm_checkdb
|
from crm.model import checkdb as crm_checkdb
|
||||||
crm_checkdb.checkupdates()
|
crm_checkdb.checkupdates()
|
||||||
_seed_muster_kunden()
|
click.echo("Lösche vorhandene CRM-Geschäftsdaten ...")
|
||||||
_seed_muster_projekte()
|
_purge_crm_daten()
|
||||||
_seed_muster_kontaktadressen()
|
click.echo("Lege Musterdaten an ...")
|
||||||
_seed_muster_calllogs()
|
_seed_musterdaten()
|
||||||
|
|
||||||
click.echo("Fertig.")
|
click.echo("Fertig.")
|
||||||
|
|
||||||
@@ -83,182 +89,577 @@ def _seed_app_roles():
|
|||||||
session.commit()
|
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
|
session = dbapp.session
|
||||||
if session.query(Kunde).count() > 0:
|
session.query(CallLogTag).delete(synchronize_session=False)
|
||||||
return
|
session.query(CallLog).delete(synchronize_session=False)
|
||||||
muster = [
|
session.query(Kontaktadresse).delete(synchronize_session=False)
|
||||||
Kunde(name="Musterfirma GmbH", strasse="Musterstraße 1",
|
session.query(ProjektUser).delete(synchronize_session=False)
|
||||||
plz="12345", ort="Musterstadt", land="Deutschland"),
|
session.query(Projekt).delete(synchronize_session=False)
|
||||||
Kunde(name="Beispiel AG", strasse="Beispielweg 2",
|
session.query(Kontakt).delete(synchronize_session=False)
|
||||||
plz="54321", ort="Beispielstadt", land="Deutschland"),
|
session.query(Kunde).delete(synchronize_session=False)
|
||||||
Kunde(name="Test Unternehmen", strasse="Testallee 3",
|
|
||||||
plz="67890", ort="Testdorf", land="Österreich"),
|
|
||||||
]
|
|
||||||
for kunde in muster:
|
|
||||||
session.add(kunde)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
def _seed_muster_projekte():
|
# ---------------------------------------------------------------------------
|
||||||
|
# Musterdaten: drei Kunden, jeder mit eigenen Kontakten und je drei
|
||||||
|
# Telefon-Akquise-Projekten. Die Daten sind bewusst so aufgebaut, dass
|
||||||
|
# jeder Kontakte- und Kontaktadressen-Name genau einmal in der gesamten
|
||||||
|
# Datenbank vorkommt und kein Projektname bei zwei Kunden existiert.
|
||||||
|
# Die Kontaktadressen sind die Akquise-Ziele (angerufene Firmen), die
|
||||||
|
# Gesprächsnotizen werden daraus automatisch abgeleitet.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_MUSTERDATEN = [
|
||||||
|
{
|
||||||
|
"name": "Nordwerk Metallbau GmbH & Co. KG",
|
||||||
|
"strasse": "Hamburger Str. 118",
|
||||||
|
"plz": "21079",
|
||||||
|
"ort": "Hamburg",
|
||||||
|
"land": "Deutschland",
|
||||||
|
"kontakte": [
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Dipl.-Ing. Markus Haverkamp",
|
||||||
|
"position": "Einkaufsleiter",
|
||||||
|
"telefon": "+49 40 790 214 66",
|
||||||
|
"email": "haverkamp@nordwerk-metallbau.de",
|
||||||
|
"notiz": "Entscheider für Rahmenverträge; telefonisch am besten nach 15 Uhr erreichbar.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Katrin Ohmsen",
|
||||||
|
"position": "Vertriebsleitung",
|
||||||
|
"telefon": "+49 40 790 214 12",
|
||||||
|
"email": "ohmsen@nordwerk-metallbau.de",
|
||||||
|
"notiz": "Steuert die laufenden Kampagnen mit uns; erwartet jeden Freitag ein kurzes Zwischen-Reporting.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"projekte": [
|
||||||
|
{
|
||||||
|
"projektname": "Akquise Stahlträgerprogramm 2026 – Dachstühle und Hallenbau",
|
||||||
|
"zweck": "Telefonische Neukundenansprache bei Bauunternehmen in Norddeutschland, um das überarbeitete Stahlträgerprogramm zu positionieren. Ziel: 400 geführte Gespräche bis Jahresende, davon 60 Angebotsanfragen.",
|
||||||
|
"verantwortlicher": "Miriam Falkner",
|
||||||
|
"startdatum": date(2026, 8, 3),
|
||||||
|
"kontaktadressen": [
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Torben Block",
|
||||||
|
"firma": "Block Bauunternehmung GmbH",
|
||||||
|
"position": "Einkaufsleiter",
|
||||||
|
"telefon": "+49 40 6899 2140",
|
||||||
|
"email": "t.block@block-bau-hamburg.de",
|
||||||
|
"strasse": "Wandsbeker Chaussee 210", "plz": "22089",
|
||||||
|
"ort": "Hamburg", "land": "Deutschland",
|
||||||
|
"notiz": "Tauscht die Rahmenverträge jährlich aus; entscheidet gemeinsam mit der Kalkulation.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Silke Reimers",
|
||||||
|
"firma": "Reimers Dachbaustoffe Nord",
|
||||||
|
"position": "Leiterin Kalkulation",
|
||||||
|
"telefon": "+49 431 556 872",
|
||||||
|
"email": "s.reimers@reimers-dachbaustoffe.de",
|
||||||
|
"strasse": "Kieler Str. 31", "plz": "24116",
|
||||||
|
"ort": "Kiel", "land": "Deutschland",
|
||||||
|
"notiz": "Plant mehrere Hallenprojekte für das kommende Jahr.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Gero Timmermann",
|
||||||
|
"firma": "Timmermann Hallenbau",
|
||||||
|
"position": "Geschäftsführer",
|
||||||
|
"telefon": "+49 511 987 4432",
|
||||||
|
"email": "g.timmermann@timmermann-hallenbau.de",
|
||||||
|
"strasse": "Am Lindener Hafen 12", "plz": "30453",
|
||||||
|
"ort": "Hannover", "land": "Deutschland",
|
||||||
|
"notiz": "Hat großes Interesse an Schnellmontage-Systemen signalisiert.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"projektname": "Neukundengewinnung Zulieferer Ostösterreich",
|
||||||
|
"zweck": "Kaltakquise bei Stahl- und Metallzulieferern in Wien, Niederösterreich und Oberösterreich zur Erschließung neuer Lieferbeziehungen. Ziel: 250 Kontakte, davon 40 qualifizierte Terminvorschläge für den Außendienst.",
|
||||||
|
"verantwortlicher": "Dominik Reiss",
|
||||||
|
"startdatum": date(2026, 8, 17),
|
||||||
|
"kontaktadressen": [
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Wolfgang Gruber",
|
||||||
|
"firma": "Gruber Stahlhandel",
|
||||||
|
"position": "Vertriebsleiter",
|
||||||
|
"telefon": "+43 1 982 76 11",
|
||||||
|
"email": "w.gruber@gruber-stahlhandel.at",
|
||||||
|
"strasse": "Laxenburger Str. 84", "plz": "1100",
|
||||||
|
"ort": "Wien", "land": "Österreich",
|
||||||
|
"notiz": "Bezieht Trägerprofile bislang aus Deutschland; offen für österreichische Bezugsquellen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Martina Ehgartner",
|
||||||
|
"firma": "Ehgartner Metallwerke",
|
||||||
|
"position": "Einkaufsleitung",
|
||||||
|
"telefon": "+43 732 455 210",
|
||||||
|
"email": "m.ehgartner@ehgartner-metallwerke.at",
|
||||||
|
"strasse": "Wiener Str. 145", "plz": "4020",
|
||||||
|
"ort": "Linz", "land": "Österreich",
|
||||||
|
"notiz": "Kauft halbjährlich größere Kontingente; Verträge laufen über die Zentrale.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Kurt Strubreiter",
|
||||||
|
"firma": "Strubreiter Blechtechnik",
|
||||||
|
"position": "Inhaber",
|
||||||
|
"telefon": "+43 2628 741 33",
|
||||||
|
"email": "office@strubreiter-blechtechnik.at",
|
||||||
|
"strasse": "Betriebsstr. 7", "plz": "2700",
|
||||||
|
"ort": "Wiener Neustadt", "land": "Österreich",
|
||||||
|
"notiz": "Familienbetrieb; entscheidet kurzfristig, wenn die Konditionen stimmen.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"projektname": "Cross-Selling Schutzgitter und Tore im Bestand",
|
||||||
|
"zweck": "Nachverkauf im Bestandskundenstamm: Kunden der Jahre 2022 bis 2025 werden telefonisch auf das neue Sortiment an Schutzgittern und Industrietoren angesprochen. Ziel: 300 Bestandskunden kontaktiert, Angebotsversand innerhalb von 48 Stunden.",
|
||||||
|
"verantwortlicher": "Aylin Kocabiyik",
|
||||||
|
"startdatum": date(2026, 9, 1),
|
||||||
|
"kontaktadressen": [
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Petra Wilhelmi",
|
||||||
|
"firma": "Wilhelmi Logistikzentrum",
|
||||||
|
"position": "Technische Leiterin",
|
||||||
|
"telefon": "+49 421 338 7762",
|
||||||
|
"email": "p.wilhelmi@wilhelmi-logistik.de",
|
||||||
|
"strasse": "Universitätsallee 25", "plz": "28359",
|
||||||
|
"ort": "Bremen", "land": "Deutschland",
|
||||||
|
"notiz": "Sanierung der Tore für nächstes Jahr geplant; Budget muss noch beantragt werden.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Heino Sievers",
|
||||||
|
"firma": "Sievers Agrartechnik",
|
||||||
|
"position": "Kundendienstleiter",
|
||||||
|
"telefon": "+49 441 763 519",
|
||||||
|
"email": "h.sievers@sievers-agrartechnik.de",
|
||||||
|
"strasse": "Boschdamm 18", "plz": "26122",
|
||||||
|
"ort": "Oldenburg", "land": "Deutschland",
|
||||||
|
"notiz": "Sucht Ersatzteile für ältere Hallentore; auch Interesse an Wartungsverträgen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Dietrich Voskamp",
|
||||||
|
"firma": "Voskamp Landbau",
|
||||||
|
"position": "Betriebsleiter",
|
||||||
|
"telefon": "+49 541 229 834",
|
||||||
|
"email": "d.voskamp@voskamp-landbau.de",
|
||||||
|
"strasse": "Natruper-Tor-Wall 70", "plz": "49074",
|
||||||
|
"ort": "Osnabrück", "land": "Deutschland",
|
||||||
|
"notiz": "Erweitert den Maschinenpark; über Neuanschaffungen entscheidet der Sohn mit.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vitalis Apothekenverbund eG",
|
||||||
|
"strasse": "Kaisersstr. 77",
|
||||||
|
"plz": "76133",
|
||||||
|
"ort": "Karlsruhe",
|
||||||
|
"land": "Deutschland",
|
||||||
|
"kontakte": [
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Dr. Ingrid Halbach",
|
||||||
|
"position": "Mitglied der Geschäftsleitung",
|
||||||
|
"telefon": "+49 721 508 3390",
|
||||||
|
"email": "halbach@vitalis-apotheken.de",
|
||||||
|
"notiz": "Apothekerin; legt besonderen Wert auf seriös formulierte Gesprächsleitfäden.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Jörn Kiebitz",
|
||||||
|
"position": "Leiter Filialmarketing",
|
||||||
|
"telefon": "+49 721 508 3377",
|
||||||
|
"email": "kiebitz@vitalis-apotheken.de",
|
||||||
|
"notiz": "Liefert die Filial- und Zielgruppenlisten für unsere Kampagnen; Ansprechpartner für Abstimmungstermine.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"projekte": [
|
||||||
|
{
|
||||||
|
"projektname": "Terminierung Arzneimittelberatung – Vitalis Filialen Süd",
|
||||||
|
"zweck": "Telefonische Terminierung kostenpflichtiger Arzneimittelberatungen für Mitgliedsapotheken in Baden-Württemberg und Bayern. Ziel: 120 bestätigte Beratungstermine pro Monat bei maximal drei Absagen je Termin.",
|
||||||
|
"verantwortlicher": "Tobias Lenzen",
|
||||||
|
"startdatum": date(2026, 9, 7),
|
||||||
|
"kontaktadressen": [
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Dr. Marlene Vogt",
|
||||||
|
"firma": "Vogt-Apotheke Stuttgart-West",
|
||||||
|
"position": "Inhaberin",
|
||||||
|
"telefon": "+49 711 563 448",
|
||||||
|
"email": "m.vogt@vogt-apotheke-stuttgart.de",
|
||||||
|
"strasse": "Kernerstr. 31", "plz": "70182",
|
||||||
|
"ort": "Stuttgart", "land": "Deutschland",
|
||||||
|
"notiz": "Führt die Beratungen selbst durch; Terminvorschläge möglichst vormittags.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Ingo Pasternak",
|
||||||
|
"firma": "Pasternak Apotheke",
|
||||||
|
"position": "Filialleiter",
|
||||||
|
"telefon": "+49 89 448 2120",
|
||||||
|
"email": "i.pasternak@pasternak-apotheke.de",
|
||||||
|
"strasse": "Leopoldstr. 90", "plz": "80802",
|
||||||
|
"ort": "München", "land": "Deutschland",
|
||||||
|
"notiz": "Stellvertretung zu Servicezwecken; Zeitfenster dienstags und donnerstags.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Regine Axt",
|
||||||
|
"firma": "Axt Rathaus-Apotheke",
|
||||||
|
"position": "Approbierte Apothekerin",
|
||||||
|
"telefon": "+49 721 976 512",
|
||||||
|
"email": "r.axt@axt-apotheke.de",
|
||||||
|
"strasse": "Am Stadtgarten 2", "plz": "76133",
|
||||||
|
"ort": "Karlsruhe", "land": "Deutschland",
|
||||||
|
"notiz": "Neu in der Mitgliedschaft; wünscht ein Einführungsgespräch.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"projektname": "Rückgewinnung inaktiver Bonuskarten-Kunden",
|
||||||
|
"zweck": "Reaktivierung von rund 5.000 nicht mehr aktiven Bonuskarten-Kunden per Telefon mit anschließender Follow-up-Mail. Ziel: 8 Prozent Rückkehrquote im ersten Quartal, Nachverkauf über die Filialen.",
|
||||||
|
"verantwortlicher": "Camilla Brugger",
|
||||||
|
"startdatum": date(2026, 8, 24),
|
||||||
|
"kontaktadressen": [
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Falk Rüdiger",
|
||||||
|
"firma": "Sonnen-Apotheke Freiburg",
|
||||||
|
"position": "Inhaber",
|
||||||
|
"telefon": "+49 761 388 224",
|
||||||
|
"email": "f.ruediger@sonnen-apotheke-freiburg.de",
|
||||||
|
"strasse": "Salzstr. 60", "plz": "79098",
|
||||||
|
"ort": "Freiburg", "land": "Deutschland",
|
||||||
|
"notiz": "Wertet die Rückkehrquote monatlich aus; erwartet Zahlen zum Quartalsende.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Heidrun Clauß",
|
||||||
|
"firma": "Clauß Apotheke am Markt",
|
||||||
|
"position": "Inhaberin",
|
||||||
|
"telefon": "+49 351 469 887",
|
||||||
|
"email": "h.clauss@clauss-apotheke.de",
|
||||||
|
"strasse": "Wilsdruffer Str. 24", "plz": "01067",
|
||||||
|
"ort": "Dresden", "land": "Deutschland",
|
||||||
|
"notiz": "Ist mit der bisherigen Rücklaufquote unzufrieden und drängt auf eine neue Ansprache.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Marek Sawatzki",
|
||||||
|
"firma": "Sawatzki Apotheke Leipzig-Süd",
|
||||||
|
"position": "Fachapotheker",
|
||||||
|
"telefon": "+49 341 225 904",
|
||||||
|
"email": "m.sawatzki@sawatzki-apotheke.de",
|
||||||
|
"strasse": "Karl-Liebknecht-Str. 105", "plz": "04275",
|
||||||
|
"ort": "Leipzig", "land": "Deutschland",
|
||||||
|
"notiz": "Möchte besonders die junge Zielgruppe wieder ansprechen.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"projektname": "Up-Selling Homöopathie-Eigenmarke bei Mitgliedsapotheken",
|
||||||
|
"zweck": "Telefonische Empfehlung der Eigenmarke 'Vitalis Natur' an alle 180 Mitgliedsapotheken. Ziel: Listung in mindestens 60 Apotheken bis zum Ende des ersten Quartals, durchschnittliches Bestellvolumen von 1.200 Euro je Listung.",
|
||||||
|
"verantwortlicher": "Nils Gröber",
|
||||||
|
"startdatum": date(2026, 9, 14),
|
||||||
|
"kontaktadressen": [
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Brunhilde Kessler",
|
||||||
|
"firma": "Kessler Engelpforte-Apotheke",
|
||||||
|
"position": "Inhaberin",
|
||||||
|
"telefon": "+49 611 344 210",
|
||||||
|
"email": "b.kessler@kessler-apotheke.de",
|
||||||
|
"strasse": "Kirchgasse 32", "plz": "65183",
|
||||||
|
"ort": "Wiesbaden", "land": "Deutschland",
|
||||||
|
"notiz": "Listet bislang nur zwei Eigenmarken-Produkte; offen für eine Sortimentserweiterung.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Ali Demirci",
|
||||||
|
"firma": "Demirci Apotheke Köln",
|
||||||
|
"position": "Apotheker",
|
||||||
|
"telefon": "+49 221 559 731",
|
||||||
|
"email": "a.demirci@demirci-apotheke.de",
|
||||||
|
"strasse": "Neumarkt 3", "plz": "50667",
|
||||||
|
"ort": "Köln", "land": "Deutschland",
|
||||||
|
"notiz": "Fragt nach Verkaufsunterlagen für das Beratungsgespräch.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Yasmin Trautmann",
|
||||||
|
"firma": "Trautmann Apotheke",
|
||||||
|
"position": "Fachapothekerin",
|
||||||
|
"telefon": "+49 69 664 128",
|
||||||
|
"email": "y.trautmann@trautmann-apotheke.de",
|
||||||
|
"strasse": "Berger Str. 175", "plz": "60385",
|
||||||
|
"ort": "Frankfurt", "land": "Deutschland",
|
||||||
|
"notiz": "Möchte erst die Ergebnisse einer Testlistung abwarten.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alpina Holzwerke Sepp Achmüller GmbH",
|
||||||
|
"strasse": "Sepp-Achmüller-Str. 4",
|
||||||
|
"plz": "6330",
|
||||||
|
"ort": "Kufstein",
|
||||||
|
"land": "Österreich",
|
||||||
|
"kontakte": [
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Sepp Achmüller jun.",
|
||||||
|
"position": "Geschäftsführer",
|
||||||
|
"telefon": "+43 5372 6234 18",
|
||||||
|
"email": "achmiller@alpina-holzwerke.at",
|
||||||
|
"notiz": "Prüft jede Kampagne vor dem Start persönlich ab; Budgetfreigabe erst nach Vorlage einer Musterabrechnung.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Elisabeth Steger",
|
||||||
|
"position": "Vertriebsleiterin",
|
||||||
|
"telefon": "+43 5372 6234 22",
|
||||||
|
"email": "steger@alpina-holzwerke.at",
|
||||||
|
"notiz": "Stimmt Zielgruppen und Anrufregionen mit uns ab; bevorzugt die Abstimmung per Mail.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"projekte": [
|
||||||
|
{
|
||||||
|
"projektname": "Kampagne Thermofenster Linienserie Alpin",
|
||||||
|
"zweck": "Telefonische Kampagne bei Baufirmen, Architekten und Holzbaubetrieben für die neue Thermofenster-Serie 'Alpin'. Ziel: 300 geführte Gespräche und 45 Mess- beziehungsweise Angebotstermine.",
|
||||||
|
"verantwortlicher": "Sandra Wittke",
|
||||||
|
"startdatum": date(2026, 8, 10),
|
||||||
|
"kontaktadressen": [
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Jakob Rainer",
|
||||||
|
"firma": "Rainer Holzbau",
|
||||||
|
"position": "Geschäftsführer",
|
||||||
|
"telefon": "+43 512 344 087",
|
||||||
|
"email": "j.rainer@rainer-holzbau.at",
|
||||||
|
"strasse": "Museumstr. 13", "plz": "6020",
|
||||||
|
"ort": "Innsbruck", "land": "Österreich",
|
||||||
|
"notiz": "Setzt auf Passivhaus-Komponenten; U-Werte müssen im Angebot ausgewiesen sein.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Notburga Wörgötter",
|
||||||
|
"firma": "Wörgötter Zimmerei",
|
||||||
|
"position": "Kalkulation und Einkauf",
|
||||||
|
"telefon": "+43 662 834 556",
|
||||||
|
"email": "n.woergoetter@woergoetter-zimmerei.at",
|
||||||
|
"strasse": "Schallmooser Hauptstr. 44", "plz": "5020",
|
||||||
|
"ort": "Salzburg", "land": "Österreich",
|
||||||
|
"notiz": "Benötigt Muster für die Messepräsentation im November.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Quirin Hofer",
|
||||||
|
"firma": "Hofer Bau & Planung",
|
||||||
|
"position": "Architekt",
|
||||||
|
"telefon": "+49 871 339 421",
|
||||||
|
"email": "q.hofer@hofer-bau-planung.de",
|
||||||
|
"strasse": "Ostengasse 15", "plz": "84028",
|
||||||
|
"ort": "Landshut", "land": "Deutschland",
|
||||||
|
"notiz": "Plant ein mehrgeschossiges Holzbauprojekt; Fensterelemente sind noch nicht vergeben.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"projektname": "Wiedervorlage-Bestellungen Winterholz-Vorkauf",
|
||||||
|
"zweck": "Frühzeitige telefonische Vorkaufskampagne für Winterholz: Bestandskunden aus dem Bauhandel anrufen, Bedarf aufnehmen und Wiedervorlagen für die Bestellzeit setzen. Ziel: 65 Prozent Vorab-Buchung des Winterkontingents.",
|
||||||
|
"verantwortlicher": "Jonas Ade",
|
||||||
|
"startdatum": date(2026, 9, 21),
|
||||||
|
"kontaktadressen": [
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Werner Prantner",
|
||||||
|
"firma": "Prantner Baustoffe",
|
||||||
|
"position": "Verkauf",
|
||||||
|
"telefon": "+49 8631 554 22",
|
||||||
|
"email": "w.prantner@prantner-baustoffe.de",
|
||||||
|
"strasse": "Simbacher Str. 14", "plz": "84137",
|
||||||
|
"ort": "Vilsbiburg", "land": "Deutschland",
|
||||||
|
"notiz": "Bestellt traditionell erst im November; testet dieses Jahr erstmals den Vorkauf.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Cäcilia Pfeiff",
|
||||||
|
"firma": "Pfeiff Holzmarkt",
|
||||||
|
"position": "Inhaberin",
|
||||||
|
"telefon": "+43 732 915 447",
|
||||||
|
"email": "c.pfeiff@pfeiff-holzmarkt.at",
|
||||||
|
"strasse": "Rudolfstr. 12", "plz": "4020",
|
||||||
|
"ort": "Linz", "land": "Österreich",
|
||||||
|
"notiz": "Braucht eine verbindliche Lieferzusage bis Anfang Dezember.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Ambros Hinterseer",
|
||||||
|
"firma": "Hinterseer Sägewerke",
|
||||||
|
"position": "Disposition",
|
||||||
|
"telefon": "+43 5356 622 18",
|
||||||
|
"email": "a.hinterseer@hinterseer-saegewerke.at",
|
||||||
|
"strasse": "Jochberger Str. 9", "plz": "6360",
|
||||||
|
"ort": "Kitzbühel", "land": "Österreich",
|
||||||
|
"notiz": "Koordiniert den Transport selbst; Übergabetermine müssen exakt stehen.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"projektname": "Lead-Qualifizierung Sägewerksbetriebe Bayern/Tirol",
|
||||||
|
"zweck": "Anrufcampagne zur Lead-Qualifizierung bei mittelständischen Sägewerken in Bayern und Tirol: Recherche der Ansprechpartner und Bewertung nach Einkaufsvolumen. Ziel: 200 qualifizierte Leads bis Mitte des zweiten Quartals.",
|
||||||
|
"verantwortlicher": "Pia Merkert",
|
||||||
|
"startdatum": date(2026, 9, 28),
|
||||||
|
"kontaktadressen": [
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Hubert Mühlbacher",
|
||||||
|
"firma": "Mühlbacher Holz",
|
||||||
|
"position": "Einkaufsleiter",
|
||||||
|
"telefon": "+49 851 987 350",
|
||||||
|
"email": "h.muehlbacher@muehlbacher-holz.de",
|
||||||
|
"strasse": "Innstr. 40", "plz": "94032",
|
||||||
|
"ort": "Passau", "land": "Deutschland",
|
||||||
|
"notiz": "Fragt nach Zertifikaten und Lieferkonditionen bereits im Erstgespräch.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Frau", "name": "Resi Peringer",
|
||||||
|
"firma": "Peringer Säge",
|
||||||
|
"position": "Disposition",
|
||||||
|
"telefon": "+43 5285 644 21",
|
||||||
|
"email": "r.peringer@peringer-saege.at",
|
||||||
|
"strasse": "Dorfstr. 22", "plz": "6290",
|
||||||
|
"ort": "Mayrhofen", "land": "Österreich",
|
||||||
|
"notiz": "Familienbetrieb in dritter Generation; die Entscheidung liegt beim Senior.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anrede": "Herr", "name": "Konrad Eder",
|
||||||
|
"firma": "Eder Holzwerk",
|
||||||
|
"position": "Betriebsleiter",
|
||||||
|
"telefon": "+49 8031 554 67",
|
||||||
|
"email": "k.eder@eder-holzwerk.de",
|
||||||
|
"strasse": "Chiemseestr. 28", "plz": "83022",
|
||||||
|
"ort": "Rosenheim", "land": "Deutschland",
|
||||||
|
"notiz": "Erweitert die Produktionskapazität; sucht Lieferanten mit hoher Flexibilität.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Betreff-Vorlagen für die automatisch erzeugten Gesprächsnotizen. Passen
|
||||||
|
# zum Ablauf einer Telefon-Akquise (Erstkontakt -> Termin -> Angebot).
|
||||||
|
_BETREFFVORLAGEN = [
|
||||||
|
"Erstkontakt und Bedarfsaufnahme",
|
||||||
|
"Produktvorstellung am Telefon",
|
||||||
|
"Terminvorschlag Außendienst",
|
||||||
|
"Angebot nachreichen",
|
||||||
|
"Nachfassung zum Angebot",
|
||||||
|
"Wiedervorlage nach Sommerpause",
|
||||||
|
"Rückruf vereinbart",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Notiz-Vorlagen für die automatisch erzeugten Gesprächsnotizen. Formuliert
|
||||||
|
# wie typische Ergebnisse eines Akquise-Telefonats; ein leerer Eintrag
|
||||||
|
# steht für ein Telefonat ohne Notiz.
|
||||||
|
_NOTIZVORLAGEN = [
|
||||||
|
"Gesprächspartner zeigt grundsätzliches Interesse und bittet um die Preisliste als PDF. Das Angebot geht per Mail raus.",
|
||||||
|
"Kein akuter Bedarf, aber Erweiterungsplanung für das kommende Jahr. Wiedervorlage im Frühjahr gesetzt.",
|
||||||
|
"Vor-Ort-Termin zur Besichtigung vereinbart; Unterlagen werden vorab per Mail gesendet.",
|
||||||
|
"Zentrale verweist auf die Beschaffungsabteilung, der konkrete Ansprechpartner wird noch erfragt.",
|
||||||
|
"Muster werden angefragt; Rückruf zugesagt, sobald die Kollegin aus dem Urlaub zurück ist.",
|
||||||
|
"Angebot wurde durchgesprochen, das Budget ist noch nicht freigegeben. Nächster Anruf nach der Freigabe.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_musterdaten():
|
||||||
"""
|
"""
|
||||||
Legt für jeden Muster-Kunden 1 bis 3 Muster-Projekte an, falls die
|
Legt die vollständigen Musterdaten an: drei Kunden mit kundenspezifischen
|
||||||
projekte-Tabelle noch leer ist.
|
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
|
import random
|
||||||
|
|
||||||
from crm.model import Kunde, Projekt
|
from crm.model import Kunde, Kontakt, Projekt, Kontaktadresse, CallLog
|
||||||
|
|
||||||
session = dbapp.session
|
session = dbapp.session
|
||||||
if session.query(Projekt).count() > 0:
|
# Fester Seed für reproduzierbare Musterdaten (Datum, Uhrzeit, Dauer).
|
||||||
return
|
rng = random.Random(456)
|
||||||
|
|
||||||
kunden = session.query(Kunde).order_by(Kunde.id).all()
|
for kundendaten in _MUSTERDATEN:
|
||||||
vorlagen = [
|
kunde = Kunde(
|
||||||
("Website-Relaunch", "Neugestaltung der Unternehmenswebsite"),
|
name=kundendaten["name"],
|
||||||
("E-Commerce Plattform", "Online-Shop Entwicklung"),
|
strasse=kundendaten["strasse"],
|
||||||
("CRM-System", "Kundenbeziehungsmanagement"),
|
plz=kundendaten["plz"],
|
||||||
("Mobile App", "iOS- und Android-Entwicklung"),
|
ort=kundendaten["ort"],
|
||||||
("SEO-Optimierung", "Suchmaschinenoptimierung"),
|
land=kundendaten["land"],
|
||||||
]
|
)
|
||||||
verantwortliche = [
|
session.add(kunde)
|
||||||
"Max Mustermann", "Anna Schmidt", "Peter Müller",
|
# flush liefert die Auto-Increment-ID für die abhängigen Sätze.
|
||||||
"Lisa Bauer", "Thomas Becker",
|
session.flush()
|
||||||
]
|
|
||||||
rng = random.Random(42)
|
for kontaktdaten in kundendaten["kontakte"]:
|
||||||
vorlagen_idx = 0
|
kontakt = Kontakt(
|
||||||
for kunde in kunden:
|
kunden_id=kunde.id,
|
||||||
anzahl = rng.randint(1, 3)
|
anrede=kontaktdaten["anrede"],
|
||||||
for _ in range(anzahl):
|
name=kontaktdaten["name"],
|
||||||
name, zweck = vorlagen[vorlagen_idx % len(vorlagen)]
|
position=kontaktdaten["position"],
|
||||||
vorlagen_idx += 1
|
telefon=kontaktdaten["telefon"],
|
||||||
projekt = Projekt()
|
email=kontaktdaten["email"],
|
||||||
projekt.kunden_id = kunde.id
|
notiz=kontaktdaten["notiz"],
|
||||||
projekt.projektname = name
|
)
|
||||||
projekt.zweck = zweck
|
session.add(kontakt)
|
||||||
projekt.verantwortlicher = rng.choice(verantwortliche)
|
|
||||||
projekt.startdatum = "2024-0{monat}-1{tag}".format(
|
for projektdaten in kundendaten["projekte"]:
|
||||||
monat=rng.randint(1, 9), tag=rng.randint(0, 9)
|
projekt = Projekt(
|
||||||
|
kunden_id=kunde.id,
|
||||||
|
projektname=projektdaten["projektname"],
|
||||||
|
zweck=projektdaten["zweck"],
|
||||||
|
verantwortlicher=projektdaten["verantwortlicher"],
|
||||||
|
startdatum=projektdaten["startdatum"],
|
||||||
)
|
)
|
||||||
session.add(projekt)
|
session.add(projekt)
|
||||||
session.commit()
|
session.flush()
|
||||||
|
|
||||||
|
for adressdaten in projektdaten["kontaktadressen"]:
|
||||||
|
adresse = Kontaktadresse(
|
||||||
|
projekt_id=projekt.id,
|
||||||
|
anrede=adressdaten["anrede"],
|
||||||
|
name=adressdaten["name"],
|
||||||
|
firma=adressdaten["firma"],
|
||||||
|
position=adressdaten["position"],
|
||||||
|
telefon=adressdaten["telefon"],
|
||||||
|
email=adressdaten["email"],
|
||||||
|
strasse=adressdaten["strasse"],
|
||||||
|
plz=adressdaten["plz"],
|
||||||
|
ort=adressdaten["ort"],
|
||||||
|
land=adressdaten["land"],
|
||||||
|
notiz=adressdaten["notiz"],
|
||||||
|
)
|
||||||
|
session.add(adresse)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
def _seed_muster_kontaktadressen():
|
# Je Kontaktadresse ein bis zwei Gesprächsnotizen, die das
|
||||||
"""
|
# Akquise-Telefonat mit diesem Ziel dokumentieren.
|
||||||
Legt für jedes Muster-Projekt 1 bis 3 Kontaktadressen an, falls die
|
for _ in range(rng.randint(1, 2)):
|
||||||
kontaktadressen-Tabelle noch leer ist.
|
log = CallLog()
|
||||||
"""
|
log.projekt_id = projekt.id
|
||||||
import random
|
log.kontaktadresse_id = adresse.id
|
||||||
|
log.datum = date(2026, 9, rng.randint(1, 26))
|
||||||
from crm.model import Projekt, Kontaktadresse
|
log.uhrzeit_beginn = "{h:02d}:{min:02d}".format(
|
||||||
|
h=rng.randint(8, 17), min=rng.randint(0, 59)
|
||||||
session = dbapp.session
|
)
|
||||||
if session.query(Kontaktadresse).count() > 0:
|
log.dauer = str(rng.randint(5, 45))
|
||||||
return
|
log.betreff = rng.choice(_BETREFFVORLAGEN)
|
||||||
|
log.name_ansprechpartner = adresse.name
|
||||||
projekte = session.query(Projekt).order_by(Projekt.id).all()
|
log.telefon = adresse.telefon
|
||||||
vorlagen = [
|
log.notiz = rng.choice(_NOTIZVORLAGEN)
|
||||||
("Herr", "Thomas Becker", "Becker Webdesign GmbH", "Geschäftsführer"),
|
log.wiedervorlage_datum = log.datum + timedelta(
|
||||||
("Frau", "Sandra Klein", "Klein Marketing", "Marketingleiterin"),
|
days=rng.randint(7, 28)
|
||||||
("Herr", "Michael Wagner", "Wagner Online Shop", "IT-Leiter"),
|
)
|
||||||
("Frau", "Julia Hoffmann", "Hoffmann Consulting", "Beraterin"),
|
session.add(log)
|
||||||
("Herr", "Stefan Roth", "Roth IT Solutions", "Technischer Leiter"),
|
|
||||||
]
|
|
||||||
orte = [
|
|
||||||
("Industriestr. 12", "45131", "Essen", "Deutschland"),
|
|
||||||
("Königsallee 5", "40212", "Düsseldorf", "Deutschland"),
|
|
||||||
("Hauptstr. 88", "50667", "Köln", "Deutschland"),
|
|
||||||
("Marienplatz 3", "80331", "München", "Deutschland"),
|
|
||||||
]
|
|
||||||
rng = random.Random(123)
|
|
||||||
vorlagen_idx = 0
|
|
||||||
for projekt in projekte:
|
|
||||||
anzahl = rng.randint(1, 3)
|
|
||||||
for _ in range(anzahl):
|
|
||||||
anrede, name, firma, position = vorlagen[vorlagen_idx % len(vorlagen)]
|
|
||||||
vorlagen_idx += 1
|
|
||||||
strasse, plz, ort, land = rng.choice(orte)
|
|
||||||
ka = Kontaktadresse()
|
|
||||||
ka.projekt_id = projekt.id
|
|
||||||
ka.anrede = anrede
|
|
||||||
ka.name = name
|
|
||||||
ka.firma = firma
|
|
||||||
ka.position = position
|
|
||||||
ka.telefon = "+49 {vorwahl} {nummer}".format(
|
|
||||||
vorwahl=rng.randint(100, 999),
|
|
||||||
nummer=rng.randint(100000, 999999)
|
|
||||||
)
|
|
||||||
ka.email = name.lower().replace(" ", ".") + "@example.de"
|
|
||||||
ka.strasse = strasse
|
|
||||||
ka.plz = plz
|
|
||||||
ka.ort = ort
|
|
||||||
ka.land = land
|
|
||||||
ka.notiz = ""
|
|
||||||
session.add(ka)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _seed_muster_calllogs():
|
|
||||||
"""
|
|
||||||
Legt für jedes Muster-Projekt 2 bis 5 Gesprächsnotizen an, falls die
|
|
||||||
call_logs-Tabelle noch leer ist.
|
|
||||||
"""
|
|
||||||
import random
|
|
||||||
|
|
||||||
from crm.model import Projekt, CallLog
|
|
||||||
|
|
||||||
session = dbapp.session
|
|
||||||
if session.query(CallLog).count() > 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
projekte = session.query(Projekt).order_by(Projekt.id).all()
|
|
||||||
betreffvorlagen = [
|
|
||||||
"Anforderungen klären",
|
|
||||||
"Feedback zum Design",
|
|
||||||
"Technische Anforderungen",
|
|
||||||
"Schulungstermin",
|
|
||||||
"Feature-Request",
|
|
||||||
"Budgetgespräch",
|
|
||||||
"Projekt-Review",
|
|
||||||
]
|
|
||||||
notizvorlagen = [
|
|
||||||
"Kunde möchte responsive Design und neue Farbgestaltung. Budget noch offen.",
|
|
||||||
"Design-Entwürfe wurden positiv aufgenommen. Kleine Änderungen an der Navigation nötig.",
|
|
||||||
"Payment-Gateway und Versand-Schnittstellen müssen integriert werden.",
|
|
||||||
"Schulung für 10 Mitarbeiter geplant. Raum noch zu buchen.",
|
|
||||||
"Kunde wünscht Push-Benachrichtigungen und Offline-Modus.",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
ansprechpartner = ["Herr Schmidt", "Frau Müller", "Herr Weber", "Frau Bauer", "Herr König"]
|
|
||||||
rng = random.Random(456)
|
|
||||||
for projekt in projekte:
|
|
||||||
anzahl = rng.randint(2, 5)
|
|
||||||
for _ in range(anzahl):
|
|
||||||
log = CallLog()
|
|
||||||
log.projekt_id = projekt.id
|
|
||||||
monat = rng.randint(1, 9)
|
|
||||||
tag = rng.randint(1, 28)
|
|
||||||
log.datum = "2024-0{m}-{t}".format(m=monat, t=tag) if monat < 10 else "2024-{m}-{t}".format(m=monat, t=tag)
|
|
||||||
log.uhrzeit_beginn = "{h:02d}:{min:02d}".format(
|
|
||||||
h=rng.randint(8, 17), min=rng.randint(0, 59)
|
|
||||||
)
|
|
||||||
log.dauer = str(rng.randint(15, 90))
|
|
||||||
log.betreff = rng.choice(betreffvorlagen)
|
|
||||||
log.name_ansprechpartner = rng.choice(ansprechpartner)
|
|
||||||
log.telefon = "+49 {v} {n}".format(
|
|
||||||
v=rng.randint(100, 999), n=rng.randint(100000, 999999)
|
|
||||||
)
|
|
||||||
log.notiz = rng.choice(notizvorlagen)
|
|
||||||
wiedervorlage_monat = monat + 1 if monat < 12 else 1
|
|
||||||
log.wiedervorlage_datum = "2024-0{m}-{t}".format(m=wiedervorlage_monat, t=tag) if wiedervorlage_monat < 10 else "2024-{m}-{t}".format(m=wiedervorlage_monat, t=tag)
|
|
||||||
log.erledigt = rng.choice([True, False])
|
|
||||||
session.add(log)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -148,15 +148,33 @@ class Projekt(dbapp.Model):
|
|||||||
startdatum = Column(Date, nullable=True)
|
startdatum = Column(Date, nullable=True)
|
||||||
|
|
||||||
kunde = relationship('Kunde')
|
kunde = relationship('Kunde')
|
||||||
|
# Kontaktadressen dieses Projekts (Akquise-Ziele). Die Rückrichtung
|
||||||
|
# ist Kontaktadresse.projekt (back_populates).
|
||||||
|
kontaktadressen = relationship('Kontaktadresse', back_populates='projekt')
|
||||||
|
|
||||||
|
def is_erledigt(self):
|
||||||
|
"""
|
||||||
|
Prüft dynamisch, ob das Projekt als erledigt gilt.
|
||||||
|
|
||||||
|
True, wenn das Projekt Kontaktadressen hat und alle davon erledigt
|
||||||
|
sind. Ein Projekt ohne Kontaktadressen ist offen (False), da
|
||||||
|
Kontaktadressen erst nach dem Anlegen des Projekts erfasst werden
|
||||||
|
können — ein neues Projekt ist somit zunächst offen.
|
||||||
|
"""
|
||||||
|
if not self.kontaktadressen:
|
||||||
|
return False
|
||||||
|
return all(k.is_erledigt() for k in self.kontaktadressen)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""
|
"""
|
||||||
Liefert die Projektdaten als Dictionary. Die Tabellenspalten werden
|
Liefert die Projektdaten als Dictionary. Die Tabellenspalten werden
|
||||||
über columns2dict serialisiert; kunden_name wird zusätzlich aus der
|
über columns2dict serialisiert; kunden_name wird zusätzlich aus der
|
||||||
Kunde-Relationship eingefügt.
|
Kunde-Relationship eingefügt. is_erledigt wird dynamisch aus den
|
||||||
|
zugeordneten Kontaktadressen berechnet.
|
||||||
"""
|
"""
|
||||||
result = columns2dict(self)
|
result = columns2dict(self)
|
||||||
result['kunden_name'] = self.kunde.name if self.kunde else None
|
result['kunden_name'] = self.kunde.name if self.kunde else None
|
||||||
|
result['is_erledigt'] = self.is_erledigt()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -220,34 +238,75 @@ class Kontaktadresse(dbapp.Model):
|
|||||||
land = Column(String(80), nullable=True)
|
land = Column(String(80), nullable=True)
|
||||||
notiz = Column(String(2000), nullable=True)
|
notiz = Column(String(2000), nullable=True)
|
||||||
|
|
||||||
projekt = relationship('Projekt')
|
projekt = relationship('Projekt', back_populates='kontaktadressen')
|
||||||
|
# Gesprächsnotizen, die zu diesem Kontakt erfasst wurden. Die
|
||||||
|
# Rückrichtung ist CallLog.kontaktadresse (back_populates); die
|
||||||
|
# Join-Bedingung ergibt sich aus dem Fremdschlüssel
|
||||||
|
# call_logs.kontaktadresse_id -> kontaktadressen.id.
|
||||||
|
call_logs = relationship('CallLog', back_populates='kontaktadresse')
|
||||||
|
|
||||||
|
def is_erledigt(self):
|
||||||
|
"""
|
||||||
|
Prüft dynamisch, ob der Kontakt als erledigt gilt.
|
||||||
|
|
||||||
|
True, sobald mindestens eine Gesprächsnotiz des Kontakts einen Tag
|
||||||
|
mit is_erledigend=True hat. Spätere Notizen ohne erledigend-Tag
|
||||||
|
setzen den Status nicht zurück. False bei keinen Notizen oder wenn
|
||||||
|
keine Notiz einen erledigend-Tag hat.
|
||||||
|
"""
|
||||||
|
return any(
|
||||||
|
clt.tag.is_erledigend
|
||||||
|
for notiz in self.call_logs
|
||||||
|
for clt in notiz.tags_rel
|
||||||
|
)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""
|
"""
|
||||||
Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden
|
Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden
|
||||||
über columns2dict serialisiert; projekt_name wird zusätzlich aus der
|
über columns2dict serialisiert; projekt_name, projekt_zweck und
|
||||||
Projekt-Relationship eingefügt.
|
kunden_name werden zusätzlich aus der Projekt- bzw. Kunde-Relationship
|
||||||
|
eingefügt. is_erledigt wird dynamisch aus den Gesprächsnotizen des
|
||||||
|
Kontakts berechnet.
|
||||||
"""
|
"""
|
||||||
result = columns2dict(self)
|
result = columns2dict(self)
|
||||||
result['projekt_name'] = self.projekt.projektname if self.projekt else None
|
result['projekt_name'] = self.projekt.projektname if self.projekt else None
|
||||||
|
result['projekt_zweck'] = self.projekt.zweck if self.projekt else None
|
||||||
|
result['kunden_name'] = self.projekt.kunde.name if self.projekt and self.projekt.kunde else None
|
||||||
|
result['is_erledigt'] = self.is_erledigt()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class CallLog(dbapp.Model):
|
class CallLog(dbapp.Model):
|
||||||
"""
|
"""
|
||||||
Gesprächsnotiz für ein Projekt. Jede Gesprächsnotiz ist genau einem
|
Gesprächsnotiz für einen Kontakt. Jede Gesprächsnotiz ist genau einem
|
||||||
Projekt zugeordnet (projekt_id). Der Projektname (projekt_name) und
|
Projekt (projekt_id) und genau einer Kontaktadresse (kontaktadresse_id)
|
||||||
der Kundenname (kunden_name) sind keine eigenen Spalten, sondern
|
zugeordnet. Der Projektname (projekt_name) und der Kundenname
|
||||||
werden über die Projekt-Relationship abgeleitet und nur in to_dict()
|
(kunden_name) sind keine eigenen Spalten, sondern werden über die
|
||||||
für die JSON-Serialisierung ergänzt.
|
Projekt-Relationship abgeleitet und nur in to_dict() für die
|
||||||
|
JSON-Serialisierung ergänzt.
|
||||||
"""
|
"""
|
||||||
__tablename__ = 'call_logs'
|
__tablename__ = 'call_logs'
|
||||||
__bind_key__ = BINDKEY
|
__bind_key__ = BINDKEY
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('ix_call_logs_projekt_id', 'projekt_id'),
|
Index('ix_call_logs_projekt_id', 'projekt_id'),
|
||||||
|
Index('ix_call_logs_kontaktadresse_id', 'kontaktadresse_id'),
|
||||||
)
|
)
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False)
|
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False)
|
||||||
|
# Jede Gesprächsnotiz gehört zu genau einer Kontaktadresse
|
||||||
|
# (Pflichtfeld). nullable=False im Modell; in bestehenden Datenbanken
|
||||||
|
# ist die physische Spalte nullable (SQLite kann kein nachträgliches
|
||||||
|
# NOT NULL) — die Pflicht wird über die Server-Validierung in
|
||||||
|
# crm.model.call_log durchgesetzt.
|
||||||
|
kontaktadresse_id = Column(Integer, ForeignKey('kontaktadressen.id'), nullable=False)
|
||||||
|
# Ersteller der Gesprächsnotiz (Mitarbeiter, der den Anruf getätigt
|
||||||
|
# hat). Referenziert logisch die 'id' in der Auth-Datenbanktabelle
|
||||||
|
# 'users'; ein physischer Fremdschlüssel ist aufgrund der zwei
|
||||||
|
# getrennten SQLite-Datenbanken nicht möglich (Muster wie
|
||||||
|
# projekt_users.user_id). nullable=True, da Alt-Notizen (angelegt
|
||||||
|
# vor der Spalte) keinen Ersteller erfassen konnten; die Auflösung
|
||||||
|
# auf den username erfolgt zur Laufzeit über eine separate Query.
|
||||||
|
user_id = Column(Integer, nullable=True)
|
||||||
datum = Column(Date, nullable=True)
|
datum = Column(Date, nullable=True)
|
||||||
uhrzeit_beginn = Column(String(10), nullable=True)
|
uhrzeit_beginn = Column(String(10), nullable=True)
|
||||||
dauer = Column(String(10), nullable=True)
|
dauer = Column(String(10), nullable=True)
|
||||||
@@ -256,15 +315,24 @@ class CallLog(dbapp.Model):
|
|||||||
telefon = Column(String(100), nullable=True)
|
telefon = Column(String(100), nullable=True)
|
||||||
notiz = Column(String(2000), nullable=True)
|
notiz = Column(String(2000), nullable=True)
|
||||||
wiedervorlage_datum = Column(Date, nullable=True)
|
wiedervorlage_datum = Column(Date, nullable=True)
|
||||||
erledigt = Column(Boolean, nullable=False, default=False)
|
|
||||||
|
|
||||||
projekt = relationship('Projekt')
|
projekt = relationship('Projekt')
|
||||||
|
kontaktadresse = relationship('Kontaktadresse', back_populates='call_logs')
|
||||||
|
# n:m-Beziehung zu Tags über die Verknüpfungstabelle call_logs_tags.
|
||||||
|
# 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):
|
def to_dict(self):
|
||||||
"""
|
"""
|
||||||
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
|
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
|
||||||
über columns2dict serialisiert; projekt_name und kunden_name werden
|
über columns2dict serialisiert; projekt_name und kunden_name werden
|
||||||
zusätzlich aus der Projekt-Relationship eingefügt.
|
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)
|
result = columns2dict(self)
|
||||||
if self.projekt:
|
if self.projekt:
|
||||||
@@ -273,7 +341,82 @@ class CallLog(dbapp.Model):
|
|||||||
else:
|
else:
|
||||||
result['projekt_name'] = None
|
result['projekt_name'] = None
|
||||||
result['kunden_name'] = None
|
result['kunden_name'] = None
|
||||||
|
# Ersteller gegen die Auth-Datenbank auflösen (Muster wie
|
||||||
|
# crm.model.projekt_user). Alt-Notizen ohne user_id erhalten None.
|
||||||
|
result['mitarbeiter_name'] = None
|
||||||
|
if self.user_id is not None:
|
||||||
|
from auth.model import User
|
||||||
|
user = User.query.get(self.user_id)
|
||||||
|
if user is not None:
|
||||||
|
result['mitarbeiter_name'] = user.username
|
||||||
|
# Zugeordnete Tags serialisieren (nur is_parent=False sind zuordenbar).
|
||||||
|
result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel]
|
||||||
return result
|
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")
|
print("########## crm/model importiert")
|
||||||
@@ -9,21 +9,102 @@ dort über den Decorator authorisation_required.
|
|||||||
:date: 2026-09-04
|
:date: 2026-09-04
|
||||||
:version: 1.0
|
:version: 1.0
|
||||||
"""
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from db import dbapp
|
from db import dbapp
|
||||||
from crm.model import CallLog, Projekt, ProjektUser
|
from crm.model import CallLog, CallLogTag, Kontaktadresse, Projekt, ProjektUser, Tag
|
||||||
|
|
||||||
from auth.model.errors import ApplicationError, HtmlStatusCode
|
from auth.model.errors import ApplicationError, HtmlStatusCode
|
||||||
|
|
||||||
session = dbapp.session
|
session = dbapp.session
|
||||||
|
|
||||||
|
|
||||||
def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
|
def _parse_date(value: str | None) -> date | None:
|
||||||
|
"""
|
||||||
|
Wandelt einen ISO-Datum-String (yyyy-MM-dd) in ein date-Objekt um.
|
||||||
|
Leere Strings und None werden zu None konvertiert (Spalte ist nullable).
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _set_call_log_tags(call_log: CallLog, tag_ids: list) -> None:
|
||||||
|
"""
|
||||||
|
Ersetzt die Tag-Zuordnungen einer Gesprächsnotiz durch die übergebene
|
||||||
|
Liste von Tag-IDs. Bestehende Zuordnungen werden gelöscht, danach werden
|
||||||
|
die neuen Zuordnungen angelegt.
|
||||||
|
|
||||||
|
Nur Tags, die existieren und nicht reine Gruppierungsknoten
|
||||||
|
(is_parent=True) sind, werden zugeordnet. Duplikate in der Liste
|
||||||
|
werden durch ein Set eliminiert.
|
||||||
|
"""
|
||||||
|
# Bestehende Zuordnungen entfernen.
|
||||||
|
for clt in list(call_log.tags_rel):
|
||||||
|
session.delete(clt)
|
||||||
|
call_log.tags_rel = []
|
||||||
|
# Neue Zuordnungen anlegen.
|
||||||
|
seen = set()
|
||||||
|
for tag_id in tag_ids or []:
|
||||||
|
try:
|
||||||
|
tag_id = int(tag_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if tag_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(tag_id)
|
||||||
|
tag = Tag.query.get(tag_id)
|
||||||
|
if tag is None or tag.is_parent:
|
||||||
|
# Nicht zuordenbar — überspringen.
|
||||||
|
continue
|
||||||
|
clt = CallLogTag()
|
||||||
|
clt.call_log = call_log
|
||||||
|
clt.tag = tag
|
||||||
|
session.add(clt)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _get_validierte_kontaktadresse_id(data: dict, projekt_id) -> int:
|
||||||
|
"""
|
||||||
|
Liest kontaktadresse_id aus dem data-Dict und validiert sie:
|
||||||
|
- Pflichtfeld: fehlt sie oder ist sie leer, wird Bad Request geliefert.
|
||||||
|
- Die Kontaktadresse muss existieren und zum selben Projekt gehören.
|
||||||
|
|
||||||
|
Liefert die geprüfte ID als int zurück.
|
||||||
|
"""
|
||||||
|
kontaktadresse_id = data.get("kontaktadresse_id")
|
||||||
|
if not kontaktadresse_id:
|
||||||
|
raise ApplicationError(
|
||||||
|
"Es muss eine Kontaktadresse ausgewählt werden.",
|
||||||
|
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||||
|
)
|
||||||
|
kontaktadresse = Kontaktadresse.query.get(int(kontaktadresse_id))
|
||||||
|
if kontaktadresse is None or kontaktadresse.projekt_id != int(projekt_id):
|
||||||
|
raise ApplicationError(
|
||||||
|
"Die ausgewählte Kontaktadresse existiert nicht oder gehört nicht zum Projekt.",
|
||||||
|
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||||
|
)
|
||||||
|
return kontaktadresse.id
|
||||||
|
|
||||||
|
|
||||||
|
def get_call_logs(projekt_id: int | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
kontaktadresse_id: int | None = None):
|
||||||
"""
|
"""
|
||||||
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
|
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
|
||||||
|
|
||||||
Ist projekt_id gesetzt, werden nur die Notizen dieses Projekts
|
Ist projekt_id gesetzt, werden nur die Notizen dieses Projekts
|
||||||
zurückgegeben (serverseitige Filterung). Ohne projekt_id werden
|
zurückgegeben (serverseitige Filterung). Ist kontaktadresse_id
|
||||||
alle Notizen geliefert.
|
gesetzt, werden nur die Notizen dieses Kontakts zurückgegeben —
|
||||||
|
beide Filter können kombiniert werden. Ohne Filter werden alle
|
||||||
|
Notizen geliefert.
|
||||||
|
|
||||||
Ist user_id gesetzt, werden nur Notizen von Projekten geliefert, denen
|
Ist user_id gesetzt, werden nur Notizen von Projekten geliefert, denen
|
||||||
der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-Filterung).
|
der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-Filterung).
|
||||||
@@ -32,6 +113,8 @@ def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
|
|||||||
query = CallLog.query
|
query = CallLog.query
|
||||||
if projekt_id is not None:
|
if projekt_id is not None:
|
||||||
query = query.filter(CallLog.projekt_id == projekt_id)
|
query = query.filter(CallLog.projekt_id == projekt_id)
|
||||||
|
if kontaktadresse_id is not None:
|
||||||
|
query = query.filter(CallLog.kontaktadresse_id == kontaktadresse_id)
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
# Nur Notizen von Projekten, denen der Nutzer zugeordnet ist.
|
# Nur Notizen von Projekten, denen der Nutzer zugeordnet ist.
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
@@ -71,14 +154,19 @@ def get_call_log(call_log_id: int, user_id: int | None = None) -> CallLog:
|
|||||||
return call_log
|
return call_log
|
||||||
|
|
||||||
|
|
||||||
def create_call_log(data: dict) -> CallLog:
|
def create_call_log(data: dict, user_id: int | None = None) -> CallLog:
|
||||||
"""
|
"""
|
||||||
Legt eine neue Gesprächsnotiz an. Die vom Client übergebene ID wird
|
Legt eine neue Gesprächsnotiz an. Die vom Client übergebene ID wird
|
||||||
ignoriert; die ID wird serverseitig per Auto-Increment vergeben.
|
ignoriert; die ID wird serverseitig per Auto-Increment vergeben.
|
||||||
|
|
||||||
- data: dict mit den Feldern projekt_id, datum, uhrzeit_beginn, dauer,
|
- data: dict mit den Feldern projekt_id, kontaktadresse_id, datum,
|
||||||
betreff, name_ansprechpartner, telefon, notiz,
|
uhrzeit_beginn, dauer, betreff, name_ansprechpartner, telefon,
|
||||||
wiedervorlage_datum, erledigt
|
notiz, wiedervorlage_datum, tag_ids
|
||||||
|
- user_id: ID des angemeldeten Mitarbeiters, der die Notiz erfasst
|
||||||
|
(Ersteller). Wird von der Route übergeben (current_user.id),
|
||||||
|
damit diese Funktion frei vom Flask-Request-Kontext bleibt.
|
||||||
|
Der Ersteller wird beim Anlegen gesetzt und beim späteren
|
||||||
|
Update nicht mehr verändert.
|
||||||
"""
|
"""
|
||||||
projekt_id = data.get("projekt_id")
|
projekt_id = data.get("projekt_id")
|
||||||
if projekt_id is None:
|
if projekt_id is None:
|
||||||
@@ -92,17 +180,25 @@ def create_call_log(data: dict) -> CallLog:
|
|||||||
"Das ausgewählte Projekt existiert nicht.",
|
"Das ausgewählte Projekt existiert nicht.",
|
||||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||||
)
|
)
|
||||||
|
kontaktadresse_id = _get_validierte_kontaktadresse_id(data, projekt_id)
|
||||||
call_log = CallLog()
|
call_log = CallLog()
|
||||||
call_log.projekt_id = int(projekt_id)
|
call_log.projekt_id = int(projekt_id)
|
||||||
call_log.datum = (data.get("datum") or "").strip() or None
|
call_log.kontaktadresse_id = kontaktadresse_id
|
||||||
|
# Ersteller erfassen: welcher Mitarbeiter den Anruf getätigt hat.
|
||||||
|
# Alt-Notizen (vor der user_id-Spalte angelegt) haben NULL; neue
|
||||||
|
# Notizen ohne Angabe erhalten ebenfalls NULL, bleiben aber die
|
||||||
|
# Ausnahme, da die Route immer current_user.id übergibt.
|
||||||
|
if user_id is not None:
|
||||||
|
call_log.user_id = int(user_id)
|
||||||
|
call_log.datum = _parse_date(data.get("datum"))
|
||||||
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
||||||
call_log.dauer = (data.get("dauer") or "").strip() or None
|
call_log.dauer = (data.get("dauer") or "").strip() or None
|
||||||
call_log.betreff = (data.get("betreff") 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.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
|
||||||
call_log.telefon = (data.get("telefon") or "").strip() or None
|
call_log.telefon = (data.get("telefon") or "").strip() or None
|
||||||
call_log.notiz = (data.get("notiz") or "").strip() or None
|
call_log.notiz = (data.get("notiz") or "").strip() or None
|
||||||
call_log.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
|
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
|
||||||
call_log.erledigt = bool(data.get("erledigt", False))
|
_set_call_log_tags(call_log, data.get("tag_ids"))
|
||||||
session.add(call_log)
|
session.add(call_log)
|
||||||
session.commit()
|
session.commit()
|
||||||
return call_log
|
return call_log
|
||||||
@@ -126,16 +222,18 @@ def update_call_log(data: dict) -> CallLog:
|
|||||||
"Das ausgewählte Projekt existiert nicht.",
|
"Das ausgewählte Projekt existiert nicht.",
|
||||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||||
)
|
)
|
||||||
|
kontaktadresse_id = _get_validierte_kontaktadresse_id(data, projekt_id)
|
||||||
call_log.projekt_id = int(projekt_id)
|
call_log.projekt_id = int(projekt_id)
|
||||||
call_log.datum = (data.get("datum") or "").strip() or None
|
call_log.kontaktadresse_id = kontaktadresse_id
|
||||||
|
call_log.datum = _parse_date(data.get("datum"))
|
||||||
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
|
||||||
call_log.dauer = (data.get("dauer") or "").strip() or None
|
call_log.dauer = (data.get("dauer") or "").strip() or None
|
||||||
call_log.betreff = (data.get("betreff") 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.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
|
||||||
call_log.telefon = (data.get("telefon") or "").strip() or None
|
call_log.telefon = (data.get("telefon") or "").strip() or None
|
||||||
call_log.notiz = (data.get("notiz") or "").strip() or None
|
call_log.notiz = (data.get("notiz") or "").strip() or None
|
||||||
call_log.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
|
call_log.wiedervorlage_datum = _parse_date(data.get("wiedervorlage_datum"))
|
||||||
call_log.erledigt = bool(data.get("erledigt", False))
|
_set_call_log_tags(call_log, data.get("tag_ids"))
|
||||||
session.commit()
|
session.commit()
|
||||||
return call_log
|
return call_log
|
||||||
|
|
||||||
|
|||||||
@@ -83,10 +83,19 @@ def _db_V01_04():
|
|||||||
Fügt die Spalte 'position' zur Tabelle 'kontaktadressen' hinzu.
|
Fügt die Spalte 'position' zur Tabelle 'kontaktadressen' hinzu.
|
||||||
Bei bestehenden Datenbanken, die mit Version 01.03 initialisiert wurden,
|
Bei bestehenden Datenbanken, die mit Version 01.03 initialisiert wurden,
|
||||||
fehlt diese Spalte, da das Feld erst nachträglich ergänzt wurde.
|
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
|
from sqlalchemy import text
|
||||||
|
|
||||||
session.execute(text("ALTER TABLE kontaktadressen ADD COLUMN position VARCHAR(200)"))
|
# 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')
|
_updatesysteminfo('01.04')
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@@ -212,12 +221,198 @@ def _db_V01_09():
|
|||||||
session.commit()
|
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():
|
def checkupdates():
|
||||||
"""
|
"""
|
||||||
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
|
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
|
||||||
_db_Vxx_xx-Funktionen aus, deren Versionsnummer größer ist. Ist die
|
_db_Vxx_xx-Funktionen aus, deren Versionsnummer größer ist. Ist die
|
||||||
Datenbank noch leer (Tabelle fehlt), wird die Ersteinrichtung ausgeführt.
|
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'
|
curr_dbversion = '_db_V01_00'
|
||||||
try:
|
try:
|
||||||
systeminfo: CrmSysteminfo = CrmSysteminfo.query.get(1)
|
systeminfo: CrmSysteminfo = CrmSysteminfo.query.get(1)
|
||||||
@@ -225,8 +420,6 @@ def checkupdates():
|
|||||||
except (OperationalError, AttributeError):
|
except (OperationalError, AttributeError):
|
||||||
_db_V01_00()
|
_db_V01_00()
|
||||||
|
|
||||||
versionen = sorted(filter(lambda f: f.startswith("_db_V"), globals()))
|
|
||||||
|
|
||||||
for v in versionen:
|
for v in versionen:
|
||||||
if v > curr_dbversion:
|
if v > curr_dbversion:
|
||||||
globals()[v]()
|
globals()[v]()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
Geschäftslogik für den Tages-Workflow (Telefon-Akquise).
|
||||||
|
|
||||||
|
Aggregiert pro Kontaktadresse die für den Tages-Workflow relevanten
|
||||||
|
Informationen: letzte Gesprächsnotiz, aktuell gültiges Wiedervorlage-Datum
|
||||||
|
und dynamisch berechneter Erledigt-Status. Filter (Kunde, Status,
|
||||||
|
Wiedervorlage-Zeitraum) und die priorisierte Sortierung sind Teil der
|
||||||
|
Aggregation — der Client erhält eine fertige, gefilterte Liste.
|
||||||
|
|
||||||
|
Aufgerufen werden die Funktionen aus crm/routes.py; die Autorisierungs-
|
||||||
|
prüfung erfolgt dort über den Decorator authorisation_required.
|
||||||
|
|
||||||
|
:author: Mistral Vibe <vibe@mistral.ai>
|
||||||
|
:date: 2026-09-23
|
||||||
|
:version: 1.0
|
||||||
|
"""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from db import dbapp
|
||||||
|
from crm.model import Kontaktadresse, Projekt, ProjektUser
|
||||||
|
|
||||||
|
session = dbapp.session
|
||||||
|
|
||||||
|
|
||||||
|
def _naechste_woche_start_und_ende(heute: date) -> tuple[date, date]:
|
||||||
|
"""
|
||||||
|
Liefert Montag und Sonntag der ISO-Kalenderwoche, in die der
|
||||||
|
übergebene Tag fällt.
|
||||||
|
"""
|
||||||
|
jahr, woche, _ = heute.isocalendar()
|
||||||
|
montag = date.fromisocalendar(jahr, woche, 1)
|
||||||
|
sonntag = montag + timedelta(days=6)
|
||||||
|
return montag, sonntag
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_im_wiedervorlage_zeitraum(wiedervorlage_datum: date | None,
|
||||||
|
zeitraum: str | None,
|
||||||
|
heute: date) -> bool:
|
||||||
|
"""
|
||||||
|
Prüft, ob das Wiedervorlage-Datum in den angeforderten Zeitraum
|
||||||
|
fällt. Ohne Zeitraum (None) ist die Prüfung immer wahr.
|
||||||
|
|
||||||
|
Kontakte ohne wiedervorlage_datum fallen bei gesetztem Zeitraum
|
||||||
|
heraus — auch Kontakte ohne Gesprächsnotizen, da für sie kein
|
||||||
|
Wiedervorlage-Datum existiert.
|
||||||
|
"""
|
||||||
|
if not zeitraum:
|
||||||
|
return True
|
||||||
|
if wiedervorlage_datum is None:
|
||||||
|
return False
|
||||||
|
if zeitraum == "heute":
|
||||||
|
return wiedervorlage_datum == heute
|
||||||
|
if zeitraum == "3tage":
|
||||||
|
return heute <= wiedervorlage_datum <= heute + timedelta(days=3)
|
||||||
|
if zeitraum == "ueberfaellig":
|
||||||
|
return wiedervorlage_datum < heute
|
||||||
|
if zeitraum == "woche":
|
||||||
|
montag, sonntag = _naechste_woche_start_und_ende(heute)
|
||||||
|
return montag <= wiedervorlage_datum <= sonntag
|
||||||
|
# Unbekannte Zeitraum-Angaben wirken wie kein Filter.
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _sortierschlüssel(eintrag: dict, heute: date) -> tuple:
|
||||||
|
"""
|
||||||
|
Priorisierte Sortierung der Tages-Workflow-Liste (Konzept 2.2):
|
||||||
|
|
||||||
|
- Prio 0: überfällige Wiedervorlagen (wiedervorlage < heute),
|
||||||
|
aufsteigend nach Datum
|
||||||
|
- Prio 1: Kontakte ohne Gesprächsnotizen
|
||||||
|
- Prio 2: Rest (heute/zukünftig), aufsteigend nach Wiedervorlage;
|
||||||
|
Notizen ohne Wiedervorlage ans Ende von Prio 2
|
||||||
|
|
||||||
|
Innerhalb gleicher Priorität/Datum entscheidet der Kontaktname.
|
||||||
|
"""
|
||||||
|
wiedervorlage = eintrag["wiedervorlage_datum"]
|
||||||
|
hat_notizen = eintrag["letzte_notiz"] is not None
|
||||||
|
# wiedervorlage ist ein ISO-String (yyyy-MM-dd) und sortiert lexikalisch
|
||||||
|
# genauso wie das zugehörige date — daher String-Vergleich mit dem
|
||||||
|
# isoformat() des heutigen Tages.
|
||||||
|
heute_str = heute.isoformat()
|
||||||
|
if wiedervorlage is not None and wiedervorlage < heute_str:
|
||||||
|
prio = 0
|
||||||
|
elif not hat_notizen:
|
||||||
|
prio = 1
|
||||||
|
else:
|
||||||
|
prio = 2
|
||||||
|
# Ohne Wiedervorlage ans Ende der Prioritätsgruppe sortieren.
|
||||||
|
datum_key = wiedervorlage if wiedervorlage is not None else "9999-12-31"
|
||||||
|
return (prio, datum_key, eintrag["name"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_tages_workflow_list(user_id: int | None = None,
|
||||||
|
kunden_id: int | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
wiedervorlage: str | None = None) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Liefert die Tages-Workflow-Liste: pro Kontaktadresse ein Dictionary
|
||||||
|
mit den Kontakt- und Projektdaten, der letzten Gesprächsnotiz, dem
|
||||||
|
aktuell gültigen Wiedervorlage-Datum und dem Erledigt-Status.
|
||||||
|
|
||||||
|
Das aktuell gültige Wiedervorlage-Datum ist das wiedervorlage_datum
|
||||||
|
der jüngsten Gesprächsnotiz, die eines gesetzt hat (Reihenfolge nach
|
||||||
|
datum/uhrzeit_beginn absteigend). Spätere Notizen ohne Wiedervorlage
|
||||||
|
heben eine früher gesetzte nicht auf.
|
||||||
|
|
||||||
|
Ist user_id gesetzt, werden nur Kontaktadressen von Projekten geliefert,
|
||||||
|
denen der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-
|
||||||
|
Filterung). SysAdmin und AppAdmin übergeben user_id=None und sehen alle.
|
||||||
|
|
||||||
|
Filter (alle optional):
|
||||||
|
- kunden_id: nur Kontaktadressen von Projekten dieses Kunden
|
||||||
|
- status: 'offen' (nicht erledigte) oder 'erledigt'; alles andere
|
||||||
|
wirkt wie kein Filter
|
||||||
|
- wiedervorlage: 'heute', '3tage' (bis heute+3), 'ueberfaellig'
|
||||||
|
(< heute) oder 'woche' (ISO-Kalenderwoche des heutigen Tages)
|
||||||
|
"""
|
||||||
|
heute = date.today()
|
||||||
|
query = Kontaktadresse.query
|
||||||
|
if kunden_id is not None:
|
||||||
|
# Nur Kontaktadressen von Projekten des ausgewählten Kunden.
|
||||||
|
query = query.filter(
|
||||||
|
Kontaktadresse.projekt_id.in_(
|
||||||
|
dbapp.session.query(Projekt.id).filter(
|
||||||
|
Projekt.kunden_id == kunden_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if user_id is not None:
|
||||||
|
# Nur Kontaktadressen von Projekten, denen der Nutzer zugeordnet ist.
|
||||||
|
query = query.filter(
|
||||||
|
Kontaktadresse.projekt_id.in_(
|
||||||
|
dbapp.session.query(ProjektUser.projekt_id).filter(
|
||||||
|
ProjektUser.user_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
kontaktadressen = query.all()
|
||||||
|
|
||||||
|
eintraege = []
|
||||||
|
for kontaktadresse in kontaktadressen:
|
||||||
|
# Gesprächsnotizen des Kontakts, jüngste zuerst. uhrzeit_beginn
|
||||||
|
# ist ein 'HH:MM'-String und sortiert lexikalisch korrekt.
|
||||||
|
logs = sorted(
|
||||||
|
kontaktadresse.call_logs,
|
||||||
|
key=lambda log: (log.datum or date.min, log.uhrzeit_beginn or ""),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
letzte_notiz = logs[0] if logs else None
|
||||||
|
# Aktuell gültiges Wiedervorlage-Datum: jüngste Notiz, die
|
||||||
|
# eines gesetzt hat.
|
||||||
|
wiedervorlage_datum = next(
|
||||||
|
(log.wiedervorlage_datum for log in logs
|
||||||
|
if log.wiedervorlage_datum is not None),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
ist_erledigt = kontaktadresse.is_erledigt()
|
||||||
|
|
||||||
|
# Status-Filter: 'offen' bzw. 'erledigt'; alles andere wirkt
|
||||||
|
# wie kein Filter.
|
||||||
|
if status == "offen" and ist_erledigt:
|
||||||
|
continue
|
||||||
|
if status == "erledigt" and not ist_erledigt:
|
||||||
|
continue
|
||||||
|
# Wiedervorlage-Zeitraum-Filter.
|
||||||
|
if not _ist_im_wiedervorlage_zeitraum(wiedervorlage_datum,
|
||||||
|
wiedervorlage, heute):
|
||||||
|
continue
|
||||||
|
|
||||||
|
projekt = kontaktadresse.projekt
|
||||||
|
eintrag = {
|
||||||
|
'kontaktadresse_id': kontaktadresse.id,
|
||||||
|
'projekt_id': kontaktadresse.projekt_id,
|
||||||
|
'projekt_name': projekt.projektname if projekt else None,
|
||||||
|
'kunden_id': projekt.kunden_id if projekt else None,
|
||||||
|
'kunden_name': projekt.kunde.name if projekt and projekt.kunde else None,
|
||||||
|
'anrede': kontaktadresse.anrede,
|
||||||
|
'name': kontaktadresse.name,
|
||||||
|
'firma': kontaktadresse.firma,
|
||||||
|
'position': kontaktadresse.position,
|
||||||
|
'telefon': kontaktadresse.telefon,
|
||||||
|
'email': kontaktadresse.email,
|
||||||
|
'is_erledigt': ist_erledigt,
|
||||||
|
# Text der letzten Gesprächsnotiz (leerer String, falls der
|
||||||
|
# jüngste Log keinen Notiztext enthält) bzw. None, wenn der
|
||||||
|
# Kontakt noch keine Gesprächsnotiz hat.
|
||||||
|
'letzte_notiz': (letzte_notiz.notiz or "") if letzte_notiz else None,
|
||||||
|
'letzte_notiz_datum': letzte_notiz.datum.isoformat() if letzte_notiz and letzte_notiz.datum else None,
|
||||||
|
'letzte_notiz_uhrzeit': letzte_notiz.uhrzeit_beginn if letzte_notiz else None,
|
||||||
|
# Tags der letzten Gesprächsnotiz (für die Listen-Anzeige);
|
||||||
|
# leere Liste, wenn der Kontakt noch keine Notiz hat.
|
||||||
|
'letzte_notiz_tags': (
|
||||||
|
[clt.tag.to_dict() for clt in letzte_notiz.tags_rel]
|
||||||
|
if letzte_notiz else []
|
||||||
|
),
|
||||||
|
'wiedervorlage_datum': wiedervorlage_datum.isoformat() if wiedervorlage_datum else None,
|
||||||
|
}
|
||||||
|
eintraege.append(eintrag)
|
||||||
|
|
||||||
|
# Priorisierte Sortierung: überfällige Wiedervorlagen zuerst, dann
|
||||||
|
# Kontakte ohne Gesprächsnotizen, dann der Rest nach Wiedervorlage.
|
||||||
|
eintraege.sort(key=lambda eintrag: _sortierschlüssel(eintrag, heute))
|
||||||
|
return eintraege
|
||||||
@@ -27,6 +27,8 @@ from crm.model import projekt as projekte
|
|||||||
from crm.model import projekt_user as projekt_users
|
from crm.model import projekt_user as projekt_users
|
||||||
from crm.model import kontaktadresse as kontaktadressen
|
from crm.model import kontaktadresse as kontaktadressen
|
||||||
from crm.model import call_log as call_logs
|
from crm.model import call_log as call_logs
|
||||||
|
from crm.model import tages_workflow
|
||||||
|
from crm.model import tag as tags
|
||||||
|
|
||||||
print("crm-routes")
|
print("crm-routes")
|
||||||
|
|
||||||
@@ -331,11 +333,15 @@ def kontaktadresse_delete():
|
|||||||
@app.get("/xhr/calllog/list")
|
@app.get("/xhr/calllog/list")
|
||||||
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
||||||
def calllog_list():
|
def calllog_list():
|
||||||
"""Liefert Gesprächsnotizen als Liste. Optional nach projekt_id gefiltert.
|
"""Liefert Gesprächsnotizen als Liste. Optional nach projekt_id und/oder
|
||||||
Für Nicht-SysAdmin/Nicht-AppAdmin nur Notizen sichtbarer Projekte."""
|
kontaktadresse_id gefiltert. Für Nicht-SysAdmin/Nicht-AppAdmin nur
|
||||||
|
Notizen sichtbarer Projekte."""
|
||||||
projekt_id = request.args.get("projekt_id", type=int)
|
projekt_id = request.args.get("projekt_id", type=int)
|
||||||
|
kontaktadresse_id = request.args.get("kontaktadresse_id", type=int)
|
||||||
user_id = _get_visible_user_id()
|
user_id = _get_visible_user_id()
|
||||||
result = [l.to_dict() for l in call_logs.get_call_logs(projekt_id, user_id)]
|
result = [l.to_dict() for l in call_logs.get_call_logs(
|
||||||
|
projekt_id, user_id, kontaktadresse_id
|
||||||
|
)]
|
||||||
return jsonify(result), 200
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -353,9 +359,10 @@ def calllog_get():
|
|||||||
@app.post("/xhr/calllog/create")
|
@app.post("/xhr/calllog/create")
|
||||||
@authorisation_required([KUNDEN_MANAGER])
|
@authorisation_required([KUNDEN_MANAGER])
|
||||||
def calllog_create():
|
def calllog_create():
|
||||||
"""Legt eine neue Gesprächsnotiz an. ID wird serverseitig vergeben."""
|
"""Legt eine neue Gesprächsnotiz an. ID wird serverseitig vergeben.
|
||||||
|
current_user.id wird als Ersteller (user_id) erfasst."""
|
||||||
_require_projekt_access(int(request.json["projekt_id"]))
|
_require_projekt_access(int(request.json["projekt_id"]))
|
||||||
call_log = call_logs.create_call_log(request.json)
|
call_log = call_logs.create_call_log(request.json, current_user.id)
|
||||||
return jsonify(call_log.to_dict()), 200
|
return jsonify(call_log.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -380,4 +387,88 @@ def calllog_delete():
|
|||||||
return jsonify({}), 200
|
return jsonify({}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Tages-Workflow #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/xhr/workflow/list")
|
||||||
|
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
||||||
|
def workflow_list():
|
||||||
|
"""Tages-Workflow-Liste: Kontaktadressen mit letzter Notiz, Wiedervorlage
|
||||||
|
und dynamisch berechnetem Status. Optional gefiltert nach kunden_id,
|
||||||
|
status ('offen'/'erledigt') und wiedervorlage ('heute'/'3tage'/
|
||||||
|
'ueberfaellig'/'woche'). Für Nicht-SysAdmin/Nicht-AppAdmin nur
|
||||||
|
Kontaktadressen sichtbarer Projekte. Die Sortierung ist priorisiert:
|
||||||
|
überfällige Wiedervorlagen, dann Kontakte ohne Notizen, dann der Rest."""
|
||||||
|
user_id = _get_visible_user_id()
|
||||||
|
result = tages_workflow.get_tages_workflow_list(
|
||||||
|
user_id,
|
||||||
|
request.args.get("kunden_id", type=int),
|
||||||
|
request.args.get("status"),
|
||||||
|
request.args.get("wiedervorlage"),
|
||||||
|
)
|
||||||
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Tag (hierarchische Taxonomie) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 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")
|
print("########## crm.routes importiert")
|
||||||
@@ -1,20 +1,98 @@
|
|||||||
from waitress import serve
|
import os
|
||||||
from werkzeug import serving
|
import signal
|
||||||
from approot import init_app
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
def run_server():
|
def _is_debug_requested() -> bool:
|
||||||
try:
|
"""
|
||||||
if app.config["DEBUG"] is False:
|
True, wenn -D übergeben wurde oder APP_DEBUG gesetzt ist.
|
||||||
_server = "waitress"
|
|
||||||
# serve(app, listen='nginx-proxy:8080') # Funktioniert aus unbekannten Gründen nicht. Der Hostname wird im Docker Container nicht korrekt aufgelöst
|
-D erlaubt den Dev-Start direkt von der Befehlszeile — ohne Devcontainer
|
||||||
serve(app, listen='*:8080')
|
und ohne Env-Variable setzen zu müssen. APP_DEBUG steuert den
|
||||||
else:
|
Devcontainer-Betrieb (docker-compose.dev.yaml).
|
||||||
_server = "werkzeug"
|
"""
|
||||||
serving.run_simple("0.0.0.0", 8080, app, use_reloader=True)
|
if "-D" in sys.argv[1:]:
|
||||||
except Exception as ex:
|
return True
|
||||||
print(str(ex))
|
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__':
|
if __name__ == '__main__':
|
||||||
app = init_app()
|
_debug = _is_debug_requested()
|
||||||
run_server()
|
if _debug and os.environ.get("KBP_DEV_SUPERVISED") != "true":
|
||||||
|
# Dev-Modus und noch nicht beaufsichtigt: Supervisor werden
|
||||||
|
# (im Devcontainer ist das PID 1 und hält den Container am Leben)
|
||||||
|
run_dev_supervised()
|
||||||
|
else:
|
||||||
|
# Prod-Modus, oder wir sind bereits der beaufsichtigte
|
||||||
|
# Dev-Subprozess (der Supervisor setzt KBP_DEV_SUPERVISED):
|
||||||
|
# als eigentlicher Server starten.
|
||||||
|
run_server(_debug)
|
||||||
@@ -1,16 +1,7 @@
|
|||||||
# Python virtual Environment anlegen
|
#!/bin/bash
|
||||||
cd /usr/opt/pythonapp
|
# Startet den Python-Server im Prod-Modus.
|
||||||
# Wenn es die Datei pyvenv.cfg im Unterverzeichnis env nocht nicht gibt, muss das
|
# pip install ist nicht mehr noetig — Abhaengigkeiten sind im Image.
|
||||||
# virtuelle Environment angelegt werden
|
# Kein cd noetig — alle Pfade sind absolut.
|
||||||
# if [ ! -s env/pyvenv.cfg ]
|
# Python-Imports funktionieren ueber sys.path[0] (Verzeichnis des Scripts).
|
||||||
# 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."
|
|
||||||
|
|
||||||
echo "Starting Python-Server..."
|
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
|
||||||
+224
-52
@@ -1,13 +1,17 @@
|
|||||||
import m from "mithril"
|
import m from "mithril"
|
||||||
import {Form, iForm} from "./widgets/form"
|
import {Form, iForm} from "./widgets/form"
|
||||||
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
|
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 {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||||
|
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
|
||||||
import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCallLogREST} from "../models/call_log"
|
import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCallLogREST} from "../models/call_log"
|
||||||
import {iKunde, fetchKunden} from "../models/kunde"
|
import {iKunde, fetchKunden} from "../models/kunde"
|
||||||
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
|
import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
|
||||||
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
|
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
|
||||||
|
import {iTag, fetchTags, berechneWiedervorlageDatum} from "../models/tag"
|
||||||
|
import {TagBadge} from "./tagbadge"
|
||||||
import {showServerError} from "./auth/authmessages"
|
import {showServerError} from "./auth/authmessages"
|
||||||
|
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||||
|
|
||||||
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
|
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
|
||||||
const DAUER_UPDATE_INTERVALL = 1000
|
const DAUER_UPDATE_INTERVALL = 1000
|
||||||
@@ -21,11 +25,20 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
|
|||||||
// da es nicht in den Route-Params enthalten ist.
|
// da es nicht in den Route-Params enthalten ist.
|
||||||
let projektName: string = ""
|
let projektName: string = ""
|
||||||
let kundenName: string = ""
|
let kundenName: string = ""
|
||||||
|
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||||
|
let breadcrumbs: iBreadcrumb[] = []
|
||||||
|
|
||||||
const handleRowMarked = (node: iTreenode) => {
|
const handleRowMarked = (node: iTreenode) => {
|
||||||
selectedNode = node
|
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 addCallLogRoute = () => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const datum = now.toISOString().split('T')[0]
|
const datum = now.toISOString().split('T')[0]
|
||||||
@@ -40,32 +53,47 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
|
|||||||
telefon: "",
|
telefon: "",
|
||||||
notiz: "",
|
notiz: "",
|
||||||
wiedervorlage_datum: "",
|
wiedervorlage_datum: "",
|
||||||
erledigt: false
|
from_liste: "1"
|
||||||
}
|
}
|
||||||
if (filterProjektId !== null) {
|
if (filterProjektId !== null) {
|
||||||
params.projekt_id = filterProjektId
|
params.projekt_id = filterProjektId
|
||||||
params.projekt_name = projektName
|
params.projekt_name = projektName
|
||||||
params.kunden_name = kundenName
|
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)
|
m.route.set("/call_log", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
const editCallLog = (log: iCallLog, e: Event) => {
|
const editCallLog = (log: iCallLog, e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
m.route.set("/call_log", {id: log.id})
|
const params: any = {id: log.id, from_liste: "1"}
|
||||||
|
if (filterProjektId !== null) {
|
||||||
|
params.return_projekt_id = filterProjektId
|
||||||
|
params.return_projekt_name = projektName
|
||||||
|
}
|
||||||
|
// Breadcrumb-Kette um eigene Position erweitern.
|
||||||
|
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
|
||||||
|
m.route.set("/call_log", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
||||||
let log: iCallLog = node.nodevalue
|
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 [
|
return [
|
||||||
m("div", m("strong", log.kunden_name + ": " + log.projekt_name)),
|
m("div", m("strong", log.kunden_name + ": " + log.projekt_name)),
|
||||||
m("span", log.datum),
|
m("span", log.datum),
|
||||||
m("span", log.uhrzeit_beginn),
|
m("span", log.uhrzeit_beginn),
|
||||||
m("span", log.dauer + " min"),
|
m("span", log.dauer + " min"),
|
||||||
m("span", log.betreff),
|
m("span", log.betreff, ...tagBadges),
|
||||||
m("span", log.wiedervorlage_datum),
|
m("span", log.wiedervorlage_datum),
|
||||||
m("span", log.erledigt ? "Erledigt" : "Offen"),
|
|
||||||
m("button.w3-button.w3-small", {title: "Gesprächsnotiz bearbeiten", style: {padding: '0 2px'}, onclick: (e: Event) => editCallLog(log, e)}, m("i.material-icons", "edit"))
|
m("button.w3-button.w3-small", {title: "Gesprächsnotiz bearbeiten", style: {padding: '0 2px'}, onclick: (e: Event) => editCallLog(log, e)}, m("i.material-icons", "edit"))
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -93,6 +121,8 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
|
|||||||
filterProjektId = null
|
filterProjektId = null
|
||||||
projektName = ""
|
projektName = ""
|
||||||
}
|
}
|
||||||
|
// Breadcrumb-Kette aus Route-Param parsen.
|
||||||
|
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||||
// Gesprächsnotizen asynchron vom Server laden. Die Filterung
|
// Gesprächsnotizen asynchron vom Server laden. Die Filterung
|
||||||
// nach projekt_id erfolgt serverseitig, sofern ein Filter
|
// nach projekt_id erfolgt serverseitig, sofern ein Filter
|
||||||
// gesetzt ist.
|
// gesetzt ist.
|
||||||
@@ -118,6 +148,10 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
|
|||||||
height: 'inherit',
|
height: 'inherit',
|
||||||
'overflow-y': 'inherit'}},
|
'overflow-y': 'inherit'}},
|
||||||
m("h1", projektName ? "Gesprächsnotizen für " + projektName : "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("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||||
m(Treetable,
|
m(Treetable,
|
||||||
{
|
{
|
||||||
@@ -143,6 +177,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
let currentLog: {
|
let currentLog: {
|
||||||
id: number
|
id: number
|
||||||
projekt_id: number
|
projekt_id: number
|
||||||
|
kontaktadresse_id: number
|
||||||
projekt_name: string
|
projekt_name: string
|
||||||
kunden_name: string
|
kunden_name: string
|
||||||
datum: string
|
datum: string
|
||||||
@@ -153,10 +188,10 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
telefon: string
|
telefon: string
|
||||||
notiz: string
|
notiz: string
|
||||||
wiedervorlage_datum: string
|
wiedervorlage_datum: string
|
||||||
erledigt: boolean
|
|
||||||
} = {
|
} = {
|
||||||
id: -1,
|
id: -1,
|
||||||
projekt_id: -1,
|
projekt_id: -1,
|
||||||
|
kontaktadresse_id: -1,
|
||||||
projekt_name: "",
|
projekt_name: "",
|
||||||
kunden_name: "",
|
kunden_name: "",
|
||||||
datum: "",
|
datum: "",
|
||||||
@@ -166,8 +201,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
name_ansprechpartner: "",
|
name_ansprechpartner: "",
|
||||||
telefon: "",
|
telefon: "",
|
||||||
notiz: "",
|
notiz: "",
|
||||||
wiedervorlage_datum: "",
|
wiedervorlage_datum: ""
|
||||||
erledigt: false
|
|
||||||
}
|
}
|
||||||
// Ist das Projekt voreingestellt (Liste wurde projektgefiltert)?
|
// Ist das Projekt voreingestellt (Liste wurde projektgefiltert)?
|
||||||
let projektVoreingestellt: boolean = false
|
let projektVoreingestellt: boolean = false
|
||||||
@@ -183,6 +217,42 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
let kundenListe: iKunde[] = []
|
let kundenListe: iKunde[] = []
|
||||||
let projektListe: iProjekt[] = []
|
let projektListe: iProjekt[] = []
|
||||||
let kontaktadressenListe: iKontaktadresse[] = []
|
let kontaktadressenListe: iKontaktadresse[] = []
|
||||||
|
// Im Auswahl-Modus (nicht vorbelegt): gewählte Kunden-ID für die
|
||||||
|
// abhängige Projekt-Filterung.
|
||||||
|
let selectedKundenId: number = -1
|
||||||
|
// Asynchron geladene Tag-Liste (nur zuordenbare, nicht is_parent).
|
||||||
|
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.
|
* Formatiert verstrichene Sekunden als mm:ss.
|
||||||
@@ -197,12 +267,23 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
oninit: (vnode) => {
|
oninit: (vnode) => {
|
||||||
const routeParams = m.route.param()
|
const routeParams = m.route.param()
|
||||||
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
|
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) {
|
if (id !== -1) {
|
||||||
// Gesprächsnotiz frisch vom Server holen.
|
// Gesprächsnotiz frisch vom Server holen.
|
||||||
fetchCallLogById(id).then((result) => {
|
fetchCallLogById(id).then((result) => {
|
||||||
currentLog = result
|
currentLog = result
|
||||||
projektVoreingestellt = true
|
projektVoreingestellt = true
|
||||||
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
|
ansprechpartnerVoreingestellt = currentLog.kontaktadresse_id > 0
|
||||||
|
selectedTagIds = (result.tags || []).map((t: iTag) => t.id)
|
||||||
}).catch((e) => showServerError(e))
|
}).catch((e) => showServerError(e))
|
||||||
} else {
|
} else {
|
||||||
projektVoreingestellt = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) !== -1 : false
|
projektVoreingestellt = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) !== -1 : false
|
||||||
@@ -217,8 +298,10 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
currentLog.telefon = routeParams && routeParams.telefon ? routeParams.telefon : ""
|
currentLog.telefon = routeParams && routeParams.telefon ? routeParams.telefon : ""
|
||||||
currentLog.notiz = routeParams && routeParams.notiz ? routeParams.notiz : ""
|
currentLog.notiz = routeParams && routeParams.notiz ? routeParams.notiz : ""
|
||||||
currentLog.wiedervorlage_datum = routeParams && routeParams.wiedervorlage_datum ? routeParams.wiedervorlage_datum : ""
|
currentLog.wiedervorlage_datum = routeParams && routeParams.wiedervorlage_datum ? routeParams.wiedervorlage_datum : ""
|
||||||
currentLog.erledigt = routeParams && (routeParams.erledigt === true || routeParams.erledigt === "true")
|
// Kontaktadresse-ID aus den Route-Params übernehmen, wenn die
|
||||||
ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
|
// Maske aus der Kontaktadressenliste geöffnet wurde.
|
||||||
|
currentLog.kontaktadresse_id = routeParams && routeParams.kontaktadresse_id ? parseInt(routeParams.kontaktadresse_id) : -1
|
||||||
|
ansprechpartnerVoreingestellt = currentLog.kontaktadresse_id > 0
|
||||||
// Bei Neuanlage Startzeit merken und Dauer-Timer starten.
|
// Bei Neuanlage Startzeit merken und Dauer-Timer starten.
|
||||||
startZeit = new Date()
|
startZeit = new Date()
|
||||||
timerId = window.setInterval(() => {
|
timerId = window.setInterval(() => {
|
||||||
@@ -234,28 +317,20 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
fetchKunden().then((result) => { kundenListe = result }).catch((e) => showServerError(e))
|
fetchKunden().then((result) => { kundenListe = result }).catch((e) => showServerError(e))
|
||||||
fetchProjekte().then((result) => { projektListe = result }).catch((e) => showServerError(e))
|
fetchProjekte().then((result) => { projektListe = result }).catch((e) => showServerError(e))
|
||||||
fetchKontaktadressen().then((result) => { kontaktadressenListe = 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) => {
|
onremove: (vnode) => {
|
||||||
if (timerId != null) { window.clearInterval(timerId) }
|
if (timerId != null) { window.clearInterval(timerId) }
|
||||||
|
guard.cleanup()
|
||||||
},
|
},
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
// kunden_id aus dem geladenen Projekt ableiten (fuer abhaengiges
|
|
||||||
// Projekt-Select). Wird nur bei nicht voreingestelltem Projekt
|
|
||||||
// benoetigt.
|
|
||||||
const internKundenId = (() => {
|
|
||||||
if (currentLog.projekt_id !== -1) {
|
|
||||||
const p = projektListe.find((p: iProjekt) => p.id === currentLog.projekt_id)
|
|
||||||
return p ? p.kunden_id : -1
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
})()
|
|
||||||
|
|
||||||
const kundenOptionen: iSelectOption[] = kundenListe.map((k: iKunde) => {
|
const kundenOptionen: iSelectOption[] = kundenListe.map((k: iKunde) => {
|
||||||
return {value: k.id, label: k.name}
|
return {value: k.id, label: k.name}
|
||||||
})
|
})
|
||||||
// Projekt-Optionen sind vom gewählten Kunden abhängig.
|
// Projekt-Optionen sind vom gewählten Kunden abhängig.
|
||||||
const projektOptionen: iSelectOption[] = projektListe
|
const projektOptionen: iSelectOption[] = projektListe
|
||||||
.filter((p: iProjekt) => internKundenId === -1 || p.kunden_id === internKundenId)
|
.filter((p: iProjekt) => selectedKundenId === -1 || p.kunden_id === selectedKundenId)
|
||||||
.map((p: iProjekt) => {
|
.map((p: iProjekt) => {
|
||||||
return {value: p.id, label: p.projektname}
|
return {value: p.id, label: p.projektname}
|
||||||
})
|
})
|
||||||
@@ -267,58 +342,124 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
|
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
// Tag-Optionen und Farb-Map für die Mehrfachauswahl. Als content
|
||||||
|
// wird das fertige TagBadge übergeben — das Auswahl-Modal zeigt
|
||||||
|
// damit den Farbchip mit Namen und die Workflow-Icons.
|
||||||
|
const tagOptionen: iSelectOption[] = tagListe.map((t: iTag) => {
|
||||||
|
return {value: t.id, label: t.name, content: m(TagBadge, {tagData: t})}
|
||||||
|
})
|
||||||
|
const tagColors: Map<number, string> = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string]))
|
||||||
|
|
||||||
const submit = (form: HTMLFormElement): boolean => {
|
const submit = (form: HTMLFormElement): boolean => {
|
||||||
const formData = new FormData(form)
|
const formData = new FormData(form)
|
||||||
const gespeichertesLog: iCallLog = {
|
const gespeichertesLog: iCallLog = {
|
||||||
id: currentLog.id,
|
id: currentLog.id,
|
||||||
projekt_id: currentLog.projekt_id,
|
projekt_id: projektVoreingestellt
|
||||||
|
? currentLog.projekt_id
|
||||||
|
: parseInt((formData.get("projekt_id_select") as string) || "-1"),
|
||||||
|
// Pflichtfeld: voreingestellt aus dem currentLog, sonst
|
||||||
|
// aus dem Select-Widget (kontaktadresse_id ist im Server
|
||||||
|
// ein Pflichtfeld, ohne wird Bad Request geliefert).
|
||||||
|
kontaktadresse_id: ansprechpartnerVoreingestellt
|
||||||
|
? currentLog.kontaktadresse_id
|
||||||
|
: parseInt((formData.get("kontaktadresse_id") as string) || "-1"),
|
||||||
|
// Ersteller wird serverseitig aus current_user.id
|
||||||
|
// gesetzt — hier nur Platzhalter, mitarbeiter_name
|
||||||
|
// wird ausschließlich vom Server geliefert.
|
||||||
|
user_id: null,
|
||||||
|
mitarbeiter_name: null,
|
||||||
projekt_name: "",
|
projekt_name: "",
|
||||||
kunden_name: "",
|
kunden_name: "",
|
||||||
datum: (formData.get("datum") as string) || "",
|
datum: currentLog.datum || "",
|
||||||
uhrzeit_beginn: (formData.get("uhrzeit_beginn") as string) || "",
|
uhrzeit_beginn: (formData.get("uhrzeit_beginn") as string) || "",
|
||||||
dauer: (formData.get("dauer") as string) || "",
|
dauer: (formData.get("dauer") as string) || "",
|
||||||
betreff: (formData.get("betreff") as string) || "",
|
betreff: (formData.get("betreff") as string) || "",
|
||||||
name_ansprechpartner: (formData.get("name_ansprechpartner") as string) || "",
|
name_ansprechpartner: (formData.get("name_ansprechpartner") as string) || "",
|
||||||
telefon: (formData.get("telefon") as string) || "",
|
telefon: (formData.get("telefon") as string) || "",
|
||||||
notiz: (formData.get("notiz") as string) || "",
|
notiz: (formData.get("notiz") as string) || "",
|
||||||
wiedervorlage_datum: (formData.get("wiedervorlage_datum") as string) || "",
|
wiedervorlage_datum: currentLog.wiedervorlage_datum || "",
|
||||||
erledigt: formData.get("erledigt") === "on"
|
tag_ids: formData.getAll("tag_ids").map((v) => parseInt(v as string)).filter((n) => !isNaN(n))
|
||||||
}
|
}
|
||||||
if (currentLog.id === -1) {
|
if (currentLog.id === -1) {
|
||||||
createCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})).catch((e) => showServerError(e))
|
createCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||||
} else {
|
} else {
|
||||||
updateCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})).catch((e) => showServerError(e))
|
updateCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const cancel = (e: Event) => {
|
|
||||||
m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name})
|
|
||||||
}
|
|
||||||
return m("div.w3-container.w3-auto.form-view-container",
|
return m("div.w3-container.w3-auto.form-view-container",
|
||||||
m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"),
|
m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"),
|
||||||
|
m(BreadcrumbBar, {
|
||||||
|
breadcrumbs: breadcrumbs,
|
||||||
|
current: currentLog.id === -1 ? "Neue Gesprächsnotiz" : "Gesprächsnotiz",
|
||||||
|
onnavigate: guardedNavigate
|
||||||
|
} as iBreadcrumbBar),
|
||||||
m("div", {style: {flex: '1', 'min-height': '0'}},
|
m("div", {style: {flex: '1', 'min-height': '0'}},
|
||||||
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
|
m(Form, guard.formAttrs(submit) as iForm, [
|
||||||
m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}),
|
projektVoreingestellt
|
||||||
// Kunde und Projekt als Read-Only-Text, da voreingestellt.
|
? m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id})
|
||||||
m("label", "Kunde",
|
: null,
|
||||||
m("input.w3-input.w3-border", {
|
// Kunde und Projekt: vorbelegt als Read-Only-Text,
|
||||||
type: "text",
|
// nicht vorbelegt als auswählbare Select-Widgets.
|
||||||
value: currentLog.kunden_name,
|
projektVoreingestellt
|
||||||
readonly: true,
|
? [
|
||||||
style: {'margin-bottom': '5px'}
|
m("label", "Kunde",
|
||||||
})),
|
m("input.w3-input.w3-border", {
|
||||||
m("label", "Projekt",
|
type: "text",
|
||||||
m("input.w3-input.w3-border", {
|
value: currentLog.kunden_name,
|
||||||
type: "text",
|
readonly: true,
|
||||||
value: currentLog.projekt_name,
|
style: {'margin-bottom': '5px'}
|
||||||
readonly: true,
|
})),
|
||||||
style: {'margin-bottom': '5px'}
|
m("label", "Projekt",
|
||||||
})),
|
m("input.w3-input.w3-border", {
|
||||||
|
type: "text",
|
||||||
|
value: currentLog.projekt_name,
|
||||||
|
readonly: true,
|
||||||
|
style: {'margin-bottom': '5px'}
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
m("label", "Kunde", m(Select, {
|
||||||
|
name: "kunden_id",
|
||||||
|
value: selectedKundenId,
|
||||||
|
options: kundenOptionen,
|
||||||
|
required: true,
|
||||||
|
onchanged: (kundenId: number) => {
|
||||||
|
selectedKundenId = kundenId
|
||||||
|
// Projekt zurücksetzen, da es zum
|
||||||
|
// alten Kunden gehörte.
|
||||||
|
currentLog.projekt_id = -1
|
||||||
|
currentLog.projekt_name = ""
|
||||||
|
currentLog.kunden_name = kundenListe.find(k => k.id === kundenId)?.name || ""
|
||||||
|
guard.markDirty()
|
||||||
|
}
|
||||||
|
} as iSelect)),
|
||||||
|
m("label", "Projekt", m(Select, {
|
||||||
|
name: "projekt_id_select",
|
||||||
|
value: currentLog.projekt_id,
|
||||||
|
options: projektOptionen,
|
||||||
|
required: true,
|
||||||
|
onchanged: (projektId: number) => {
|
||||||
|
const p = projektListe.find(pr => pr.id === projektId)
|
||||||
|
currentLog.projekt_id = projektId
|
||||||
|
currentLog.projekt_name = p?.projektname || ""
|
||||||
|
currentLog.kunden_name = p?.kunden_name || ""
|
||||||
|
guard.markDirty()
|
||||||
|
}
|
||||||
|
} as iSelect))
|
||||||
|
],
|
||||||
// Datum, Startzeit und Dauer in einer Zeile.
|
// Datum, Startzeit und Dauer in einer Zeile.
|
||||||
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
|
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
|
||||||
m("label", {style: {flex: '1'}}, "Datum",
|
m("label", {style: {flex: '1'}}, "Datum",
|
||||||
m(Input, {name: "datum", value: currentLog.datum, type: "date"} as iInput)),
|
m(Input, {
|
||||||
|
name: "datum",
|
||||||
|
value: currentLog.datum,
|
||||||
|
type: "date",
|
||||||
|
oninput: (e: Event) => {
|
||||||
|
currentLog.datum = (e.target as HTMLInputElement).value
|
||||||
|
guard.markDirty()
|
||||||
|
}
|
||||||
|
} as iInput)),
|
||||||
m("label", {style: {flex: '1'}}, "Uhrzeit Beginn",
|
m("label", {style: {flex: '1'}}, "Uhrzeit Beginn",
|
||||||
m(Input, {name: "uhrzeit_beginn", value: currentLog.uhrzeit_beginn, type: "time"} as iInput)),
|
m(Input, {name: "uhrzeit_beginn", value: currentLog.uhrzeit_beginn, type: "time"} as iInput)),
|
||||||
m("label", {style: {flex: '1'}}, "Dauer (mm:ss)",
|
m("label", {style: {flex: '1'}}, "Dauer (mm:ss)",
|
||||||
@@ -341,11 +482,13 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
name: "kontaktadresse_id",
|
name: "kontaktadresse_id",
|
||||||
value: -1,
|
value: -1,
|
||||||
options: kontaktadressenOptionen,
|
options: kontaktadressenOptionen,
|
||||||
|
required: true,
|
||||||
onchanged: (kontaktadresseId: number) => {
|
onchanged: (kontaktadresseId: number) => {
|
||||||
const ka = kontaktadressenListe.find((k: iKontaktadresse) => k.id === kontaktadresseId)
|
const ka = kontaktadressenListe.find((k: iKontaktadresse) => k.id === kontaktadresseId)
|
||||||
if (ka) {
|
if (ka) {
|
||||||
currentLog.name_ansprechpartner = ka.name
|
currentLog.name_ansprechpartner = ka.name
|
||||||
currentLog.telefon = ka.telefon
|
currentLog.telefon = ka.telefon
|
||||||
|
guard.markDirty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} as iSelect)),
|
} as iSelect)),
|
||||||
@@ -353,8 +496,37 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
|||||||
m(Input, {name: "telefon", value: currentLog.telefon, type: "tel"} as iInput))
|
m(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", "Notiz", m(Textarea, {name: "notiz", value: currentLog.notiz, rows: 4} as iTextarea)),
|
||||||
m("label", "Wiedervorlage Datum", m(Input, {name: "wiedervorlage_datum", value: currentLog.wiedervorlage_datum, type: "date"} as iInput)),
|
m("label", "Tags", m(MultiSelect, {
|
||||||
m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt")
|
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))
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import m from "mithril"
|
|||||||
import {Form, iForm} from "./widgets/form"
|
import {Form, iForm} from "./widgets/form"
|
||||||
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
|
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
|
||||||
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||||
|
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
|
||||||
import {iKontakt, fetchKontakte, fetchKontaktById, createKontaktREST, updateKontaktREST} from "../models/kontakt"
|
import {iKontakt, fetchKontakte, fetchKontaktById, createKontaktREST, updateKontaktREST} from "../models/kontakt"
|
||||||
import {iKunde, fetchKundeById} from "../models/kunde"
|
import {iKunde, fetchKundeById} from "../models/kunde"
|
||||||
import {showServerError} from "./auth/authmessages"
|
import {showServerError} from "./auth/authmessages"
|
||||||
|
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||||
|
|
||||||
export const kontaktliste: m.ClosureComponent = (vnode) => {
|
export const kontaktliste: m.ClosureComponent = (vnode) => {
|
||||||
let treenodes: iTreenode[] = []
|
let treenodes: iTreenode[] = []
|
||||||
@@ -13,11 +15,20 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
|
|||||||
// Kundenname für die Überschrift, asynchron per fetchKundeById
|
// Kundenname für die Überschrift, asynchron per fetchKundeById
|
||||||
// aufgelöst, falls nicht in den Route-Params enthalten.
|
// aufgelöst, falls nicht in den Route-Params enthalten.
|
||||||
let kundenName: string = ""
|
let kundenName: string = ""
|
||||||
|
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||||
|
let breadcrumbs: iBreadcrumb[] = []
|
||||||
|
|
||||||
const handleRowMarked = (node: iTreenode) => {
|
const handleRowMarked = (node: iTreenode) => {
|
||||||
selectedNode = node
|
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 addKontaktRoute = () => {
|
||||||
const params: any = {id: -1}
|
const params: any = {id: -1}
|
||||||
// Nur bei kundengefilterter Liste den Kunden vorbelegen.
|
// Nur bei kundengefilterter Liste den Kunden vorbelegen.
|
||||||
@@ -25,12 +36,15 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
|
|||||||
params.kunden_id = filterKundenId
|
params.kunden_id = filterKundenId
|
||||||
params.kunden_name = kundenName
|
params.kunden_name = kundenName
|
||||||
}
|
}
|
||||||
|
// Breadcrumb-Kette um eigene Position erweitern.
|
||||||
|
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
|
||||||
m.route.set("/kontakt", params)
|
m.route.set("/kontakt", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
const editKontakt = (kontakt: iKontakt, e: Event) => {
|
const editKontakt = (kontakt: iKontakt, e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
m.route.set("/kontakt", {id: kontakt.id})
|
// Breadcrumb-Kette um eigene Position erweitern.
|
||||||
|
m.route.set("/kontakt", {id: kontakt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
||||||
@@ -66,6 +80,8 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
|
|||||||
filterKundenId = null
|
filterKundenId = null
|
||||||
kundenName = ""
|
kundenName = ""
|
||||||
}
|
}
|
||||||
|
// Breadcrumb-Kette aus Route-Param parsen.
|
||||||
|
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||||
// Kontakte asynchron vom Server laden. Die Filterung nach
|
// Kontakte asynchron vom Server laden. Die Filterung nach
|
||||||
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
|
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
|
||||||
fetchKontakte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
|
fetchKontakte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
|
||||||
@@ -86,6 +102,10 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
|
|||||||
height: 'inherit',
|
height: 'inherit',
|
||||||
'overflow-y': 'inherit'}},
|
'overflow-y': 'inherit'}},
|
||||||
m("h1", kundenName ? "Kontaktliste für " + kundenName : "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("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||||
m(Treetable,
|
m(Treetable,
|
||||||
{
|
{
|
||||||
@@ -129,11 +149,27 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
|
|||||||
email: "",
|
email: "",
|
||||||
notiz: ""
|
notiz: ""
|
||||||
}
|
}
|
||||||
|
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||||
|
let breadcrumbs: iBreadcrumb[] = []
|
||||||
|
// Route-Params für die Rückkehr zur Kontaktliste; als Closure an den
|
||||||
|
// DirtyMaskHelper übergeben, da dieser die maskenspezifischen Variablen
|
||||||
|
// (currentKontakt, breadcrumbs) nicht kennt.
|
||||||
|
const backParams = (): any => {
|
||||||
|
const listBc = serializeBc(breadcrumbs.slice(0, -1))
|
||||||
|
const params: any = {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name}
|
||||||
|
if (listBc) params.bc = listBc
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
|
||||||
|
const guard = new DirtyMaskHelper("/kontaktliste", backParams)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
oninit: (vnode) => {
|
oninit: (vnode) => {
|
||||||
const routeParams = m.route.param()
|
const routeParams = m.route.param()
|
||||||
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
|
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) {
|
if (id !== -1) {
|
||||||
// Kontaktdaten frisch vom Server holen, um zwischenzeitliche
|
// Kontaktdaten frisch vom Server holen, um zwischenzeitliche
|
||||||
// Änderungen anderer Nutzer nicht zu überschreiben.
|
// Änderungen anderer Nutzer nicht zu überschreiben.
|
||||||
@@ -147,6 +183,9 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
|
|||||||
currentKontakt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
|
currentKontakt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onremove: (vnode) => {
|
||||||
|
guard.cleanup()
|
||||||
|
},
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
const submit = (form: HTMLFormElement): boolean => {
|
const submit = (form: HTMLFormElement): boolean => {
|
||||||
const formData = new FormData(form)
|
const formData = new FormData(form)
|
||||||
@@ -162,21 +201,23 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
|
|||||||
notiz: (formData.get("notiz") as string) || ""
|
notiz: (formData.get("notiz") as string) || ""
|
||||||
}
|
}
|
||||||
if (currentKontakt.id === -1) {
|
if (currentKontakt.id === -1) {
|
||||||
createKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})).catch((e) => showServerError(e))
|
createKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||||
} else {
|
} else {
|
||||||
updateKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})).catch((e) => showServerError(e))
|
updateKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const cancel = (e: Event) => {
|
|
||||||
m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})
|
|
||||||
}
|
|
||||||
return m("div.w3-container.w3-auto.form-view-container",
|
return m("div.w3-container.w3-auto.form-view-container",
|
||||||
m("h1", currentKontakt.id === -1
|
m("h1", currentKontakt.id === -1
|
||||||
? "Neuer Kontakt für " + currentKontakt.kunden_name
|
? "Neuer Kontakt für " + currentKontakt.kunden_name
|
||||||
: "Kontakt-Stammblatt"),
|
: "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("div", {style: {flex: '1', 'min-height': '0'}},
|
||||||
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
|
m(Form, guard.formAttrs(submit) as iForm, [
|
||||||
m("input", {type: "hidden", name: "kunden_id", value: currentKontakt.kunden_id}),
|
m("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", "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", "Name", m(Input, {name: "name", value: currentKontakt.name, type: "text", required: true} as iInput)),
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import m from "mithril"
|
|||||||
import {Form, iForm} from "./widgets/form"
|
import {Form, iForm} from "./widgets/form"
|
||||||
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
|
import { Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
|
||||||
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||||
|
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
|
||||||
import {iKontaktadresse, fetchKontaktadressen, fetchKontaktadresseById, createKontaktadresseREST, updateKontaktadresseREST} from "../models/kontaktadresse"
|
import {iKontaktadresse, fetchKontaktadressen, fetchKontaktadresseById, createKontaktadresseREST, updateKontaktadresseREST} from "../models/kontaktadresse"
|
||||||
import {iProjekt, fetchProjektById} from "../models/projekt"
|
import {iProjekt, fetchProjektById} from "../models/projekt"
|
||||||
import {showServerError} from "./auth/authmessages"
|
import {showServerError} from "./auth/authmessages"
|
||||||
|
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||||
|
|
||||||
export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
||||||
let treenodes: iTreenode[] = []
|
let treenodes: iTreenode[] = []
|
||||||
@@ -13,23 +15,35 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
|||||||
// Projektname für die Überschrift, asynchron per fetchProjektById
|
// Projektname für die Überschrift, asynchron per fetchProjektById
|
||||||
// aufgelöst, falls nicht in den Route-Params enthalten.
|
// aufgelöst, falls nicht in den Route-Params enthalten.
|
||||||
let projektName: string = ""
|
let projektName: string = ""
|
||||||
|
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||||
|
let breadcrumbs: iBreadcrumb[] = []
|
||||||
|
|
||||||
const handleRowMarked = (node: iTreenode) => {
|
const handleRowMarked = (node: iTreenode) => {
|
||||||
selectedNode = node
|
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 addKontaktadresseRoute = () => {
|
||||||
const params: any = {id: -1}
|
const params: any = {id: -1}
|
||||||
if (filterProjektId !== null) {
|
if (filterProjektId !== null) {
|
||||||
params.projekt_id = filterProjektId
|
params.projekt_id = filterProjektId
|
||||||
params.projekt_name = projektName
|
params.projekt_name = projektName
|
||||||
}
|
}
|
||||||
|
// Breadcrumb-Kette um eigene Position erweitern.
|
||||||
|
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
|
||||||
m.route.set("/kontaktadresse", params)
|
m.route.set("/kontaktadresse", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
const editKontaktadresse = (kontaktadresse: iKontaktadresse, e: Event) => {
|
const editKontaktadresse = (kontaktadresse: iKontaktadresse, e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
m.route.set("/kontaktadresse", {id: kontaktadresse.id})
|
// Breadcrumb-Kette um eigene Position erweitern.
|
||||||
|
m.route.set("/kontaktadresse", {id: kontaktadresse.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||||
}
|
}
|
||||||
|
|
||||||
const addCallLog = (kontaktadresse: iKontaktadresse, e: Event) => {
|
const addCallLog = (kontaktadresse: iKontaktadresse, e: Event) => {
|
||||||
@@ -37,10 +51,20 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const datum = now.toISOString().split('T')[0]
|
const datum = now.toISOString().split('T')[0]
|
||||||
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
|
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
|
||||||
|
// Breadcrumb-Kette um Kontaktadressenliste und Notizliste
|
||||||
|
// erweitern, da die Maske nach Speichern/Abbrechen in die
|
||||||
|
// projektgefilterte Notizliste zurückkehrt.
|
||||||
|
const chain = [...breadcrumbs, ownCrumb(), {
|
||||||
|
route: "/calllogliste",
|
||||||
|
params: {projekt_id: kontaktadresse.projekt_id},
|
||||||
|
label: "Notizen: " + kontaktadresse.projekt_name
|
||||||
|
} as iBreadcrumb]
|
||||||
m.route.set("/call_log", {
|
m.route.set("/call_log", {
|
||||||
id: -1,
|
id: -1,
|
||||||
projekt_id: kontaktadresse.projekt_id,
|
projekt_id: kontaktadresse.projekt_id,
|
||||||
projekt_name: kontaktadresse.projekt_name,
|
projekt_name: kontaktadresse.projekt_name,
|
||||||
|
// Kontaktadresse als Pflichtfeld an die Notizmaske durchreichen
|
||||||
|
kontaktadresse_id: kontaktadresse.id,
|
||||||
datum: datum,
|
datum: datum,
|
||||||
uhrzeit_beginn: uhrzeit,
|
uhrzeit_beginn: uhrzeit,
|
||||||
dauer: "",
|
dauer: "",
|
||||||
@@ -49,7 +73,7 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
|||||||
telefon: kontaktadresse.telefon,
|
telefon: kontaktadresse.telefon,
|
||||||
notiz: "",
|
notiz: "",
|
||||||
wiedervorlage_datum: "",
|
wiedervorlage_datum: "",
|
||||||
erledigt: false
|
bc: serializeBc(chain)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +116,8 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
|||||||
filterProjektId = null
|
filterProjektId = null
|
||||||
projektName = ""
|
projektName = ""
|
||||||
}
|
}
|
||||||
|
// Breadcrumb-Kette aus Route-Param parsen.
|
||||||
|
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||||
// Kontaktadressen asynchron vom Server laden. Die Filterung
|
// Kontaktadressen asynchron vom Server laden. Die Filterung
|
||||||
// nach projekt_id erfolgt serverseitig, sofern ein Filter
|
// nach projekt_id erfolgt serverseitig, sofern ein Filter
|
||||||
// gesetzt ist.
|
// gesetzt ist.
|
||||||
@@ -113,6 +139,10 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
|||||||
height: 'inherit',
|
height: 'inherit',
|
||||||
'overflow-y': 'inherit'}},
|
'overflow-y': 'inherit'}},
|
||||||
m("h1", projektName ? "Kontaktadressen für " + projektName : "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("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||||
m(Treetable,
|
m(Treetable,
|
||||||
{
|
{
|
||||||
@@ -166,11 +196,26 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
|||||||
land: "",
|
land: "",
|
||||||
notiz: ""
|
notiz: ""
|
||||||
}
|
}
|
||||||
|
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||||
|
let breadcrumbs: iBreadcrumb[] = []
|
||||||
|
// Route-Params für die Rückkehr zur Kontaktadressenliste; als Closure
|
||||||
|
// an den DirtyMaskHelper übergeben.
|
||||||
|
const backParams = (): any => {
|
||||||
|
const listBc = serializeBc(breadcrumbs.slice(0, -1))
|
||||||
|
const params: any = {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name}
|
||||||
|
if (listBc) params.bc = listBc
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
|
||||||
|
const guard = new DirtyMaskHelper("/kontaktadresseliste", backParams)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
oninit: (vnode) => {
|
oninit: (vnode) => {
|
||||||
const routeParams = m.route.param()
|
const routeParams = m.route.param()
|
||||||
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
|
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) {
|
if (id !== -1) {
|
||||||
// Kontaktadresse frisch vom Server holen.
|
// Kontaktadresse frisch vom Server holen.
|
||||||
fetchKontaktadresseById(id).then((result) => {
|
fetchKontaktadresseById(id).then((result) => {
|
||||||
@@ -182,6 +227,9 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
|||||||
currentKontaktadresse.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
|
currentKontaktadresse.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onremove: (vnode) => {
|
||||||
|
guard.cleanup()
|
||||||
|
},
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
const submit = (form: HTMLFormElement): boolean => {
|
const submit = (form: HTMLFormElement): boolean => {
|
||||||
const formData = new FormData(form)
|
const formData = new FormData(form)
|
||||||
@@ -189,6 +237,10 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
|||||||
id: currentKontaktadresse.id,
|
id: currentKontaktadresse.id,
|
||||||
projekt_id: currentKontaktadresse.projekt_id,
|
projekt_id: currentKontaktadresse.projekt_id,
|
||||||
projekt_name: "",
|
projekt_name: "",
|
||||||
|
projekt_zweck: "",
|
||||||
|
// Server-seitig aus den Relationships abgeleitet;
|
||||||
|
// Platzhalter beim Senden.
|
||||||
|
kunden_name: "",
|
||||||
anrede: (formData.get("anrede") as string) || "",
|
anrede: (formData.get("anrede") as string) || "",
|
||||||
name: (formData.get("name") as string) || "",
|
name: (formData.get("name") as string) || "",
|
||||||
firma: (formData.get("firma") as string) || "",
|
firma: (formData.get("firma") as string) || "",
|
||||||
@@ -199,24 +251,30 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
|||||||
plz: (formData.get("plz") as string) || "",
|
plz: (formData.get("plz") as string) || "",
|
||||||
ort: (formData.get("ort") as string) || "",
|
ort: (formData.get("ort") as string) || "",
|
||||||
land: (formData.get("land") as string) || "",
|
land: (formData.get("land") as string) || "",
|
||||||
notiz: (formData.get("notiz") as string) || ""
|
notiz: (formData.get("notiz") as string) || "",
|
||||||
|
// Server-seitig dynamisch berechnet; Platzhalter beim Senden.
|
||||||
|
is_erledigt: false
|
||||||
}
|
}
|
||||||
if (currentKontaktadresse.id === -1) {
|
if (currentKontaktadresse.id === -1) {
|
||||||
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})).catch((e) => showServerError(e))
|
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||||
} else {
|
} else {
|
||||||
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})).catch((e) => showServerError(e))
|
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const cancel = (e: Event) => {
|
|
||||||
m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})
|
|
||||||
}
|
|
||||||
return m("div.w3-container.w3-auto.form-view-container",
|
return m("div.w3-container.w3-auto.form-view-container",
|
||||||
m("h1", currentKontaktadresse.id === -1
|
m("h1", currentKontaktadresse.id === -1
|
||||||
? "Neue Kontaktadresse für " + currentKontaktadresse.projekt_name
|
? "Neue Kontaktadresse für " + currentKontaktadresse.projekt_name
|
||||||
: "Kontaktadresse-Stammblatt"),
|
: "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("div", {style: {flex: '1', 'min-height': '0'}},
|
||||||
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
|
m(Form, guard.formAttrs(submit) as iForm, [
|
||||||
m("input", {type: "hidden", name: "projekt_id", value: currentKontaktadresse.projekt_id}),
|
m("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", "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", "Name", m(Input, {name: "name", value: currentKontaktadresse.name, type: "text", required: true} as iInput)),
|
||||||
|
|||||||
+53
-18
@@ -2,8 +2,10 @@ import m from "mithril"
|
|||||||
import {Form, iForm} from "./widgets/form"
|
import {Form, iForm} from "./widgets/form"
|
||||||
import { Input, iInput} from "./widgets/inputs"
|
import { Input, iInput} from "./widgets/inputs"
|
||||||
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||||
|
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
|
||||||
import {iKunde, fetchKunden, fetchKundeById, createKundeREST, updateKundeREST} from "../models/kunde"
|
import {iKunde, fetchKunden, fetchKundeById, createKundeREST, updateKundeREST} from "../models/kunde"
|
||||||
import {showServerError} from "./auth/authmessages"
|
import {showServerError} from "./auth/authmessages"
|
||||||
|
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||||
|
|
||||||
export const kunde: m.ClosureComponent = (vnode) => {
|
export const kunde: m.ClosureComponent = (vnode) => {
|
||||||
let currentKunde: iKunde = {
|
let currentKunde: iKunde = {
|
||||||
@@ -14,11 +16,18 @@ export const kunde: m.ClosureComponent = (vnode) => {
|
|||||||
ort: "",
|
ort: "",
|
||||||
land: ""
|
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 {
|
return {
|
||||||
oninit: (vnode) => {
|
oninit: (vnode) => {
|
||||||
const routeParams = m.route.param()
|
const routeParams = m.route.param()
|
||||||
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
|
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) {
|
if (id !== -1) {
|
||||||
// Kundendaten frisch vom Server holen, um zwischenzeitliche
|
// Kundendaten frisch vom Server holen, um zwischenzeitliche
|
||||||
// Änderungen anderer Nutzer nicht zu überschreiben.
|
// Änderungen anderer Nutzer nicht zu überschreiben.
|
||||||
@@ -27,6 +36,9 @@ export const kunde: m.ClosureComponent = (vnode) => {
|
|||||||
}).catch((e) => showServerError(e))
|
}).catch((e) => showServerError(e))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onremove: (vnode) => {
|
||||||
|
guard.cleanup()
|
||||||
|
},
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
const submit = (form: HTMLFormElement): boolean => {
|
const submit = (form: HTMLFormElement): boolean => {
|
||||||
const formData = new FormData(form)
|
const formData = new FormData(form)
|
||||||
@@ -39,26 +51,28 @@ export const kunde: m.ClosureComponent = (vnode) => {
|
|||||||
land: (formData.get("land") as string) || ""
|
land: (formData.get("land") as string) || ""
|
||||||
}
|
}
|
||||||
if (currentKunde.id === -1) {
|
if (currentKunde.id === -1) {
|
||||||
createKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e))
|
createKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||||
} else {
|
} else {
|
||||||
updateKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e))
|
updateKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const cancel = (e: Event) => {
|
|
||||||
m.route.set("/kundenliste")
|
|
||||||
}
|
|
||||||
return m("div.w3-container.w3-auto.form-view-container",
|
return m("div.w3-container.w3-auto.form-view-container",
|
||||||
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
|
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
|
||||||
m("h1", currentKunde.id === -1 ? "Neuen Kunden anlegen" : "Kundenstammblatt"),
|
m("h1", currentKunde.id === -1 ? "Neuen Kunden anlegen" : "Kundenstammblatt"),
|
||||||
currentKunde.id !== -1
|
currentKunde.id !== -1
|
||||||
? m("button.w3-button.w3-theme",
|
? m("button.w3-button.w3-theme",
|
||||||
{onclick: () => m.route.set("/kontaktliste", {kunden_id: currentKunde.id})},
|
{onclick: (e: MouseEvent) => guardedNavigate("/kontaktliste", {kunden_id: currentKunde.id}, e)},
|
||||||
m("i.material-icons", "contact_page"), " Kontakte")
|
m("i.material-icons", "contact_page"), " Kontakte")
|
||||||
: null
|
: 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("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", "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", "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)),
|
m("label", "PLZ", m(Input, {name: "plz", value: currentKunde.plz, type: "text"} as iInput)),
|
||||||
@@ -74,28 +88,43 @@ export const kunde: m.ClosureComponent = (vnode) => {
|
|||||||
export const kundenliste: m.ClosureComponent = (vnode) => {
|
export const kundenliste: m.ClosureComponent = (vnode) => {
|
||||||
let treenodes: iTreenode[] = []
|
let treenodes: iTreenode[] = []
|
||||||
let selectedNode: iTreenode | null = null
|
let selectedNode: iTreenode | null = null
|
||||||
|
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||||
|
let breadcrumbs: iBreadcrumb[] = []
|
||||||
|
|
||||||
const handleRowMarked = (node: iTreenode) => {
|
const handleRowMarked = (node: iTreenode) => {
|
||||||
selectedNode = node
|
selectedNode = node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eigener Listen-Crumb für die Breadcrumb-Kette.
|
||||||
|
const ownCrumb = (): iBreadcrumb => ({
|
||||||
|
route: "/kundenliste",
|
||||||
|
params: {},
|
||||||
|
label: "Kunden"
|
||||||
|
})
|
||||||
|
|
||||||
const editKunde = (kunde: iKunde, e: Event) => {
|
const editKunde = (kunde: iKunde, e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
m.route.set("/kunde", {id: kunde.id})
|
// Breadcrumb-Kette um eigene Position erweitern.
|
||||||
|
m.route.set("/kunde", {id: kunde.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||||
}
|
}
|
||||||
|
|
||||||
const showProjekte = (kunde: iKunde, e: Event) => {
|
const showProjekte = (kunde: iKunde, e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
m.route.set("/projektliste", {kunden_id: kunde.id})
|
// Breadcrumb-Kette: Kundenliste als Vorgänger der Projektliste.
|
||||||
|
const bc = serializeBc([...breadcrumbs, ownCrumb()])
|
||||||
|
m.route.set("/projektliste", {kunden_id: kunde.id, bc})
|
||||||
}
|
}
|
||||||
|
|
||||||
const showKontakte = (kunde: iKunde, e: Event) => {
|
const showKontakte = (kunde: iKunde, e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
m.route.set("/kontaktliste", {kunden_id: kunde.id})
|
// Breadcrumb-Kette: Kundenliste als Vorgänger der Kontaktliste.
|
||||||
|
const bc = serializeBc([...breadcrumbs, ownCrumb()])
|
||||||
|
m.route.set("/kontaktliste", {kunden_id: kunde.id, bc})
|
||||||
}
|
}
|
||||||
|
|
||||||
const addKundeRoute = () => {
|
const addKundeRoute = () => {
|
||||||
m.route.set("/kunde", {id: -1})
|
// Breadcrumb-Kette um eigene Position erweitern.
|
||||||
|
m.route.set("/kunde", {id: -1, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
|
||||||
@@ -127,6 +156,8 @@ export const kundenliste: m.ClosureComponent = (vnode) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
oninit: (vnode) => {
|
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
|
// Kundenliste asynchron vom Server laden. m.request löst nach
|
||||||
// Promise-Auflösung automatisch ein Redraw aus.
|
// Promise-Auflösung automatisch ein Redraw aus.
|
||||||
fetchKunden().then((kunden: iKunde[]) => {
|
fetchKunden().then((kunden: iKunde[]) => {
|
||||||
@@ -136,11 +167,15 @@ export const kundenliste: m.ClosureComponent = (vnode) => {
|
|||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
return m("div.w3-container.w3-auto", {
|
return m("div.w3-container.w3-auto", {
|
||||||
style: {
|
style: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
'flex-direction': 'column',
|
'flex-direction': 'column',
|
||||||
height: 'inherit',
|
height: 'inherit',
|
||||||
'overflow-y': 'inherit'}},
|
'overflow-y': 'inherit'}},
|
||||||
m("h1", "Kundenliste"),
|
m("h1", "Kundenliste"),
|
||||||
|
m(BreadcrumbBar, {
|
||||||
|
breadcrumbs: breadcrumbs,
|
||||||
|
current: "Kunden"
|
||||||
|
} as iBreadcrumbBar),
|
||||||
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||||
m(Treetable,
|
m(Treetable,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import m from "mithril"
|
import m from "mithril"
|
||||||
import {Form, iForm} from "./widgets/form"
|
import {Form, iForm} from "./widgets/form"
|
||||||
import { Input, iInput} from "./widgets/inputs"
|
import {Input, iInput, Textarea, iTextarea} from "./widgets/inputs"
|
||||||
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
|
||||||
|
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
|
||||||
import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt"
|
import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt"
|
||||||
import {iKunde, fetchKundeById} from "../models/kunde"
|
import {iKunde, fetchKundeById} from "../models/kunde"
|
||||||
import {showServerError} from "./auth/authmessages"
|
import {showServerError} from "./auth/authmessages"
|
||||||
|
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||||
|
|
||||||
export const projektliste: m.ClosureComponent = (vnode) => {
|
export const projektliste: m.ClosureComponent = (vnode) => {
|
||||||
let treenodes: iTreenode[] = []
|
let treenodes: iTreenode[] = []
|
||||||
@@ -13,11 +15,20 @@ export const projektliste: m.ClosureComponent = (vnode) => {
|
|||||||
// Kundenname für die Überschrift, asynchron per fetchKundeById
|
// Kundenname für die Überschrift, asynchron per fetchKundeById
|
||||||
// aufgelöst, falls nicht in den Route-Params enthalten.
|
// aufgelöst, falls nicht in den Route-Params enthalten.
|
||||||
let kundenName: string = ""
|
let kundenName: string = ""
|
||||||
|
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||||
|
let breadcrumbs: iBreadcrumb[] = []
|
||||||
|
|
||||||
const handleRowMarked = (node: iTreenode) => {
|
const handleRowMarked = (node: iTreenode) => {
|
||||||
selectedNode = node
|
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 addProjektRoute = () => {
|
||||||
const params: any = {id: -1}
|
const params: any = {id: -1}
|
||||||
// Nur bei kundengefilterter Liste den Kunden vorbelegen.
|
// Nur bei kundengefilterter Liste den Kunden vorbelegen.
|
||||||
@@ -25,22 +36,27 @@ export const projektliste: m.ClosureComponent = (vnode) => {
|
|||||||
params.kunden_id = filterKundenId
|
params.kunden_id = filterKundenId
|
||||||
params.kunden_name = kundenName
|
params.kunden_name = kundenName
|
||||||
}
|
}
|
||||||
|
// Breadcrumb-Kette um eigene Position erweitern.
|
||||||
|
params.bc = serializeBc([...breadcrumbs, ownCrumb()])
|
||||||
m.route.set("/projekt", params)
|
m.route.set("/projekt", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
const editProjekt = (projekt: iProjekt, e: Event) => {
|
const editProjekt = (projekt: iProjekt, e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
m.route.set("/projekt", {id: projekt.id})
|
// Breadcrumb-Kette um eigene Position erweitern.
|
||||||
|
m.route.set("/projekt", {id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||||
}
|
}
|
||||||
|
|
||||||
const showCallLogs = (projekt: iProjekt, e: Event) => {
|
const showCallLogs = (projekt: iProjekt, e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
m.route.set("/calllogliste", {projekt_id: projekt.id})
|
// Breadcrumb-Kette um eigene Position erweitern.
|
||||||
|
m.route.set("/calllogliste", {projekt_id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||||
}
|
}
|
||||||
|
|
||||||
const showKontaktadressen = (projekt: iProjekt, e: Event) => {
|
const showKontaktadressen = (projekt: iProjekt, e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
m.route.set("/kontaktadresseliste", {projekt_id: projekt.id})
|
// Breadcrumb-Kette um eigene Position erweitern.
|
||||||
|
m.route.set("/kontaktadresseliste", {projekt_id: projekt.id, bc: serializeBc([...breadcrumbs, ownCrumb()])})
|
||||||
}
|
}
|
||||||
|
|
||||||
const addCallLog = (projekt: iProjekt, e: Event) => {
|
const addCallLog = (projekt: iProjekt, e: Event) => {
|
||||||
@@ -48,6 +64,14 @@ export const projektliste: m.ClosureComponent = (vnode) => {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const datum = now.toISOString().split('T')[0]
|
const datum = now.toISOString().split('T')[0]
|
||||||
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
|
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", {
|
m.route.set("/call_log", {
|
||||||
id: -1,
|
id: -1,
|
||||||
kunden_name: projekt.kunden_name,
|
kunden_name: projekt.kunden_name,
|
||||||
@@ -61,7 +85,7 @@ export const projektliste: m.ClosureComponent = (vnode) => {
|
|||||||
telefon: "",
|
telefon: "",
|
||||||
notiz: "",
|
notiz: "",
|
||||||
wiedervorlage_datum: "",
|
wiedervorlage_datum: "",
|
||||||
erledigt: false
|
bc: serializeBc(chain)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +127,8 @@ export const projektliste: m.ClosureComponent = (vnode) => {
|
|||||||
filterKundenId = null
|
filterKundenId = null
|
||||||
kundenName = ""
|
kundenName = ""
|
||||||
}
|
}
|
||||||
|
// Breadcrumb-Kette aus Route-Param parsen.
|
||||||
|
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||||
// Projekte asynchron vom Server laden. Die Filterung nach
|
// Projekte asynchron vom Server laden. Die Filterung nach
|
||||||
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
|
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
|
||||||
fetchProjekte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
|
fetchProjekte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
|
||||||
@@ -123,6 +149,10 @@ export const projektliste: m.ClosureComponent = (vnode) => {
|
|||||||
height: 'inherit',
|
height: 'inherit',
|
||||||
'overflow-y': 'inherit'}},
|
'overflow-y': 'inherit'}},
|
||||||
m("h1", kundenName ? "Projektliste für " + kundenName : "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("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||||
m(Treetable,
|
m(Treetable,
|
||||||
{
|
{
|
||||||
@@ -162,11 +192,26 @@ export const Projekt: m.ClosureComponent = (vnode) => {
|
|||||||
verantwortlicher: "",
|
verantwortlicher: "",
|
||||||
startdatum: ""
|
startdatum: ""
|
||||||
}
|
}
|
||||||
|
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||||
|
let breadcrumbs: iBreadcrumb[] = []
|
||||||
|
// Route-Params für die Rückkehr zur Projektliste; als Closure an den
|
||||||
|
// DirtyMaskHelper übergeben.
|
||||||
|
const backParams = (): any => {
|
||||||
|
const listBc = serializeBc(breadcrumbs.slice(0, -1))
|
||||||
|
const params: any = {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name}
|
||||||
|
if (listBc) params.bc = listBc
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
|
||||||
|
const guard = new DirtyMaskHelper("/projektliste", backParams)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
oninit: (vnode) => {
|
oninit: (vnode) => {
|
||||||
const routeParams = m.route.param()
|
const routeParams = m.route.param()
|
||||||
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
|
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) {
|
if (id !== -1) {
|
||||||
// Projektdaten frisch vom Server holen, um zwischenzeitliche
|
// Projektdaten frisch vom Server holen, um zwischenzeitliche
|
||||||
// Änderungen anderer Nutzer nicht zu überschreiben.
|
// Änderungen anderer Nutzer nicht zu überschreiben.
|
||||||
@@ -180,6 +225,9 @@ export const Projekt: m.ClosureComponent = (vnode) => {
|
|||||||
currentProjekt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
|
currentProjekt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onremove: (vnode) => {
|
||||||
|
guard.cleanup()
|
||||||
|
},
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
const submit = (form: HTMLFormElement): boolean => {
|
const submit = (form: HTMLFormElement): boolean => {
|
||||||
const formData = new FormData(form)
|
const formData = new FormData(form)
|
||||||
@@ -190,18 +238,17 @@ export const Projekt: m.ClosureComponent = (vnode) => {
|
|||||||
projektname: (formData.get("projektname") as string) || "",
|
projektname: (formData.get("projektname") as string) || "",
|
||||||
zweck: (formData.get("zweck") as string) || "",
|
zweck: (formData.get("zweck") as string) || "",
|
||||||
verantwortlicher: (formData.get("verantwortlicher") as string) || "",
|
verantwortlicher: (formData.get("verantwortlicher") as string) || "",
|
||||||
startdatum: (formData.get("startdatum") as string) || ""
|
startdatum: (formData.get("startdatum") as string) || "",
|
||||||
|
// Server-seitig dynamisch berechnet; Platzhalter beim Senden.
|
||||||
|
is_erledigt: false
|
||||||
}
|
}
|
||||||
if (currentProjekt.id === -1) {
|
if (currentProjekt.id === -1) {
|
||||||
createProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})).catch((e) => showServerError(e))
|
createProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||||
} else {
|
} else {
|
||||||
updateProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})).catch((e) => showServerError(e))
|
updateProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const cancel = (e: Event) => {
|
|
||||||
m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})
|
|
||||||
}
|
|
||||||
return m("div.w3-container.w3-auto.form-view-container",
|
return m("div.w3-container.w3-auto.form-view-container",
|
||||||
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
|
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
|
||||||
m("h1", currentProjekt.id === -1
|
m("h1", currentProjekt.id === -1
|
||||||
@@ -210,20 +257,25 @@ export const Projekt: m.ClosureComponent = (vnode) => {
|
|||||||
currentProjekt.id !== -1
|
currentProjekt.id !== -1
|
||||||
? [
|
? [
|
||||||
m("button.w3-button.w3-theme",
|
m("button.w3-button.w3-theme",
|
||||||
{onclick: () => m.route.set("/kontaktadresseliste", {projekt_id: currentProjekt.id})},
|
{onclick: (e: MouseEvent) => guardedNavigate("/kontaktadresseliste", {projekt_id: currentProjekt.id}, e)},
|
||||||
m("i.material-icons", "contact_page"), " Kontaktadressen"),
|
m("i.material-icons", "contact_page"), " Kontaktadressen"),
|
||||||
m("button.w3-button.w3-theme",
|
m("button.w3-button.w3-theme",
|
||||||
{style: {'margin-left': '4px'},
|
{style: {'margin-left': '4px'},
|
||||||
onclick: () => m.route.set("/projektuserliste", {projekt_id: currentProjekt.id})},
|
onclick: (e: MouseEvent) => guardedNavigate("/projektuserliste", {projekt_id: currentProjekt.id}, e)},
|
||||||
m("i.material-icons", "group"), " Nutzerberechtigungen")
|
m("i.material-icons", "group"), " Nutzerberechtigungen")
|
||||||
]
|
]
|
||||||
: null
|
: 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("div", {style: {flex: '1', 'min-height': '0'}},
|
||||||
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
|
m(Form, guard.formAttrs(submit) as iForm, [
|
||||||
m("input", {type: "hidden", name: "kunden_id", value: currentProjekt.kunden_id}),
|
m("input", {type: "hidden", name: "kunden_id", value: currentProjekt.kunden_id}),
|
||||||
m("label", "Projektname", m(Input, {name: "projektname", value: currentProjekt.projektname, type: "text", required: true} as iInput)),
|
m("label", "Projektname", m(Input, {name: "projektname", value: currentProjekt.projektname, type: "text", required: true} as iInput)),
|
||||||
m("label", "Zweck", m(Input, {name: "zweck", value: currentProjekt.zweck, type: "text"} as iInput)),
|
m("label", "Aufgabenbeschreibung", m(Textarea, {name: "zweck", value: currentProjekt.zweck, rows: 4} as iTextarea)),
|
||||||
m("label", "Verantwortlicher", m(Input, {name: "verantwortlicher", value: currentProjekt.verantwortlicher, type: "text"} as iInput)),
|
m("label", "Verantwortlicher", m(Input, {name: "verantwortlicher", value: currentProjekt.verantwortlicher, type: "text"} as iInput)),
|
||||||
m("label", "Startdatum", m(Input, {name: "startdatum", value: currentProjekt.startdatum, type: "date"} as iInput))
|
m("label", "Startdatum", m(Input, {name: "startdatum", value: currentProjekt.startdatum, type: "date"} as iInput))
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 || ""))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+325
-581
@@ -1,7 +1,13 @@
|
|||||||
import m from "mithril"
|
import m from "mithril"
|
||||||
import {Form, iForm} from "./widgets/form"
|
import {Form, iForm} from "./widgets/form"
|
||||||
import { Input, iInput} from "./widgets/inputs"
|
import { Input, iInput} 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[] = [
|
const colorPalette: string[] = [
|
||||||
"#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff",
|
"#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 {
|
function createTagTreenodes(hierarchy: iTagWithChildren[]): iTreenode[] {
|
||||||
id: number; // Numerische ID (für Kompatibilität mit bestehendem Code)
|
return hierarchy.map((tag) => createTreenodeFromTag(tag))
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rekursive Funktion zum Erstellen eines Treenode aus einem Tag mit Kindern.
|
* Rekursive Funktion zum Erstellen eines Treenode aus einem Tag mit Kindern.
|
||||||
*/
|
*/
|
||||||
function createTreenodeFromTag(tag: iTagWithChildren): iTreenode {
|
function createTreenodeFromTag(tag: iTagWithChildren): iTreenode {
|
||||||
const children = tag.children.map(child => createTreenodeFromTag(child));
|
const children = tag.children.map(child => createTreenodeFromTag(child))
|
||||||
return new Treenode(tag, true, children);
|
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
|
// KOMPONENTEN
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const Tag: m.ClosureComponent = (vnode) => {
|
export const Tag: m.ClosureComponent = () => {
|
||||||
let currentTag: iTag = {
|
let currentTag: iTag = {
|
||||||
id: -1,
|
id: -1,
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
color: "",
|
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) => {
|
const setColor = (color: string) => {
|
||||||
currentTag.color = color;
|
currentTag.color = color
|
||||||
};
|
guard.markDirty()
|
||||||
|
}
|
||||||
// Liste aller Tags für die Eltern-Auswahl
|
|
||||||
const allTags = tagDaten.filter(t => t.id !== currentTag.id); // Ausschließen des aktuellen Tags
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
oninit: (vnode) => {
|
oninit: (vnode) => {
|
||||||
const routeParams = m.route.param();
|
const routeParams = m.route.param()
|
||||||
if (routeParams) {
|
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 = {
|
currentTag = {
|
||||||
id: routeParams.id ? parseInt(routeParams.id) : -1,
|
id: -1,
|
||||||
name: routeParams.name || "",
|
name: routeParams.name || "",
|
||||||
description: routeParams.description || "",
|
description: routeParams.description || "",
|
||||||
color: routeParams.color || "",
|
color: routeParams.color || "",
|
||||||
parentId: routeParams.parentId ? parseInt(routeParams.parentId) : null,
|
parent_id: routeParams.parent_id ? parseInt(routeParams.parent_id) : null,
|
||||||
icon: routeParams.icon || undefined,
|
is_parent: routeParams.is_parent ? routeParams.is_parent === "true" : false,
|
||||||
order: routeParams.order ? parseInt(routeParams.order) : undefined,
|
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) => {
|
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",
|
return m("div.w3-container.w3-auto.form-view-container",
|
||||||
m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"),
|
m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"),
|
||||||
m("div", {style: {flex: '1', 'min-height': '0'}},
|
m("div", {style: {flex: '1', 'min-height': '0'}},
|
||||||
m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [
|
m(Form, guard.formAttrs(submit) as iForm, [
|
||||||
m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text"} as iInput)),
|
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"} 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
|
// Parent-Tag-Umschalter: true = Gruppierungs-Knoten (nicht zuordenbar)
|
||||||
m("label", "Übergeordneter Tag (optional)",
|
m("div", {style: {'margin-bottom': '5px'}},
|
||||||
m("select.w3-select", {name: "parentId", value: currentTag.parentId || ""},
|
m("label",
|
||||||
m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
|
m("input.w3-check", {
|
||||||
allTags.map(tag =>
|
type: "checkbox",
|
||||||
m("option", {value: tag.id, selected: currentTag.parentId === tag.id}, tag.name)
|
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("label", "Farbe"),
|
||||||
m("div", {style: {display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px'}},
|
m("div", {style: {display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px'}},
|
||||||
colorPalette.map(color =>
|
colorPalette.map(color =>
|
||||||
m("button", {
|
m("button", {
|
||||||
|
type: "button",
|
||||||
style: {
|
style: {
|
||||||
width: '32px',
|
width: '32px',
|
||||||
height: '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 = () => {
|
||||||
|
let treenodes: iTreenode[] = []
|
||||||
|
let selectedNode: iTreenode | null = null
|
||||||
|
|
||||||
|
// Laedt die Hierarchie vom Server und erzeugt Treenodes
|
||||||
|
const loadTreenodes = () => {
|
||||||
|
fetchTagHierarchy().then((hierarchy) => {
|
||||||
|
treenodes = createTagTreenodes(hierarchy)
|
||||||
|
selectedNode = null
|
||||||
|
}).catch((e) => showServerError(e))
|
||||||
|
}
|
||||||
|
|
||||||
export const TagList: m.ClosureComponent = (vnode) => {
|
|
||||||
let treenodes: iTreenode[] = createTagTreenodes();
|
|
||||||
let selectedNode: iTreenode | null = null;
|
|
||||||
|
|
||||||
// Aktualisiere treenodes, wenn sich die Daten ändern
|
|
||||||
const updateTreenodes = () => {
|
|
||||||
treenodes = createTagTreenodes();
|
|
||||||
selectedNode = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRowMarked = (node: iTreenode) => {
|
const handleRowMarked = (node: iTreenode) => {
|
||||||
selectedNode = node;
|
selectedNode = node
|
||||||
};
|
}
|
||||||
|
|
||||||
const editTag = (tag: iTag, e: Event) => {
|
const editTag = (tag: iTag, e: Event) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation()
|
||||||
m.route.set("/tag", {
|
m.route.set("/tag", {id: tag.id})
|
||||||
id: tag.id,
|
}
|
||||||
name: tag.name,
|
|
||||||
description: tag.description,
|
const deleteTag = (tag: iTag, node: iTreenode, e: Event) => {
|
||||||
color: tag.color,
|
e.stopPropagation()
|
||||||
parentId: tag.parentId,
|
const childCount = countDescendants(node)
|
||||||
icon: tag.icon,
|
if (childCount === 0) {
|
||||||
order: tag.order
|
// 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) => {
|
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 [
|
return [
|
||||||
m("span",
|
m("span",
|
||||||
m("span", {
|
m("i.material-icons", {
|
||||||
style: {
|
style: {
|
||||||
backgroundColor: tag.color,
|
verticalAlign: 'middle',
|
||||||
color: 'white',
|
marginRight: '6px',
|
||||||
padding: '2px 6px',
|
fontSize: '18px'
|
||||||
borderRadius: '3px',
|
},
|
||||||
fontWeight: 'bold',
|
title: tagTitle
|
||||||
display: 'inline-block',
|
}, tagIcon),
|
||||||
marginRight: '8px'
|
// Farbchip mit Name und Workflow-Icons (erledigend /
|
||||||
}
|
// Wiedervorlage) aus der zentralen TagBadge-Komponente.
|
||||||
}, tag.name)
|
m(TagBadge, {tagData: tag})),
|
||||||
),
|
|
||||||
m("span", tag.description),
|
m("span", tag.description),
|
||||||
m("div",
|
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap', 'justify-content': 'flex-end'}},
|
||||||
m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editTag(tag, e)},
|
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("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 => {
|
const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
|
||||||
let tag: iTag = node.nodevalue;
|
let tag: iTag = node.nodevalue
|
||||||
let searchLower = searchvalue.toLowerCase();
|
let searchLower = searchvalue.toLowerCase()
|
||||||
return tag.name.toLowerCase().includes(searchLower) ||
|
|
||||||
tag.description.toLowerCase().includes(searchLower) ||
|
// Prueft, ob der Tag selbst auf den Suchbegriff passt (nur Name und Beschreibung)
|
||||||
tag.color.toLowerCase().includes(searchLower) ||
|
const selfMatch = tag.name.toLowerCase().includes(searchLower) ||
|
||||||
(tag.icon && tag.icon.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 {
|
return {
|
||||||
|
oninit: (vnode) => {
|
||||||
|
loadTreenodes()
|
||||||
|
},
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
return m("div.w3-container", {
|
return m("div.w3-container.w3-auto", {
|
||||||
style: {
|
style: {
|
||||||
'max-width': '80%',
|
display: 'flex',
|
||||||
'margin-left': 'auto',
|
'flex-direction': 'column',
|
||||||
'margin-right': 'auto',
|
height: 'inherit',
|
||||||
display: 'flex',
|
|
||||||
'flex-direction': 'column',
|
|
||||||
height: 'inherit',
|
|
||||||
'overflow-y': 'inherit'}},
|
'overflow-y': 'inherit'}},
|
||||||
m("h1", "Tags"),
|
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("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
|
||||||
m(Treetable,
|
m(Treetable,
|
||||||
{
|
{
|
||||||
@@ -652,27 +410,13 @@ export const TagList: m.ClosureComponent = (vnode) => {
|
|||||||
filter: filterFunction,
|
filter: filterFunction,
|
||||||
onRowMarked: handleRowMarked,
|
onRowMarked: handleRowMarked,
|
||||||
isSelected: (node: iTreenode) => node === selectedNode
|
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,
|
|
||||||
};
|
|
||||||
@@ -39,6 +39,20 @@ export interface iTreetable {
|
|||||||
* <tr>-Zeile; die columnmapper-Inhalte brauchen dann kein eigenes
|
* <tr>-Zeile; die columnmapper-Inhalte brauchen dann kein eigenes
|
||||||
* Selektions-Styling mehr. */
|
* Selektions-Styling mehr. */
|
||||||
isSelected?: (node: iTreenode) => boolean
|
isSelected?: (node: iTreenode) => boolean
|
||||||
|
/** Optional: Spaltenüberschriften (ein Titel je columnmapper-Spalte,
|
||||||
|
* 1:1 von links nach rechts). Wenn gesetzt, rendert das Widget eine
|
||||||
|
* Kopfzeile über den Datenzeilen. Hinweis zur ersten Spalte: Die
|
||||||
|
* Einrückung/der Expand-Pfeil der Baumdarstellung liegt im selben
|
||||||
|
* td wie die ERSTE columnmapper-Zelle — der erste Titel steht
|
||||||
|
* daher direkt über der ersten Datenspalte. Die Kopfzeile bleibt
|
||||||
|
* beim Scrollen sticky sichtbar. */
|
||||||
|
header?: string[]
|
||||||
|
/** Optional: Ein-/Ausklappen-Buttons (unfold_more/unfold_less) in der
|
||||||
|
* Topbar anzeigen. Default true. Für flache Listen ohne Hierarchie
|
||||||
|
* auf false setzen — die Topbar entfällt dann ganz, sofern auch
|
||||||
|
* keine Children (z.B. TopbarButtons) übergeben und kein filter
|
||||||
|
* gesetzt ist. */
|
||||||
|
showExpandButtons?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
|
export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
|
||||||
@@ -58,26 +72,73 @@ export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
|
|||||||
const topHasSiblingsWithChildren = vnode.attrs.treenodes.some(
|
const topHasSiblingsWithChildren = vnode.attrs.treenodes.some(
|
||||||
(tn: iTreenode) => tn.hasChildren
|
(tn: iTreenode) => tn.hasChildren
|
||||||
)
|
)
|
||||||
|
// Topbar (Expand-Buttons, Children, Suchfeld) nur rendern, wenn
|
||||||
|
// tatsächlich Inhalt vorhanden ist — bei flachen Listen ohne
|
||||||
|
// Expand-Buttons, Children und filter bleibt sie komplett weg.
|
||||||
|
const showExpand = vnode.attrs.showExpandButtons !== false
|
||||||
|
// vnode.children kann ein Array oder ein Primitiv sein
|
||||||
|
// (Mithril-Typ ChildArrayOrPrimitive).
|
||||||
|
const hatChildren = Array.isArray(vnode.children)
|
||||||
|
? vnode.children.length > 0
|
||||||
|
: vnode.children != null
|
||||||
|
const hatTopbar = showExpand || hatChildren || vnode.attrs.filter != undefined
|
||||||
return m("div.w3-border-theme", {style: {height: '99%', overflow: 'hidden', 'border-style': 'solid', 'border-width': '1px'}},
|
return m("div.w3-border-theme", {style: {height: '99%', overflow: 'hidden', 'border-style': 'solid', 'border-width': '1px'}},
|
||||||
m("div.w3-bar.w3-theme",
|
hatTopbar
|
||||||
m(TopbarButton, {
|
? m("div.w3-bar.w3-theme",
|
||||||
onclick: () => {
|
showExpand
|
||||||
expandtoggle = true; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
|
? [
|
||||||
}},
|
m(TopbarButton, {
|
||||||
m("i.material-icons", "unfold_more")
|
onclick: () => {
|
||||||
),
|
expandtoggle = true; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
|
||||||
m(TopbarButton, {
|
}},
|
||||||
onclick: () => {
|
m("i.material-icons", "unfold_more")
|
||||||
expandtoggle = false; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
|
),
|
||||||
}},
|
m(TopbarButton, {
|
||||||
m("i.material-icons", "unfold_less")
|
onclick: () => {
|
||||||
),
|
expandtoggle = false; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
|
||||||
vnode.children,
|
}},
|
||||||
(vnode.attrs.filter != undefined) ? m(SearchField, {setSearchvalue: setSearchvalue} as iSearchfield) : null
|
m("i.material-icons", "unfold_less")
|
||||||
),
|
)
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
vnode.children,
|
||||||
|
(vnode.attrs.filter != undefined) ? m(SearchField, {setSearchvalue: setSearchvalue} as iSearchfield) : null
|
||||||
|
)
|
||||||
|
: null,
|
||||||
m("div", {style: {height: '100%'}},
|
m("div", {style: {height: '100%'}},
|
||||||
m("div", {style: {height: 'calc(100% - 32px', overflow: 'auto', width: '100%'}},
|
// Ohne Topbar nutzt der scrollbare Bereich die volle Höhe.
|
||||||
m("table.w3-table", {style: {'overflow-y': 'auto'}},
|
// Wichtig für den Sticky-Header: Dieser div muss selbst der
|
||||||
|
// Scroll-Container sein (feste Höhe + overflow), damit sich
|
||||||
|
// position:sticky der th-Zellen auf ihn bezieht.
|
||||||
|
m("div", {style: {height: hatTopbar ? 'calc(100% - 32px)' : '100%', overflow: 'auto', width: '100%'}},
|
||||||
|
// overflow darf hier NICHT auf dem table-Element gesetzt
|
||||||
|
// werden — sonst wird die Tabelle selbst zum Scroll-Container
|
||||||
|
// und der Sticky-Header bricht.
|
||||||
|
m("table.w3-table",
|
||||||
|
// Optionale Spaltenüberschriften: Ein Titel je
|
||||||
|
// columnmapper-Spalte, 1:1 von links nach rechts
|
||||||
|
// (die Einrückung teilt sich das td mit der
|
||||||
|
// ersten Datenspalte, keine Führungs-Spalte).
|
||||||
|
// Die Kopfzeile bleibt beim Scrollen sticky.
|
||||||
|
vnode.attrs.header
|
||||||
|
? m("thead",
|
||||||
|
m("tr.w3-theme",
|
||||||
|
vnode.attrs.header.map((titel: string) =>
|
||||||
|
m("th.w3-theme", {
|
||||||
|
// Sticky-Header: Eigener Hintergrund
|
||||||
|
// nötig, da der tr-Hintergrund beim
|
||||||
|
// Scrollen mit den Daten wegwandert
|
||||||
|
// und sonst Zeilen durchscheinen.
|
||||||
|
style: {
|
||||||
|
position: 'sticky',
|
||||||
|
top: '0',
|
||||||
|
'z-index': '1',
|
||||||
|
'text-align': 'left',
|
||||||
|
'white-space': 'nowrap'
|
||||||
|
},
|
||||||
|
title: titel
|
||||||
|
}, titel))))
|
||||||
|
: null,
|
||||||
vnode.attrs.treenodes.map((tn:iTreenode, z) => {
|
vnode.attrs.treenodes.map((tn:iTreenode, z) => {
|
||||||
if (vnode.attrs.filter == undefined || (vnode.attrs.filter != undefined && vnode.attrs.filter(tn, searchvalue) == true)) {
|
if (vnode.attrs.filter == undefined || (vnode.attrs.filter != undefined && vnode.attrs.filter(tn, searchvalue) == true)) {
|
||||||
return m(Treerow, {
|
return m(Treerow, {
|
||||||
@@ -88,7 +149,9 @@ export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
|
|||||||
onRowMarked: onRowMarked,
|
onRowMarked: onRowMarked,
|
||||||
isSelected: isSelected,
|
isSelected: isSelected,
|
||||||
isEven: ((z % 2) == 0),
|
isEven: ((z % 2) == 0),
|
||||||
hasSiblingsWithChildren: topHasSiblingsWithChildren} as iTreerow)
|
hasSiblingsWithChildren: topHasSiblingsWithChildren,
|
||||||
|
filter: vnode.attrs.filter,
|
||||||
|
searchvalue: searchvalue} as iTreerow)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return null
|
return null
|
||||||
@@ -114,6 +177,12 @@ interface iTreerow {
|
|||||||
* den Platzhalter-Pfeil nur dann zu rendern, wenn es Nodes mit
|
* den Platzhalter-Pfeil nur dann zu rendern, wenn es Nodes mit
|
||||||
* Pfeil gibt, an denen der Text ausgerichtet werden kann. */
|
* Pfeil gibt, an denen der Text ausgerichtet werden kann. */
|
||||||
hasSiblingsWithChildren?: boolean
|
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) => {
|
let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
|
||||||
|
|
||||||
@@ -178,7 +247,9 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
|
|||||||
// Falls der Trennode Kinder hat und expandiert werden soll, wird rekursiv die Trennode-Komponente für
|
// 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
|
// 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.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, {
|
return m(Treerow, {
|
||||||
treenode: tn,
|
treenode: tn,
|
||||||
level: vnode.attrs.level + 1,
|
level: vnode.attrs.level + 1,
|
||||||
@@ -186,7 +257,9 @@ let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
|
|||||||
onRowMarked: vnode.attrs.onRowMarked,
|
onRowMarked: vnode.attrs.onRowMarked,
|
||||||
isSelected: vnode.attrs.isSelected,
|
isSelected: vnode.attrs.isSelected,
|
||||||
isEven: ((z % 2) == 0),
|
isEven: ((z % 2) == 0),
|
||||||
hasSiblingsWithChildren: childHasSiblingsWithChildren
|
hasSiblingsWithChildren: childHasSiblingsWithChildren,
|
||||||
|
filter: vnode.attrs.filter,
|
||||||
|
searchvalue: vnode.attrs.searchvalue
|
||||||
} as iTreerow)
|
} as iTreerow)
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,28 @@ export interface iForm {
|
|||||||
stickyButtons?: boolean
|
stickyButtons?: boolean
|
||||||
/** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */
|
/** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */
|
||||||
onformcreate?: (form:HTMLFormElement) => void
|
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) => {
|
export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||||
@@ -29,6 +51,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
|||||||
}
|
}
|
||||||
function onchanged (e:Event) {
|
function onchanged (e:Event) {
|
||||||
isDirty = true
|
isDirty = true
|
||||||
|
if (vnode.attrs.onDirtyChange != undefined) {
|
||||||
|
vnode.attrs.onDirtyChange(true)
|
||||||
|
}
|
||||||
if (vnode.attrs.onchanged != undefined) {
|
if (vnode.attrs.onchanged != undefined) {
|
||||||
vnode.attrs.onchanged(e)
|
vnode.attrs.onchanged(e)
|
||||||
}
|
}
|
||||||
@@ -37,6 +62,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (vnode.attrs.onsubmit(form) == true) {
|
if (vnode.attrs.onsubmit(form) == true) {
|
||||||
isDirty = false
|
isDirty = false
|
||||||
|
if (vnode.attrs.onDirtyChange != undefined) {
|
||||||
|
vnode.attrs.onDirtyChange(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function registerFormElement (f:HTMLFormElement) {
|
function registerFormElement (f:HTMLFormElement) {
|
||||||
@@ -46,22 +74,24 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
|||||||
}
|
}
|
||||||
function cancel(e:Event) {
|
function cancel(e:Event) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (canCloseForm() == true) {
|
// Der Dirty-Check beim Abbrechen erfolgt jetzt durch den
|
||||||
isDirty = false
|
// Navigations-Guard der Maske (dirtyguard.ts), da oncancel die
|
||||||
if (vnode.attrs.oncancel != undefined) {
|
// Navigation auslöst. Die Form selbst fragt hier nicht mehr nach,
|
||||||
vnode.attrs.oncancel(e)
|
// um eine doppelte Bestätigung zu vermeiden. isDirty wird nicht
|
||||||
}
|
// zurückgesetzt, damit der Guard den aktuellen State sieht.
|
||||||
else {
|
if (vnode.attrs.oncancel != undefined) {
|
||||||
history.back()
|
vnode.attrs.oncancel(e)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
e['redraw'] = false
|
history.back()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function reset(e:Event) {
|
function reset(e:Event) {
|
||||||
if (canCloseForm("reset")) {
|
if (canCloseForm("reset")) {
|
||||||
isDirty = false
|
isDirty = false
|
||||||
|
if (vnode.attrs.onDirtyChange != undefined) {
|
||||||
|
vnode.attrs.onDirtyChange(false)
|
||||||
|
}
|
||||||
// Hier passiert nichts.
|
// Hier passiert nichts.
|
||||||
// Da mithril nach jedem User-Event (wie z.B. einem 'Click') ein redraw() ausführt, wird hier die Form
|
// 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
|
// 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'}, type: "submit"}, "Speichern"),
|
||||||
m("button.w3-button.w3-theme.w3-right.w3-mobile", {style: {margin: '0px 0px 2px 4px'}, onclick: cancel, type: "button"}, "Abbrechen"),
|
m("button.w3-button.w3-theme.w3-right.w3-mobile", {style: {margin: '0px 0px 2px 4px'}, onclick: cancel, type: "button"}, "Abbrechen"),
|
||||||
m("button.w3-button.w3-theme.w3-right.w3-mobile", {onclick: reset, type: "button"}, "Zurücksetzen")
|
m("button.w3-button.w3-theme.w3-right.w3-mobile", {onclick: reset, type: "button"}, "Zurücksetzen")
|
||||||
@@ -98,6 +133,14 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
|||||||
// Container, die Buttons bleiben am unteren Rand fixiert. Das
|
// Container, die Buttons bleiben am unteren Rand fixiert. Das
|
||||||
// umschließende form-Element wird zum flex-Container, damit das
|
// umschließende form-Element wird zum flex-Container, damit das
|
||||||
// Layout auch bei Fenstergrößenänderung stabil bleibt.
|
// Layout auch bei Fenstergrößenänderung stabil bleibt.
|
||||||
|
// Stil des scrollbaren Inhaltsbereichs; optional als Flex-Spalte
|
||||||
|
// (flexContent), damit Masken-Kinder den verbleibenden Platz
|
||||||
|
// füllen können.
|
||||||
|
const contentStyle: any = {flex: '1', 'overflow-y': 'auto', 'padding-right': '8px'}
|
||||||
|
if (vnode.attrs.flexContent === true) {
|
||||||
|
contentStyle.display = 'flex'
|
||||||
|
contentStyle['flex-direction'] = 'column'
|
||||||
|
}
|
||||||
if (stickyButtons) {
|
if (stickyButtons) {
|
||||||
return m("form", {
|
return m("form", {
|
||||||
style: {display: 'flex', 'flex-direction': 'column', height: '100%'},
|
style: {display: 'flex', 'flex-direction': 'column', height: '100%'},
|
||||||
@@ -109,12 +152,16 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
|||||||
for (let index = 0; index < elements.length; index++) {
|
for (let index = 0; index < elements.length; index++) {
|
||||||
const element = elements[index];
|
const element = elements[index];
|
||||||
element.addEventListener("change", onchanged)
|
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)
|
registerFormElement(form)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Scrollbarer Formular-Inhalt.
|
// Scrollbarer Formular-Inhalt.
|
||||||
m("div", {style: {flex: '1', 'overflow-y': 'auto', 'padding-right': '8px'}},
|
m("div", {style: contentStyle},
|
||||||
vnode.children
|
vnode.children
|
||||||
),
|
),
|
||||||
// Fixierter Button-Bereich.
|
// Fixierter Button-Bereich.
|
||||||
@@ -131,6 +178,10 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
|||||||
for (let index = 0; index < elements.length; index++) {
|
for (let index = 0; index < elements.length; index++) {
|
||||||
const element = elements[index];
|
const element = elements[index];
|
||||||
element.addEventListener("change", onchanged)
|
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)
|
registerFormElement(form)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ export interface iInput {
|
|||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
required?:boolean
|
required?:boolean
|
||||||
onchanged?: Function
|
onchanged?: Function
|
||||||
|
oninput?: Function
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Diese Komponente stellt ein Html-Input Element dar. Der darzustellende Wert wird einfach mit dem Attribut
|
* 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
|
* '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> = {
|
export const Input:m.Component<iInput> = {
|
||||||
onupdate: (vnode) => {
|
onupdate: (vnode) => {
|
||||||
@@ -71,6 +73,9 @@ export const Input:m.Component<iInput> = {
|
|||||||
if (vnode.attrs.onchanged != undefined) {
|
if (vnode.attrs.onchanged != undefined) {
|
||||||
attributes['onchange'] = vnode.attrs.onchanged
|
attributes['onchange'] = vnode.attrs.onchanged
|
||||||
}
|
}
|
||||||
|
if (vnode.attrs.oninput != undefined) {
|
||||||
|
attributes['oninput'] = vnode.attrs.oninput
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return m("input.w3-input.w3-border", attributes)
|
return m("input.w3-input.w3-border", attributes)
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export interface iSelectOption {
|
|||||||
value: any
|
value: any
|
||||||
/** Anzeige im Feld bzw. in der Dropdown-Liste (z.B. Kundenname). */
|
/** Anzeige im Feld bzw. in der Dropdown-Liste (z.B. Kundenname). */
|
||||||
label: string
|
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
|
let highlightIndex: number = -1
|
||||||
// Referenz auf das Eingabefeld für Fokus nach Clear.
|
// Referenz auf das Eingabefeld für Fokus nach Clear.
|
||||||
let inputfield: HTMLInputElement | null = null
|
let inputfield: HTMLInputElement | null = null
|
||||||
|
// Zuletzt im Suchtext dargestellter value. Dient der Synchronisation:
|
||||||
|
// Der Anzeigetext wird beim Initialisieren und bei der Optionsauswahl
|
||||||
|
// gesetzt; ändert sich der attrs.value von außen (z.B. Zurücksetzen
|
||||||
|
// eines Filters im Eltern-View), wird der Text im view nachgezogen.
|
||||||
|
let lastSyncedValue: any = undefined
|
||||||
|
// True, solange der attrs.value noch keinem label zugeordnet werden
|
||||||
|
// konnte — typischerweise weil die Optionen asynchron geladen werden
|
||||||
|
// und beim oninit noch leer waren. Der view wiederholt die Synchro-
|
||||||
|
// nisierung dann, bis eine passende Option existiert.
|
||||||
|
let syncPending: boolean = false
|
||||||
|
// Zeigt die Optionliste ungefiltert an, unabhängig vom Suchtext.
|
||||||
|
// Wird vom Öffnen-Button gesetzt (neben dem Clear-Button) und beim
|
||||||
|
// Tippen, bei der Auswahl und mit Escape zurückgesetzt.
|
||||||
|
let showAll: boolean = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert die gefilterten Optionen abhängig vom aktuellen Suchtext.
|
* Liefert die gefilterten Optionen abhängig vom aktuellen Suchtext.
|
||||||
@@ -59,13 +78,29 @@ export const Select: m.ClosureComponent<iSelect> = () => {
|
|||||||
return options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower))
|
return options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Suchtext auf das label des übergebenen Werts und merkt
|
||||||
|
* sich diesen als synchronisiert. Wird beim Initialisieren, bei der
|
||||||
|
* Optionsauswahl und bei externen value-Änderungen aufgerufen.
|
||||||
|
*/
|
||||||
|
const syncSearchZumValue = (value: any, options: iSelectOption[]) => {
|
||||||
|
const option = options.find((o: iSelectOption) => o.value === value)
|
||||||
|
search = option ? option.label : ""
|
||||||
|
lastSyncedValue = value
|
||||||
|
// Konnte der value keinem label zugeordnet werden, bleibt die
|
||||||
|
// Synchronisierung offen und wird im view wiederholt, sobald
|
||||||
|
// die Optionen (asynchron) nachgeladen wurden.
|
||||||
|
syncPending = !option
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wählt eine Option aus: value setzen, Suchtext = label, Dropdown zu.
|
* Wählt eine Option aus: value setzen, Suchtext = label, Dropdown zu.
|
||||||
*/
|
*/
|
||||||
const selectOption = (option: iSelectOption, vnode: m.Vnode<iSelect>) => {
|
const selectOption = (option: iSelectOption, vnode: m.Vnode<iSelect>) => {
|
||||||
vnode.attrs.value = option.value
|
vnode.attrs.value = option.value
|
||||||
search = option.label
|
syncSearchZumValue(option.value, vnode.attrs.options)
|
||||||
isOpen = false
|
isOpen = false
|
||||||
|
showAll = false
|
||||||
highlightIndex = -1
|
highlightIndex = -1
|
||||||
if (vnode.attrs.onchanged != undefined) {
|
if (vnode.attrs.onchanged != undefined) {
|
||||||
vnode.attrs.onchanged(option.value)
|
vnode.attrs.onchanged(option.value)
|
||||||
@@ -86,22 +121,36 @@ export const Select: m.ClosureComponent<iSelect> = () => {
|
|||||||
return {
|
return {
|
||||||
oninit: (vnode) => {
|
oninit: (vnode) => {
|
||||||
// Aktuelles label zum value ermitteln (für die Anzeige im Feld).
|
// Aktuelles label zum value ermitteln (für die Anzeige im Feld).
|
||||||
const initialOption = vnode.attrs.options.find((o: iSelectOption) => o.value === vnode.attrs.value)
|
syncSearchZumValue(vnode.attrs.value, vnode.attrs.options)
|
||||||
search = initialOption ? initialOption.label : ""
|
|
||||||
},
|
},
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
|
// Extern geänderte value nachziehen: Der Suchtext wird nur beim
|
||||||
|
// Initialisieren und bei der Optionsauswahl gesetzt. Ändert der
|
||||||
|
// Eltern-View den attrs.value (z.B. Zurücksetzen eines Filters),
|
||||||
|
// wird der angezeigte Text hier auf das neue label synchronisiert.
|
||||||
|
if (vnode.attrs.value !== lastSyncedValue || syncPending) {
|
||||||
|
syncSearchZumValue(vnode.attrs.value, vnode.attrs.options)
|
||||||
|
}
|
||||||
const isReadonly = vnode.attrs.readonly === true
|
const isReadonly = vnode.attrs.readonly === true
|
||||||
const filteredOptions = getFilteredOptions(vnode.attrs.options)
|
const filteredOptions = getFilteredOptions(vnode.attrs.options)
|
||||||
|
// Angezeigt wird die ungefilterte Liste, wenn der Öffnen-Button
|
||||||
|
// genutzt wurde, sonst die vom Suchtext gefilterte.
|
||||||
|
const angezeigteOptionen = showAll ? vnode.attrs.options : filteredOptions
|
||||||
const inputAttrs: any = {
|
const inputAttrs: any = {
|
||||||
type: "text",
|
type: "text",
|
||||||
value: search,
|
value: search,
|
||||||
style: {'margin-bottom': '5px', width: '100%', 'padding-right': '32px'},
|
style: {'margin-bottom': '5px', width: '100%', 'padding-right': '58px'},
|
||||||
oncreate: (vnode: m.VnodeDOM) => {
|
oncreate: (vnode: m.VnodeDOM) => {
|
||||||
inputfield = vnode.dom as HTMLInputElement
|
inputfield = vnode.dom as HTMLInputElement
|
||||||
},
|
},
|
||||||
oninput: (e: Event) => {
|
oninput: (e: Event) => {
|
||||||
if (isReadonly) { return }
|
if (isReadonly) { return }
|
||||||
search = (e.target as HTMLInputElement).value
|
search = (e.target as HTMLInputElement).value
|
||||||
|
// Der Nutzer tippt selbst — eine offene Synchronisierung
|
||||||
|
// (asynchron nachgeladene Optionen) nicht mehr nachziehen.
|
||||||
|
syncPending = false
|
||||||
|
// Tippen sucht wieder im Suchtext-Basis.
|
||||||
|
showAll = false
|
||||||
isOpen = true
|
isOpen = true
|
||||||
highlightIndex = filteredOptions.length > 0 ? 0 : -1
|
highlightIndex = filteredOptions.length > 0 ? 0 : -1
|
||||||
},
|
},
|
||||||
@@ -115,22 +164,23 @@ export const Select: m.ClosureComponent<iSelect> = () => {
|
|||||||
},
|
},
|
||||||
onkeydown: (e: KeyboardEvent) => {
|
onkeydown: (e: KeyboardEvent) => {
|
||||||
if (isReadonly) { return }
|
if (isReadonly) { return }
|
||||||
if (filteredOptions.length === 0) { return }
|
if (angezeigteOptionen.length === 0) { return }
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
|
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
|
||||||
highlightIndex = (highlightIndex + 1) % filteredOptions.length
|
highlightIndex = (highlightIndex + 1) % angezeigteOptionen.length
|
||||||
} else if (e.key === "ArrowUp") {
|
} else if (e.key === "ArrowUp") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
|
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
|
||||||
highlightIndex = (highlightIndex - 1 + filteredOptions.length) % filteredOptions.length
|
highlightIndex = (highlightIndex - 1 + angezeigteOptionen.length) % angezeigteOptionen.length
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
if (isOpen && highlightIndex >= 0 && highlightIndex < filteredOptions.length) {
|
if (isOpen && highlightIndex >= 0 && highlightIndex < angezeigteOptionen.length) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
selectOption(filteredOptions[highlightIndex], vnode)
|
selectOption(angezeigteOptionen[highlightIndex], vnode)
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
isOpen = false
|
isOpen = false
|
||||||
|
showAll = false
|
||||||
highlightIndex = -1
|
highlightIndex = -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,31 +195,68 @@ export const Select: m.ClosureComponent<iSelect> = () => {
|
|||||||
return m("div", {style: {width: '100%', position: 'relative'}},
|
return m("div", {style: {width: '100%', position: 'relative'}},
|
||||||
// Verstecktes Feld für die Formularübergabe (echter value).
|
// Verstecktes Feld für die Formularübergabe (echter value).
|
||||||
m("input", {type: "hidden", name: vnode.attrs.name, value: vnode.attrs.value}),
|
m("input", {type: "hidden", name: vnode.attrs.name, value: vnode.attrs.value}),
|
||||||
// Sichtbares Such-/Anzeigefeld mit Clear-Button darin.
|
// Sichtbares Such-/Anzeigefeld mit Öffnen- und Clear-Button darin.
|
||||||
m("div", {style: {position: 'relative', width: '100%'}},
|
m("div", {style: {position: 'relative', width: '100%'}},
|
||||||
m("input.w3-input.w3-border", inputAttrs),
|
m("input.w3-input.w3-border", inputAttrs),
|
||||||
// Clear-Button: leert das Suchfeld (nur wenn nicht readonly).
|
|
||||||
// Absolut positioniert, sodass der Button optisch im
|
|
||||||
// Eingabefeld liegt und vertikal zentriert ist.
|
|
||||||
!isReadonly
|
!isReadonly
|
||||||
? m("button.w3-button", {
|
? [
|
||||||
style: {
|
// Öffnen-Button: blendet die Optionliste ungefiltert
|
||||||
position: 'absolute',
|
// ein, ohne das Feld zu leeren — der aktuelle Wert
|
||||||
right: '4px',
|
// bleibt stehen, bis eine neue Option gewählt wird.
|
||||||
top: '50%',
|
m("button.w3-button", {
|
||||||
transform: 'translateY(-50%)',
|
type: "button",
|
||||||
'min-width': '24px',
|
title: "Liste öffnen (alle Optionen anzeigen)",
|
||||||
height: '24px',
|
style: {
|
||||||
padding: '2px',
|
position: 'absolute',
|
||||||
'line-height': '1'
|
right: '28px',
|
||||||
},
|
top: '50%',
|
||||||
onclick: () => {
|
transform: 'translateY(-50%)',
|
||||||
search = ""
|
'min-width': '24px',
|
||||||
isOpen = true
|
height: '24px',
|
||||||
highlightIndex = filteredOptions.length > 0 ? 0 : -1
|
padding: '2px',
|
||||||
if (inputfield) { inputfield.focus() }
|
'line-height': '1'
|
||||||
}
|
},
|
||||||
}, m("i.material-icons", {style: {'font-size': '20px'}}, "clear"))
|
// mousedown mit preventDefault: hält den Fokus
|
||||||
|
// auf dem Eingabefeld (kein Blur-Rennen mit dem
|
||||||
|
// verzögerten Schließen) und öffnet/schließt
|
||||||
|
// die ungefilterte Liste. Klick bei offener
|
||||||
|
// Gesamtliste schließt sie wieder (Toggle).
|
||||||
|
onmousedown: (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (isOpen && showAll) {
|
||||||
|
isOpen = false
|
||||||
|
showAll = false
|
||||||
|
highlightIndex = -1
|
||||||
|
} else {
|
||||||
|
showAll = true
|
||||||
|
isOpen = true
|
||||||
|
highlightIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, m("i.material-icons", {style: {'font-size': '20px'}}, "arrow_drop_down")),
|
||||||
|
// Clear-Button: leert das Suchfeld.
|
||||||
|
// Absolut positioniert, sodass der Button optisch im
|
||||||
|
// Eingabefeld liegt und vertikal zentriert ist.
|
||||||
|
m("button.w3-button", {
|
||||||
|
type: "button",
|
||||||
|
style: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: '4px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
'min-width': '24px',
|
||||||
|
height: '24px',
|
||||||
|
padding: '2px',
|
||||||
|
'line-height': '1'
|
||||||
|
},
|
||||||
|
onclick: () => {
|
||||||
|
search = ""
|
||||||
|
isOpen = true
|
||||||
|
highlightIndex = filteredOptions.length > 0 ? 0 : -1
|
||||||
|
if (inputfield) { inputfield.focus() }
|
||||||
|
}
|
||||||
|
}, m("i.material-icons", {style: {'font-size': '20px'}}, "clear"))
|
||||||
|
]
|
||||||
: null
|
: null
|
||||||
),
|
),
|
||||||
// Gefilterte, aufklappbare Optionliste. Eigene Positionierung,
|
// Gefilterte, aufklappbare Optionliste. Eigene Positionierung,
|
||||||
@@ -186,9 +273,9 @@ export const Select: m.ClosureComponent<iSelect> = () => {
|
|||||||
'box-shadow': '2px 2px 4px darkgrey'
|
'box-shadow': '2px 2px 4px darkgrey'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
filteredOptions.length === 0
|
angezeigteOptionen.length === 0
|
||||||
? m("div.w3-padding-small", "Keine Treffer")
|
? m("div.w3-padding-small", "Keine Treffer")
|
||||||
: filteredOptions.map((option: iSelectOption, index: number) =>
|
: angezeigteOptionen.map((option: iSelectOption, index: number) =>
|
||||||
m("a.w3-bar-item.w3-button.w3-text-left", {
|
m("a.w3-bar-item.w3-button.w3-text-left", {
|
||||||
style: {whiteSpace: 'normal'},
|
style: {whiteSpace: 'normal'},
|
||||||
class: index === highlightIndex ? "w3-theme" : "w3-hover-light-grey",
|
class: index === highlightIndex ? "w3-theme" : "w3-hover-light-grey",
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-16
@@ -11,6 +11,8 @@ import { Call_Log, call_log_liste } from "./Components/call_log"
|
|||||||
import { Kontakt, kontaktliste } from "./Components/kontakt"
|
import { Kontakt, kontaktliste } from "./Components/kontakt"
|
||||||
import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse"
|
import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse"
|
||||||
import { Tag, TagList } from "./Components/tags"
|
import { Tag, TagList } from "./Components/tags"
|
||||||
|
import { TagesWorkflowListe, TagesWorkflowMaske } from "./Components/tages_workflow"
|
||||||
|
import { guardedNavigate } from "./Components/widgets/dirtyguard"
|
||||||
|
|
||||||
|
|
||||||
function healthcheck () {
|
function healthcheck () {
|
||||||
@@ -69,7 +71,7 @@ function logout() {
|
|||||||
if (result['logout'] == true) {m.route.set("/blankpage")}
|
if (result['logout'] == true) {m.route.set("/blankpage")}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
location.href = "/index.html"
|
setTimeout(() => { location.href = "/index.html" }, 1000)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,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 = {
|
const hello2: m.Component = {
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
return m("ul",
|
return m("ul",
|
||||||
@@ -105,21 +120,30 @@ const knopf: m.Component = {
|
|||||||
}
|
}
|
||||||
const menu: m.ClosureComponent = () => {
|
const menu: m.ClosureComponent = () => {
|
||||||
let showProjektBerechtigungen = false
|
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 {
|
return {
|
||||||
oninit: (vnode) => {
|
oninit: (vnode) => {
|
||||||
showProjektBerechtigungen = false
|
showProjektBerechtigungen = false
|
||||||
},
|
},
|
||||||
view: (vnode) => {
|
view: (vnode) => {
|
||||||
return m(menubar, [
|
return m(menubar, [
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/kundenliste"}, "Kunden"),
|
menuItem("/tagesworkflow", "Tages-Workflow"),
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/projektliste"}, "Projekte"),
|
menuItem("/kundenliste", "Kunden"),
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/calllogliste"}, "Gesprächsnotizen"),
|
menuItem("/projektliste", "Projekte"),
|
||||||
|
menuItem("/calllogliste", "Gesprächsnotizen"),
|
||||||
m("div.w3-dropdown-hover",
|
m("div.w3-dropdown-hover",
|
||||||
m("button.w3-button", "Optionen"),
|
m("button.w3-button", "Optionen"),
|
||||||
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
|
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/taglist"}, "Tags"),
|
menuItem("/taglist", "Tags"),
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenexport"),
|
menuItem("/default", "Datenexport"),
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenimport"),
|
menuItem("/default", "Datenimport"),
|
||||||
// Projekt-Berechtigungen nur für SysAdmin/AppAdmin sichtbar.
|
// Projekt-Berechtigungen nur für SysAdmin/AppAdmin sichtbar.
|
||||||
// Als Toggle-Button realisiert, da verschachtelte w3-dropdown-hover
|
// Als Toggle-Button realisiert, da verschachtelte w3-dropdown-hover
|
||||||
// innerhalb eines offenen Dropdowns nicht zuverlässig funktionieren.
|
// innerhalb eines offenen Dropdowns nicht zuverlässig funktionieren.
|
||||||
@@ -134,10 +158,10 @@ const menu: m.ClosureComponent = () => {
|
|||||||
showProjektBerechtigungen
|
showProjektBerechtigungen
|
||||||
? m("div.w3-bar-block",
|
? m("div.w3-bar-block",
|
||||||
m("a.w3-button.w3-bar-item",
|
m("a.w3-button.w3-bar-item",
|
||||||
{href: "#!/projektuserliste", style: {'padding-left': '32px'}},
|
{href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/projektuserliste", undefined, e)}},
|
||||||
"Projekt-Zugriffe"),
|
"Projekt-Zugriffe"),
|
||||||
m("a.w3-button.w3-bar-item",
|
m("a.w3-button.w3-bar-item",
|
||||||
{href: "#!/userprojektliste", style: {'padding-left': '32px'}},
|
{href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/userprojektliste", undefined, e)}},
|
||||||
"Nutzer-Zugriffe")
|
"Nutzer-Zugriffe")
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
@@ -149,9 +173,9 @@ const menu: m.ClosureComponent = () => {
|
|||||||
m("div.w3-dropdown-hover.w3-right",
|
m("div.w3-dropdown-hover.w3-right",
|
||||||
m("button.w3-button", "System"),
|
m("button.w3-button", "System"),
|
||||||
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
|
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/login"}, "Login"),
|
menuItem("/login", "Login"),
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/changepassword"}, "Kennwort ändern"),
|
menuItem("/changepassword", "Kennwort ändern"),
|
||||||
m("a.w3-button.w3-bar-item", {href: "#!/usermanagement"}, "Verwalten"),
|
menuItem("/usermanagement", "Verwalten"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
@@ -174,8 +198,8 @@ if (check_currentuser() == false) {
|
|||||||
m.route.set("/login")
|
m.route.set("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
m.route(gridMountpoints.main, "/blankpage", {
|
m.route(gridMountpoints.main, "/kundenliste", {
|
||||||
"/blankpage": hello,
|
"/blankpage": loggedout,
|
||||||
"/usermanagement": appmanagementpage,
|
"/usermanagement": appmanagementpage,
|
||||||
"/changepassword": changepasswordscreen,
|
"/changepassword": changepasswordscreen,
|
||||||
"/login": loginscreen,
|
"/login": loginscreen,
|
||||||
@@ -192,6 +216,8 @@ m.route(gridMountpoints.main, "/blankpage", {
|
|||||||
"/kontaktadresseliste": kontaktadresseliste,
|
"/kontaktadresseliste": kontaktadresseliste,
|
||||||
"/kontaktadresse": Kontaktadresse,
|
"/kontaktadresse": Kontaktadresse,
|
||||||
"/taglist": TagList,
|
"/taglist": TagList,
|
||||||
"/tag": Tag
|
"/tag": Tag,
|
||||||
|
"/tagesworkflow": TagesWorkflowListe,
|
||||||
|
"/tagesworkflowkontakt": TagesWorkflowMaske
|
||||||
|
|
||||||
})
|
})
|
||||||
@@ -38,5 +38,5 @@ export function getIniValue(value){
|
|||||||
return result["APP"][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)
|
result = parseINIString(inifile)
|
||||||
@@ -1,16 +1,34 @@
|
|||||||
import m from "mithril"
|
import m from "mithril"
|
||||||
|
import {iTag} from "./tag"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Datenmodell für eine Gesprächsnotiz (Call-Log).
|
* Datenmodell für eine Gesprächsnotiz (Call-Log).
|
||||||
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
||||||
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
|
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
|
||||||
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
|
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
|
||||||
|
* kontaktadresse_id ist Pflichtfeld: Jede Notiz gehört zu genau einer
|
||||||
|
* Kontaktadresse (Fremdschlüssel auf kontaktadressen.id).
|
||||||
|
* user_id erfasst den Ersteller (Mitarbeiter, der den Anruf getätigt hat);
|
||||||
|
* sie wird serverseitig beim Anlegen gesetzt und nie vom Client gesendet.
|
||||||
|
* Alt-Notizen ohne erfassten Ersteller haben null.
|
||||||
|
* mitarbeiter_name ist keine Spalte, sondern wird serverseitig aus der
|
||||||
|
* user_id gegen die Auth-Datenbank aufgelöst (null ohne user_id).
|
||||||
|
* Ein Erledigt-Status ist bewusst kein Feld der Notiz: Er ergibt sich
|
||||||
|
* daraus, ob einer der zugeordneten Tags is_erledigend=True hat (siehe
|
||||||
|
* Kontaktadresse.is_erledigt).
|
||||||
|
* tags (vom Server beim Lesen) und tag_ids (vom Client beim Schreiben)
|
||||||
|
* steuern die n:m-Beziehung zu den Tags.
|
||||||
*/
|
*/
|
||||||
export interface iCallLog {
|
export interface iCallLog {
|
||||||
id: number
|
id: number
|
||||||
projekt_id: number
|
projekt_id: number
|
||||||
|
kontaktadresse_id: number
|
||||||
|
/** Ersteller (Mitarbeiter), serverseitig gesetzt; null bei Alt-Notizen. */
|
||||||
|
user_id: number | null
|
||||||
projekt_name: string
|
projekt_name: string
|
||||||
kunden_name: string
|
kunden_name: string
|
||||||
|
/** Aufgelöster Username des Erstellers; null bei Alt-Notizen. */
|
||||||
|
mitarbeiter_name: string | null
|
||||||
datum: string
|
datum: string
|
||||||
uhrzeit_beginn: string
|
uhrzeit_beginn: string
|
||||||
dauer: string
|
dauer: string
|
||||||
@@ -19,7 +37,10 @@ export interface iCallLog {
|
|||||||
telefon: string
|
telefon: string
|
||||||
notiz: string
|
notiz: string
|
||||||
wiedervorlage_datum: string
|
wiedervorlage_datum: string
|
||||||
erledigt: boolean
|
/** Vom Server gelieferte zugeordnete Tags (nur beim Lesen). */
|
||||||
|
tags?: iTag[]
|
||||||
|
/** Vom Client zu übermittelnde Tag-IDs (nur beim Schreiben). */
|
||||||
|
tag_ids?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -63,6 +84,19 @@ export function fetchCallLogById(id: number): Promise<iCallLog> {
|
|||||||
}).then((result) => result as iCallLog)
|
}).then((result) => result as iCallLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Gesprächsnotizen eines einzelnen Kontakts (Kontaktadresse)
|
||||||
|
* vom Server — serverseitig nach kontaktadresse_id gefiltert. Wird für
|
||||||
|
* die Historie in der Tages-Workflow-Maske genutzt.
|
||||||
|
*/
|
||||||
|
export function fetchCallLogsByKontaktadresse(kontaktadresseId: number): Promise<iCallLog[]> {
|
||||||
|
return m.request({
|
||||||
|
method: "GET",
|
||||||
|
url: CALLLOG_REST_BASE + "/list",
|
||||||
|
params: {kontaktadresse_id: kontaktadresseId}
|
||||||
|
}).then((result) => result as iCallLog[])
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legt eine neue Gesprächsnotiz serverseitig an.
|
* Legt eine neue Gesprächsnotiz serverseitig an.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import m from "mithril"
|
|||||||
* Datenmodell für eine Kontaktadresse (Akquise-Ziel innerhalb eines Projekts).
|
* Datenmodell für eine Kontaktadresse (Akquise-Ziel innerhalb eines Projekts).
|
||||||
* Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projekt_id).
|
* Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projekt_id).
|
||||||
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
||||||
* projekt_name ist keine ORM-Spalte, sondern wird serverseitig aus der
|
* projekt_name, projekt_zweck und kunden_name sind keine ORM-Spalten, sondern
|
||||||
* Projekt-Relationship abgeleitet und im JSON ergänzt.
|
* werden serverseitig aus den Projekt-/Kunde-Relationships abgeleitet und im
|
||||||
|
* JSON ergänzt. is_erledigt ist ebenfalls keine Spalte, sondern wird
|
||||||
|
* serverseitig dynamisch berechnet: True, sobald mindestens eine
|
||||||
|
* Gesprächsnotiz des Kontakts einen Tag mit is_erledigend=True hat.
|
||||||
*/
|
*/
|
||||||
export interface iKontaktadresse {
|
export interface iKontaktadresse {
|
||||||
id: number
|
id: number
|
||||||
projekt_id: number
|
projekt_id: number
|
||||||
projekt_name: string
|
projekt_name: string
|
||||||
|
projekt_zweck: string
|
||||||
|
kunden_name: string
|
||||||
anrede: string
|
anrede: string
|
||||||
name: string
|
name: string
|
||||||
firma: string
|
firma: string
|
||||||
@@ -22,6 +27,8 @@ export interface iKontaktadresse {
|
|||||||
ort: string
|
ort: string
|
||||||
land: string
|
land: string
|
||||||
notiz: string
|
notiz: string
|
||||||
|
/** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */
|
||||||
|
is_erledigt: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import m from "mithril"
|
|||||||
* Datenmodell für ein Projekt.
|
* Datenmodell für ein Projekt.
|
||||||
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
|
||||||
* kunden_name ist keine ORM-Spalte, sondern wird serverseitig aus der
|
* kunden_name ist keine ORM-Spalte, sondern wird serverseitig aus der
|
||||||
* Kunde-Relationship abgeleitet und im JSON ergänzt.
|
* Kunde-Relationship abgeleitet und im JSON ergänzt. is_erledigt ist
|
||||||
|
* ebenfalls keine Spalte, sondern wird serverseitig dynamisch berechnet:
|
||||||
|
* True, wenn das Projekt Kontaktadressen hat und alle davon erledigt sind.
|
||||||
*/
|
*/
|
||||||
export interface iProjekt {
|
export interface iProjekt {
|
||||||
id: number
|
id: number
|
||||||
@@ -14,6 +16,8 @@ export interface iProjekt {
|
|||||||
zweck: string
|
zweck: string
|
||||||
verantwortlicher: string
|
verantwortlicher: string
|
||||||
startdatum: string
|
startdatum: string
|
||||||
|
/** Dynamisch berechneter Erledigt-Status (keine ORM-Spalte). */
|
||||||
|
is_erledigt: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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[])
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && rollup -c rollup.config.mjs"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mithril": "^2.2.2"
|
"mithril": "^2.2.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -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`
|
||||||
@@ -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
@@ -1,4 +1,28 @@
|
|||||||
ARG NGINX_VERSION
|
# ── Stage: frontend — baut das TypeScript-JS-Bundle ──────────────
|
||||||
FROM nginx:${NGINX_VERSION}
|
# 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 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-38
@@ -1,38 +1,38 @@
|
|||||||
ssl_certificate /etc/certs/${NGINX_CERT};
|
ssl_certificate /etc/certs/${NGINX_CERT};
|
||||||
ssl_certificate_key /etc/certs/${NGINX_CERT_KEY};
|
ssl_certificate_key /etc/certs/${NGINX_CERT_KEY};
|
||||||
|
|
||||||
# Zunächst wird ein Default-Server definiert.
|
# Zunächst wird ein Default-Server definiert.
|
||||||
# Das Standardverhalten von NGINX ist nämlich wie folgt:
|
# 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
|
# 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.
|
# Servername definiert wird, wird einfach der erste definierte Serverblock genommen.
|
||||||
# Dadurch, dass ein expliziter Default-Server definiert wird, wird dieser verwendet.
|
# 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
|
# Falls jetzt also ein Servername übergeben wird, den es nicht gibt, gibt NGINX den Error-Code 404 zurück
|
||||||
server {
|
server {
|
||||||
listen 4430 ssl default_server;
|
listen 4430 ssl default_server;
|
||||||
return 404;
|
return 404;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 4430 ssl;
|
listen 4430 ssl;
|
||||||
server_name ${NGINX_SERVER_NAME};
|
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
|
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/
|
# https://sandro-keil.de/blog/let-nginx-start-if-upstream-host-is-unavailable-or-down/
|
||||||
|
|
||||||
location /static {
|
location /static {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
# Variable muss benutzt werden (s.o. bei resolver Direktive)
|
# Variable muss benutzt werden (s.o. bei resolver Direktive)
|
||||||
set $upstream pythonapp:8080;
|
set $upstream pythonapp:8080;
|
||||||
proxy_pass http://$upstream;
|
proxy_pass http://$upstream;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /health-check {
|
location /health-check {
|
||||||
add_header Content-Type text/plain;
|
add_header Content-Type text/plain;
|
||||||
return 200 "success $time_iso8601";
|
return 200 "success $time_iso8601";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 & Workflows)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **2.1. Hauptmenü**
|
||||||
|
|
||||||
|
- **Menüpunkte**:
|
||||||
|
- **Tages-Workflow**
|
||||||
|
- **Projekte**
|
||||||
|
- **Kontakte**
|
||||||
|
- **Statistiken**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **2.2. Tages-Workflow: Gefilterte Liste**
|
||||||
|
|
||||||
|
#### **Anzeige**
|
||||||
|
|
||||||
|
- **Tabellenansicht** mit Spalten:
|
||||||
|
|
||||||
|
| **Spalte** | **Beschreibung** | **Beispiel** |
|
||||||
|
| ------------- | ----------------------------------------- | ------------------------- |
|
||||||
|
| Kunde | Name des Kunden | "Muster GmbH" |
|
||||||
|
| Projekt | Name des Projekts | "Neukundengewinnung 2026" |
|
||||||
|
| Kontakt | Name des Ansprechpartners | "Max Mustermann" |
|
||||||
|
| Telefon | Telefonnummer | "+49 123 45678" |
|
||||||
|
| Letzte Notiz | Kurztext der letzten Gesprächsnotiz | "Nicht erreicht" |
|
||||||
|
| Wiedervorlage | Nächstes Wiedervorlage-Datum | "20.09.2026" |
|
||||||
|
| **Status** | **Dynamisch berechnet (`is_erledigt()`)** | "Offen" / "Erledigt" |
|
||||||
|
|
||||||
|
- **Sortierung**:
|
||||||
|
1. Überfällige Wiedervorlagen
|
||||||
|
2. Kontakte ohne Gesprächsnotizen
|
||||||
|
3. Kontakte mit Wiedervorlage in der aktuellen Woche
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **Filterleiste**
|
||||||
|
|
||||||
|
|
||||||
|
| **Filter** | **Typ** | **Mögliche Werte** |
|
||||||
|
| ------------------- | ---------------- | ----------------------------------------------------------------------- |
|
||||||
|
| Kunde | Dropdown | Alle Kunden, die der Mitarbeiter bearbeiten darf |
|
||||||
|
| Projektstatus | Dropdown | *Offen*, *In Bearbeitung* (dynamisch berechnet) |
|
||||||
|
| **Tag-Kategorie** | **Dropdown** | **`Erledigend`**, **`Nicht-erledigend`**, **`Wiedervorlage-Tags`** |
|
||||||
|
| **Einzelne Tags** | **Multi-Select** | **Alle verfügbaren Tags** (z. B. *"Nicht erreicht"*, *"Wiedervorlage"*) |
|
||||||
|
| Wiedervorlage-Datum | Dropdown | *Heute*, *Nächste 3 Tage*, *Überfällig*, *Aktuelle Woche* |
|
||||||
|
|
||||||
|
|
||||||
|
- **Aktionsbuttons**:
|
||||||
|
- **"Filter anwenden"** (Aktualisiert die Liste)
|
||||||
|
- **"Zurücksetzen"** (Setzt alle Filter zurück)
|
||||||
|
- **"Workflow beginnen"** (Startet die Bearbeitung mit dem ersten Eintrag)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **2.3. Bearbeitungsmaske**
|
||||||
|
|
||||||
|
#### **Kopfzeile: Projekt- und Kontaktinformationen**
|
||||||
|
|
||||||
|
- **Kundeninfo**: Name des Kunden und Projektname.
|
||||||
|
- **Kontaktinfo**: Name, Telefonnummer, E-Mail.
|
||||||
|
- **Button**: *"Kontakt bearbeiten"*
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Kunde: Muster GmbH
|
||||||
|
Projekt: Neukundengewinnung 2026
|
||||||
|
Kontakt: Max Mustermann | +49 123 45678 | max@muster.de
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **Tabelle der bisherigen Gesprächsnotizen**
|
||||||
|
|
||||||
|
|
||||||
|
| **Spalte** | **Beschreibung** | **Beispiel** |
|
||||||
|
| ------------- | ---------------------------- | ------------------------------- |
|
||||||
|
| Datum | Erstellungsdatum und Uhrzeit | "19.09.2026, 14:30" |
|
||||||
|
| Mitarbeiter | Name des Mitarbeiters | "Gerd Goldberg" |
|
||||||
|
| Notiz | Text der Gesprächsnotiz | "Nicht erreicht" |
|
||||||
|
| Tags | Zugewiesene Tags | "Nicht erreicht, Wiedervorlage" |
|
||||||
|
| Wiedervorlage | Wiedervorlage-Datum | "20.09.2026" |
|
||||||
|
|
||||||
|
|
||||||
|
- **Sortierung**: Neueste Notiz zuerst.
|
||||||
|
- **Funktionen**:
|
||||||
|
- Klick auf Notiz → Details anzeigen.
|
||||||
|
- **Button**: *"Notiz bearbeiten"*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **Formular für neue Gesprächsnotiz**
|
||||||
|
|
||||||
|
- **Felder**:
|
||||||
|
- **Notiztext** (Textarea, Pflichtfeld)
|
||||||
|
- **Tags** (Multi-Select-Dropdown mit allen verfügbaren Tags)
|
||||||
|
- **Wiedervorlage-Datum** (Datumspicker, **automatisch berechnet oder manuell überschreibbar**)
|
||||||
|
- **Buttons**:
|
||||||
|
- **"Speichern und weiter"** (Speichert und lädt den nächsten Kontakt)
|
||||||
|
- **"Speichern"** (Speichert und bleibt auf dem aktuellen Kontakt)
|
||||||
|
- **"Abbrechen"** (Verwirft Änderungen, Rückkehr zur Liste)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **Navigation**
|
||||||
|
|
||||||
|
- **Button: "Zurück zur Liste"**:
|
||||||
|
- Kehrt zur **gefilterten Liste** zurück.
|
||||||
|
- Die Liste berücksichtigt bereits alle Änderungen (z. B. neue Notizen, Statusänderungen).
|
||||||
|
- **Button: "Nächster Kontakt"**:
|
||||||
|
- Lädt manuell den nächsten Kontakt aus der Liste.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **2.4. Konfliktmeldung (bei optimistischem Locking)**
|
||||||
|
|
||||||
|
- **Anzeige**:
|
||||||
|
```
|
||||||
|
Dieser Kontakt wurde von [Mitarbeiter] bearbeitet und als erledigt markiert.
|
||||||
|
Möchten Sie die aktuelle Version neu laden?
|
||||||
|
```
|
||||||
|
- **Optionen**:
|
||||||
|
- *"Neu laden"* (Lädt die aktuelle Version des Kontakts).
|
||||||
|
- *"Trotzdem speichern"* (Überschreibt die Änderungen, **nur für Admins**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **2.5. Hinweis bei übersprungenen Kontakten**
|
||||||
|
|
||||||
|
- **Automatisches Überspringen** von Kontakten, die nicht mehr den Filterkriterien entsprechen.
|
||||||
|
- **Hinweis in der UI**:
|
||||||
|
```
|
||||||
|
Kontakt [Name] wurde übersprungen, da er nicht mehr den Filterkriterien entspricht
|
||||||
|
(Grund: [z. B. 'Wiedervorlage-Datum außerhalb der gefilterten Woche' oder 'Kontakt wurde als erledigt markiert']).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **🔄 3. Workflows & Logik**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **3.1. Wiedervorlage-Datum Berechnung**
|
||||||
|
|
||||||
|
- **Automatische Berechnung**:
|
||||||
|
- Falls **kein manuelles `wiedervorlage_datum`** in der Notiz gesetzt ist:
|
||||||
|
- **Minimum** der `wiedervorlage_abstand_tage` aller ausgewählten Tags mit `loest_wiedervorlage_aus = True`.
|
||||||
|
- **Wiedervorlage-Datum = Erstellungsdatum der Notiz + Minimum-Abstand**.
|
||||||
|
- **Manuelle Überschreibung**:
|
||||||
|
- Der Mitarbeiter kann das **`wiedervorlage_datum`** jederzeit manuell ändern (z. B. über den Datumspicker).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **3.2. Statusberechnung (dynamisch)**
|
||||||
|
|
||||||
|
- **Kontaktstatus**:
|
||||||
|
- Wird **dynamisch** über die Methode `is_erledigt()` berechnet (siehe [1.3](#13-neue-methoden-statusberechnung)).
|
||||||
|
- **Kein statisches `status`-Feld** in der Datenbank.
|
||||||
|
- **Projektstatus**:
|
||||||
|
- Wird **dynamisch** über die Methode `is_erledigt()` berechnet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **3.3. Tages-Workflow (Ablauf)**
|
||||||
|
|
||||||
|
1. **Aufruf des Tages-Workflows**:
|
||||||
|
- Mitarbeiter wählt **"Tages-Workflow"** aus.
|
||||||
|
- **System**: Zeigt die **priorisierte Liste** der Kontakte an (sortiert nach Wiedervorlage-Datum).
|
||||||
|
2. **Filter anwenden**:
|
||||||
|
- Mitarbeiter wählt Filter (z. B. Kunde, Tag-Kategorie, einzelne Tags).
|
||||||
|
- **System**: Aktualisiert die Liste basierend auf den Filtern.
|
||||||
|
3. **Workflow beginnen**:
|
||||||
|
- Mitarbeiter klickt auf **"Workflow beginnen"**.
|
||||||
|
- **System**: Lädt den **ersten Kontakt** aus der gefilterten Liste in die Bearbeitungsmaske.
|
||||||
|
4. **Bearbeitung des Kontakts**:
|
||||||
|
- Mitarbeiter erfasst eine **neue Gesprächsnotiz** (Text, Tags, **Wiedervorlage-Datum**).
|
||||||
|
- **System**:
|
||||||
|
- Berechnet das **`wiedervorlage_datum`** (falls nicht manuell gesetzt).
|
||||||
|
- Inkrementiert die **`version`** des Kontakts (für optimistisches Locking).
|
||||||
|
5. **Speichern und weiter**:
|
||||||
|
- Mitarbeiter klickt auf **"Speichern und weiter"**.
|
||||||
|
- **System**:
|
||||||
|
- Speichert die Notiz und prüft die **`version`** des Kontakts.
|
||||||
|
- Falls **Konflikt** (Version stimmt nicht überein **UND** Status wurde Wesentlich geändert):
|
||||||
|
- Zeigt **Konfliktmeldung** an.
|
||||||
|
- Lädt den **nächsten Kontakt** aus der Liste, der die **Filterkriterien erfüllt**.
|
||||||
|
- Falls kein Kontakt mehr passt:
|
||||||
|
- Zeigt Meldung: *"Keine weiteren Kontakte entsprechen den Filterkriterien."*
|
||||||
|
- Kehrt zur **gefilterten Liste** zurück.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **3.4. Dynamische Filterprüfung**
|
||||||
|
|
||||||
|
- **Beim Laden des nächsten Kontakts**:
|
||||||
|
- **System prüft**:
|
||||||
|
- Ist der Kontakt noch **nicht erledigt** (`is_erledigt() = False`)?
|
||||||
|
- Liegt das **Wiedervorlage-Datum** noch im gefilterten Zeitraum?
|
||||||
|
- Falls der Kontakt **nicht mehr passt**:
|
||||||
|
- Lädt den **nächsten Kontakt**, der die Kriterien erfüllt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **📊 4. Beispiel-Daten**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **4.1. Tags**
|
||||||
|
|
||||||
|
|
||||||
|
| **Name** | **`is_erledigend`** | **`loest_wiedervorlage_aus`** | **`wiedervorlage_abstand_tage`** |
|
||||||
|
| ------------------ | ------------------- | ----------------------------- | -------------------------------- |
|
||||||
|
| Uninteressiert | `True` | `False` | `NULL` |
|
||||||
|
| Wiedervorlage | `False` | `True` | `7` |
|
||||||
|
| Nicht erreicht | `False` | `True` | `3` |
|
||||||
|
| Rückruf vereinbart | `False` | `True` | `1` |
|
||||||
|
| Falsche Nummer | `False` | `False` | `NULL` |
|
||||||
|
| Erfolgreich | `False` | `False` | `NULL` |
|
||||||
|
| Termin vereinbart | `False` | `True` | `14` |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **4.2. Gesprächsnotizen**
|
||||||
|
|
||||||
|
|
||||||
|
| **Kontakt** | **Notiz** | **Tags** | **Wiedervorlage-Datum** | **Berechnung** |
|
||||||
|
| -------------- | -------------------- | ------------------ | ----------------------- | ----------------------------- |
|
||||||
|
| Max Mustermann | "Nicht erreicht" | Nicht erreicht | **20.09.2026** | Heute + 3 Tage (Tag-Abstand) |
|
||||||
|
| Anna Schmidt | "Rückruf vereinbart" | Rückruf vereinbart | **21.09.2026** | Heute + 1 Tag (Tag-Abstand) |
|
||||||
|
| Peter Müller | "Termin vereinbart" | Termin vereinbart | **04.10.2026** | Heute + 14 Tage (Tag-Abstand) |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **🔍 5. Zusammenfassung der Änderungen**
|
||||||
|
|
||||||
|
|
||||||
|
| **Bereich** | **Änderung / Neuerung** |
|
||||||
|
| ---------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
|
| **Datenmodell** | **Kein `status`-Feld** in `Project` oder `Contact` → **dynamische Methoden `is_erledigt()`**. |
|
||||||
|
| **`CallLog`** | **Einziges `wiedervorlage_datum`-Feld** (automatisch berechnet oder manuell überschrieben). |
|
||||||
|
| **`Contact`** | **`version`-Feld** für optimistisches Locking. |
|
||||||
|
| **Filter** | **Einzelne Tags** als Filteroption (Mehrfachauswahl). |
|
||||||
|
| **Bearbeitungsmaske** | **Wiedervorlage-Datum** ist manuell änderbar, wird aber automatisch aus Tags berechnet. |
|
||||||
|
| **Workflow-Logik** | Dynamische Filterprüfung beim Laden des nächsten Kontakts. |
|
||||||
|
| **Konfliktbehandlung** | Optimistisches Locking mit `version`-Feld + Meldung bei **wesentlichen Änderungen**. |
|
||||||
+10
-16
@@ -2,31 +2,25 @@ name: kbpbase
|
|||||||
services:
|
services:
|
||||||
nginx-proxy:
|
nginx-proxy:
|
||||||
build:
|
build:
|
||||||
context: NGINX_Server
|
context: . # Repo-Root (fuer frontend-Stage im NGINX-Dockerfile)
|
||||||
args: # Übergibt die ARGS an das dockerfile
|
dockerfile: NGINX_Server/Dockerfile # explizit, da Context nicht mehr NGINX_Server/
|
||||||
NGINX_VERSION: ${NGINX_VERSION}
|
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- ${NGINX_HOST_PORT}:4430
|
- ${NGINX_HOST_PORT}:4430
|
||||||
volumes:
|
volumes:
|
||||||
- ${NGINX_STATIC_DIR}:/usr/share/nginx/html:ro
|
# NGINX_STATIC_DIR entfaellt in Prod — statische Dateien sind im NGINX-Image
|
||||||
- ${NGINX_CONFIG_TEMPLATE_DIR}:/etc/nginx/templates
|
# 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
|
- ${NGINX_CERTS}:/etc/certs
|
||||||
environment:
|
environment:
|
||||||
|
- NGINX_USE_SSL=${NGINX_USE_SSL:-true}
|
||||||
- NGINX_CERT=${NGINX_CERT}
|
- NGINX_CERT=${NGINX_CERT}
|
||||||
- NGINX_CERT_KEY=${NGINX_CERT_KEY}
|
- NGINX_CERT_KEY=${NGINX_CERT_KEY}
|
||||||
- NGINX_SERVER_NAME=${NGINX_SERVER_NAME}
|
- NGINX_SERVER_NAME=${NGINX_SERVER_NAME}
|
||||||
pythonapp:
|
pythonapp:
|
||||||
build:
|
build:
|
||||||
context: App_Server
|
context: . # Repo-Root (fuer COPY-Pfade im Dockerfile)
|
||||||
# target: kommt aus dem prod oder dev dockerfile
|
dockerfile: App_Server/Dockerfile # explizit, da Context nicht mehr App_Server/
|
||||||
args: # Übergibt die ARGS an das dockerfile
|
|
||||||
PYTHON_VERSION: ${PYTHON_VERSION}
|
|
||||||
volumes:
|
volumes:
|
||||||
#- ${PYTHON_APP_DIR}:/usr/opt/pythonapp/root
|
|
||||||
- ${PYTHON_DB_DIR}:/usr/var/pythonappdb
|
- ${PYTHON_DB_DIR}:/usr/var/pythonappdb
|
||||||
- python-venv:/usr/opt/pythonapp/env
|
# python-venv Volume entfaellt — venv ist im Image
|
||||||
# - ${PYTHON_ENV_DIR}:/usr/opt/pythonapp/env
|
# (dev-Stage definiert eigenen Volume in docker-compose.dev.yaml)
|
||||||
|
|
||||||
volumes:
|
|
||||||
python-venv:
|
|
||||||
+18
-5
@@ -6,14 +6,27 @@ services:
|
|||||||
service: pythonapp
|
service: pythonapp
|
||||||
build:
|
build:
|
||||||
target: dev
|
target: dev
|
||||||
args: # Übergibt die ARGS an das dockerfile
|
environment:
|
||||||
# PYTHON_VERSION: ${PYTHON_VERSION}
|
# Globales Vibe-Home innerhalb des Workspace-Bind-Mounts, damit es auf dem Host persistiert
|
||||||
NODE_VERSION: ${NODE_VERSION:-18}
|
- VIBE_HOME=/usr/opt/pythonapp/.vibe-global
|
||||||
NPM_VERSION: ${NPM_VERSION:-8}
|
# Debug-Modus aktivieren — Werkzeug-Reloader statt Waitress
|
||||||
|
- APP_DEBUG=true
|
||||||
volumes:
|
volumes:
|
||||||
- ${PYTHON_DEV_DIR}:/usr/opt/pythonapp
|
- ${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
|
- 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:
|
volumes:
|
||||||
|
python-venv-dev:
|
||||||
node-modules:
|
node-modules:
|
||||||
@@ -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:
|
services:
|
||||||
pythonapp:
|
pythonapp:
|
||||||
extends:
|
extends:
|
||||||
@@ -6,5 +8,9 @@ services:
|
|||||||
service: pythonapp
|
service: pythonapp
|
||||||
build:
|
build:
|
||||||
target: prod
|
target: prod
|
||||||
volumes:
|
# Kein Code-bind-mount mehr — Code ist im Image
|
||||||
- ${PYTHON_APP_DIR}:/usr/opt/pythonapp
|
nginx-proxy:
|
||||||
|
extends:
|
||||||
|
file: docker-compose.base.yaml
|
||||||
|
service: nginx-proxy
|
||||||
|
# Kein STATIC_DIR bind-mount mehr — statische Dateien sind im NGINX-Image
|
||||||
@@ -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
@@ -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
|
||||||
Executable
+79
@@ -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 ==="
|
||||||
Executable
+66
@@ -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 ==="
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user