Compare commits

..
7 Commits
Author SHA1 Message Date
gg 93827dde0f Serverseitige Filterung für Listen-Endpunkte und Einzelabrufe in Listen-Komponenten
Server: Die list-Endpunkte für Kontakt, Projekt, Kontaktadresse und CallLog
akzeptieren nun einen optionalen Query-Parameter (kunden_id bzw. projekt_id)
und filtern im ORM-Query. Neue Migration V01.05 legt Indizes auf den
FK-Spalten an.

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

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

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

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

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

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

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

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

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

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
---
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.
+20 -2
View File
@@ -67,7 +67,7 @@
"type": "shell", "type": "shell",
"group": "none", "group": "none",
"label": "Open auth.db", "label": "Open auth.db",
"command": "C:/Daten-GGG/OneDrive/GGG-Workspace/Projekte/Sqlite3/sqlite3.exe auth.db", "command": "sqlite3 auth.db",
"options": { "options": {
"cwd": "${workspaceFolder}/App_Server/data" "cwd": "${workspaceFolder}/App_Server/data"
}, },
@@ -85,7 +85,7 @@
"type": "shell", "type": "shell",
"group": "none", "group": "none",
"label": "Open app.db", "label": "Open app.db",
"command": "C:/Daten-GGG/OneDrive/GGG-Workspace/Projekte/Sqlite3/sqlite3.exe kbp_crm.db", "command": "sqlite3 kbp_crm.db",
"options": { "options": {
"cwd": "${workspaceFolder}/App_Server/data" "cwd": "${workspaceFolder}/App_Server/data"
}, },
@@ -98,6 +98,24 @@
"showReuseMessage": true, "showReuseMessage": true,
"clear": true "clear": true
} }
},
{
"type": "shell",
"group": "none",
"label": "Flask: init-db",
"command": "${workspaceFolder}/env/bin/flask --app root/wsgi:app init-db",
"options": {
"cwd": "${workspaceFolder}/App_Server"
},
"problemMatcher": [],
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": false,
"clear": false
}
} }
] ]
} }
+8 -1
View File
@@ -3,7 +3,7 @@ Beachte die strukturellen Vorgaben, die in ai-context/AI-operative-Vorgaben.md d
In den Programmen sollen aussagekräftige Kommentare in deutsch erstellt werden. 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 Anwendung werden Änderungen immer in main gemacht. Ein neuer Branch darf nur auf ausdrückliche Anweisung hin erstellt werden. Ohne diese Anweisung werden Änderungen immer in main gemacht.
## Vorgaben für die Typescript und mithriljs Entwicklung ## Vorgaben für die Typescript und mithriljs Entwicklung
Verwende bei der GUI primar die widgets, die es in client/Components/widgets gibt. Fehler widgets, gib eines Hinweis und erstelle einen Vorschlag für ein entsprechendes neues Widget. 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.
@@ -28,6 +28,8 @@ 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.
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.
Bei `m.ClosureComponent` den Closure-Parameter weglassen (`() =>`) und Attribute ausschließlich über das `vnode` des jeweiligen Lifecycle-Hooks (`view`, `oninit`, `onupdate` etc.) lesen. Das `vnode` der Closure-Funktion ist ein Snapshot zum Erzeugungszeitpunkt und wird bei Redraws nicht aktualisiert — daraus gelesene `attrs` sind veraltet. Initialisierung, die `attrs` braucht, gehört nach `oninit`. Beispiel: Bei `m.ClosureComponent` den Closure-Parameter weglassen (`() =>`) und Attribute ausschließlich über das `vnode` des jeweiligen Lifecycle-Hooks (`view`, `oninit`, `onupdate` etc.) lesen. Das `vnode` der Closure-Funktion ist ein Snapshot zum Erzeugungszeitpunkt und wird bei Redraws nicht aktualisiert — daraus gelesene `attrs` sind veraltet. Initialisierung, die `attrs` braucht, gehört nach `oninit`. Beispiel:
```typescript ```typescript
export const Select: m.ClosureComponent<iSelect> = () => { export const Select: m.ClosureComponent<iSelect> = () => {
@@ -47,4 +49,9 @@ export const Select: m.ClosureComponent<iSelect> = () => {
} }
``` ```
Nach jeder Änderung an `.ts`-Dateien unter `Client/` ist der Build-Prozess (tsc + rollup) auszuführen, damit die deploybaren Bundles in `NGINX_Server/html/static/` aktuell sind. Verwende dafür den `client-build`-Skill.
## Vorgaben für die Server-seitige Entwicklung unter Python ## Vorgaben für die Server-seitige Entwicklung unter Python
## 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.
+160
View File
@@ -34,6 +34,9 @@ def register_cli_commands(app: Flask):
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() _seed_muster_kunden()
_seed_muster_projekte()
_seed_muster_kontaktadressen()
_seed_muster_calllogs()
click.echo("Fertig.") click.echo("Fertig.")
@@ -102,6 +105,163 @@ def _seed_muster_kunden():
session.commit() session.commit()
def _seed_muster_projekte():
"""
Legt für jeden Muster-Kunden 1 bis 3 Muster-Projekte an, falls die
projekte-Tabelle noch leer ist.
"""
import random
from crm.model import Kunde, Projekt
session = dbapp.session
if session.query(Projekt).count() > 0:
return
kunden = session.query(Kunde).order_by(Kunde.id).all()
vorlagen = [
("Website-Relaunch", "Neugestaltung der Unternehmenswebsite"),
("E-Commerce Plattform", "Online-Shop Entwicklung"),
("CRM-System", "Kundenbeziehungsmanagement"),
("Mobile App", "iOS- und Android-Entwicklung"),
("SEO-Optimierung", "Suchmaschinenoptimierung"),
]
verantwortliche = [
"Max Mustermann", "Anna Schmidt", "Peter Müller",
"Lisa Bauer", "Thomas Becker",
]
rng = random.Random(42)
vorlagen_idx = 0
for kunde in kunden:
anzahl = rng.randint(1, 3)
for _ in range(anzahl):
name, zweck = vorlagen[vorlagen_idx % len(vorlagen)]
vorlagen_idx += 1
projekt = Projekt()
projekt.kunden_id = kunde.id
projekt.projektname = name
projekt.zweck = zweck
projekt.verantwortlicher = rng.choice(verantwortliche)
projekt.startdatum = "2024-0{monat}-1{tag}".format(
monat=rng.randint(1, 9), tag=rng.randint(0, 9)
)
session.add(projekt)
session.commit()
def _seed_muster_kontaktadressen():
"""
Legt für jedes Muster-Projekt 1 bis 3 Kontaktadressen an, falls die
kontaktadressen-Tabelle noch leer ist.
"""
import random
from crm.model import Projekt, Kontaktadresse
session = dbapp.session
if session.query(Kontaktadresse).count() > 0:
return
projekte = session.query(Projekt).order_by(Projekt.id).all()
vorlagen = [
("Herr", "Thomas Becker", "Becker Webdesign GmbH", "Geschäftsführer"),
("Frau", "Sandra Klein", "Klein Marketing", "Marketingleiterin"),
("Herr", "Michael Wagner", "Wagner Online Shop", "IT-Leiter"),
("Frau", "Julia Hoffmann", "Hoffmann Consulting", "Beraterin"),
("Herr", "Stefan Roth", "Roth IT Solutions", "Technischer Leiter"),
]
orte = [
("Industriestr. 12", "45131", "Essen", "Deutschland"),
("Königsallee 5", "40212", "Düsseldorf", "Deutschland"),
("Hauptstr. 88", "50667", "Köln", "Deutschland"),
("Marienplatz 3", "80331", "München", "Deutschland"),
]
rng = random.Random(123)
vorlagen_idx = 0
for projekt in projekte:
anzahl = rng.randint(1, 3)
for _ in range(anzahl):
anrede, name, firma, position = vorlagen[vorlagen_idx % len(vorlagen)]
vorlagen_idx += 1
strasse, plz, ort, land = rng.choice(orte)
ka = Kontaktadresse()
ka.projekt_id = projekt.id
ka.anrede = anrede
ka.name = name
ka.firma = firma
ka.position = position
ka.telefon = "+49 {vorwahl} {nummer}".format(
vorwahl=rng.randint(100, 999),
nummer=rng.randint(100000, 999999)
)
ka.email = name.lower().replace(" ", ".") + "@example.de"
ka.strasse = strasse
ka.plz = plz
ka.ort = ort
ka.land = land
ka.notiz = ""
session.add(ka)
session.commit()
def _seed_muster_calllogs():
"""
Legt für jedes Muster-Projekt 2 bis 5 Gesprächsnotizen an, falls die
call_logs-Tabelle noch leer ist.
"""
import random
from crm.model import Projekt, CallLog
session = dbapp.session
if session.query(CallLog).count() > 0:
return
projekte = session.query(Projekt).order_by(Projekt.id).all()
betreffvorlagen = [
"Anforderungen klären",
"Feedback zum Design",
"Technische Anforderungen",
"Schulungstermin",
"Feature-Request",
"Budgetgespräch",
"Projekt-Review",
]
notizvorlagen = [
"Kunde möchte responsive Design und neue Farbgestaltung. Budget noch offen.",
"Design-Entwürfe wurden positiv aufgenommen. Kleine Änderungen an der Navigation nötig.",
"Payment-Gateway und Versand-Schnittstellen müssen integriert werden.",
"Schulung für 10 Mitarbeiter geplant. Raum noch zu buchen.",
"Kunde wünscht Push-Benachrichtigungen und Offline-Modus.",
"",
]
ansprechpartner = ["Herr Schmidt", "Frau Müller", "Herr Weber", "Frau Bauer", "Herr König"]
rng = random.Random(456)
for projekt in projekte:
anzahl = rng.randint(2, 5)
for _ in range(anzahl):
log = CallLog()
log.projekt_id = projekt.id
monat = rng.randint(1, 9)
tag = rng.randint(1, 28)
log.datum = "2024-0{m}-{t}".format(m=monat, t=tag) if monat < 10 else "2024-{m}-{t}".format(m=monat, t=tag)
log.uhrzeit_beginn = "{h:02d}:{min:02d}".format(
h=rng.randint(8, 17), min=rng.randint(0, 59)
)
log.dauer = str(rng.randint(15, 90))
log.betreff = rng.choice(betreffvorlagen)
log.name_ansprechpartner = rng.choice(ansprechpartner)
log.telefon = "+49 {v} {n}".format(
v=rng.randint(100, 999), n=rng.randint(100000, 999999)
)
log.notiz = rng.choice(notizvorlagen)
wiedervorlage_monat = monat + 1 if monat < 12 else 1
log.wiedervorlage_datum = "2024-0{m}-{t}".format(m=wiedervorlage_monat, t=tag) if wiedervorlage_monat < 10 else "2024-{m}-{t}".format(m=wiedervorlage_monat, t=tag)
log.erledigt = rng.choice([True, False])
session.add(log)
session.commit()
def _reset_appdb(): def _reset_appdb():
""" """
Setzt die CRM-Datenbank zurück: alle Tabellen löschen und neu anlegen Setzt die CRM-Datenbank zurück: alle Tabellen löschen und neu anlegen
+150 -1
View File
@@ -12,7 +12,8 @@ erfolgt ausschließlich über __bind_key__.
import datetime import datetime
import decimal import decimal
from sqlalchemy import Column, DateTime, Integer, String from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.orm import relationship
from db import dbapp from db import dbapp
@@ -93,4 +94,152 @@ class Kunde(dbapp.Model):
return columns2dict(self) return columns2dict(self)
class Kontakt(dbapp.Model):
"""
Ansprechpartner bei einem Kunden. Jeder Kontakt ist genau einem Kunden
zugeordnet (kunden_id). Der Kundenname (kunden_name) ist keine eigene
Spalte, sondern wird über die Relationship zu Kunde abgeleitet und nur
in to_dict() für die JSON-Serialisierung ergänzt.
"""
__tablename__ = 'kontakte'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_kontakte_kunden_id', 'kunden_id'),
)
id = Column(Integer, primary_key=True)
kunden_id = Column(Integer, ForeignKey('kunden.id'), nullable=False)
anrede = Column(String(30), nullable=True)
name = Column(String(200), nullable=False)
position = Column(String(200), nullable=True)
telefon = Column(String(100), nullable=True)
email = Column(String(200), nullable=True)
notiz = Column(String(2000), nullable=True)
kunde = relationship('Kunde')
def to_dict(self):
"""
Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; kunden_name wird zusätzlich aus der
Kunde-Relationship eingefügt.
"""
result = columns2dict(self)
result['kunden_name'] = self.kunde.name if self.kunde else None
return result
class Projekt(dbapp.Model):
"""
Projekt eines Kunden. Jedes Projekt ist genau einem Kunden zugeordnet
(kunden_id). Der Kundenname (kunden_name) ist keine eigene Spalte,
sondern wird über die Relationship zu Kunde abgeleitet und nur in
to_dict() für die JSON-Serialisierung ergänzt.
"""
__tablename__ = 'projekte'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_projekte_kunden_id', 'kunden_id'),
)
id = Column(Integer, primary_key=True)
kunden_id = Column(Integer, ForeignKey('kunden.id'), nullable=False)
projektname = Column(String(200), nullable=False)
zweck = Column(String(2000), nullable=True)
verantwortlicher = Column(String(200), nullable=True)
startdatum = Column(String(20), nullable=True)
kunde = relationship('Kunde')
def to_dict(self):
"""
Liefert die Projektdaten als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; kunden_name wird zusätzlich aus der
Kunde-Relationship eingefügt.
"""
result = columns2dict(self)
result['kunden_name'] = self.kunde.name if self.kunde else None
return result
class Kontaktadresse(dbapp.Model):
"""
Akquise-Ziel innerhalb eines Projekts. Jede Kontaktadresse ist genau
einem Projekt zugeordnet (projekt_id). Der Projektname (projekt_name)
ist keine eigene Spalte, sondern wird über die Relationship zu Projekt
abgeleitet und nur in to_dict() für die JSON-Serialisierung ergänzt.
"""
__tablename__ = 'kontaktadressen'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_kontaktadressen_projekt_id', 'projekt_id'),
)
id = Column(Integer, primary_key=True)
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False)
anrede = Column(String(30), nullable=True)
name = Column(String(200), nullable=False)
firma = Column(String(200), nullable=True)
position = Column(String(200), nullable=True)
telefon = Column(String(100), nullable=True)
email = Column(String(200), nullable=True)
strasse = Column(String(200), nullable=True)
plz = Column(String(20), nullable=True)
ort = Column(String(100), nullable=True)
land = Column(String(80), nullable=True)
notiz = Column(String(2000), nullable=True)
projekt = relationship('Projekt')
def to_dict(self):
"""
Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; projekt_name wird zusätzlich aus der
Projekt-Relationship eingefügt.
"""
result = columns2dict(self)
result['projekt_name'] = self.projekt.projektname if self.projekt else None
return result
class CallLog(dbapp.Model):
"""
Gesprächsnotiz für ein Projekt. Jede Gesprächsnotiz ist genau einem
Projekt zugeordnet (projekt_id). Der Projektname (projekt_name) und
der Kundenname (kunden_name) sind keine eigenen Spalten, sondern
werden über die Projekt-Relationship abgeleitet und nur in to_dict()
für die JSON-Serialisierung ergänzt.
"""
__tablename__ = 'call_logs'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_call_logs_projekt_id', 'projekt_id'),
)
id = Column(Integer, primary_key=True)
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False)
datum = Column(String(20), nullable=True)
uhrzeit_beginn = Column(String(10), nullable=True)
dauer = Column(String(10), nullable=True)
betreff = Column(String(200), nullable=True)
name_ansprechpartner = Column(String(200), nullable=True)
telefon = Column(String(100), nullable=True)
notiz = Column(String(2000), nullable=True)
wiedervorlage_datum = Column(String(20), nullable=True)
erledigt = Column(Boolean, nullable=False, default=False)
projekt = relationship('Projekt')
def to_dict(self):
"""
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; projekt_name und kunden_name werden
zusätzlich aus der Projekt-Relationship eingefügt.
"""
result = columns2dict(self)
if self.projekt:
result['projekt_name'] = self.projekt.projektname
result['kunden_name'] = self.projekt.kunde.name if self.projekt.kunde else None
else:
result['projekt_name'] = None
result['kunden_name'] = None
return result
print("########## crm/model importiert") print("########## crm/model importiert")
+124
View File
@@ -0,0 +1,124 @@
"""
Geschäftslogik für die Entität CallLog (Gesprächsnotiz).
Diese Funktionen kapseln den DB-Zugriff und die fachlichen Validierungen.
Aufgerufen werden sie aus crm/routes.py; die Autorisierungsprüfung erfolgt
dort über den Decorator authorisation_required.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-04
:version: 1.0
"""
from db import dbapp
from crm.model import CallLog, Projekt
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_call_logs(projekt_id: int | None = None):
"""
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
Ist projekt_id gesetzt, werden nur die Notizen dieses Projekts
zurückgegeben (serverseitige Filterung). Ohne projekt_id werden
alle Notizen geliefert.
"""
query = CallLog.query
if projekt_id is not None:
query = query.filter(CallLog.projekt_id == projekt_id)
return query.order_by(CallLog.datum.desc()).all()
def get_call_log(call_log_id: int) -> CallLog:
"""
Liefert eine einzelne Gesprächsnotiz anhand ihrer ID.
Wirft ApplicationError (404), falls die Notiz nicht existiert.
"""
call_log = CallLog.query.get(call_log_id)
if call_log is None:
raise ApplicationError(
"Gesprächsnotiz mit der ID {} wurde nicht gefunden.".format(call_log_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return call_log
def create_call_log(data: dict) -> CallLog:
"""
Legt eine neue Gesprächsnotiz an. Die vom Client übergebene ID wird
ignoriert; die ID wird serverseitig per Auto-Increment vergeben.
- data: dict mit den Feldern projekt_id, datum, uhrzeit_beginn, dauer,
betreff, name_ansprechpartner, telefon, notiz,
wiedervorlage_datum, erledigt
"""
projekt_id = data.get("projekt_id")
if projekt_id is None:
raise ApplicationError(
"Es muss ein Projekt ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
projekt = Projekt.query.get(int(projekt_id))
if projekt is None:
raise ApplicationError(
"Das ausgewählte Projekt existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
call_log = CallLog()
call_log.projekt_id = int(projekt_id)
call_log.datum = (data.get("datum") 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.betreff = (data.get("betreff") or "").strip() or None
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
call_log.telefon = (data.get("telefon") or "").strip() or None
call_log.notiz = (data.get("notiz") or "").strip() or None
call_log.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
call_log.erledigt = bool(data.get("erledigt", False))
session.add(call_log)
session.commit()
return call_log
def update_call_log(data: dict) -> CallLog:
"""
Aktualisiert eine bestehende Gesprächsnotiz. Die ID im data-Dict
bestimmt den zu ändernden Datensatz.
"""
call_log = get_call_log(data["id"])
projekt_id = data.get("projekt_id")
if projekt_id is None:
raise ApplicationError(
"Es muss ein Projekt ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
projekt = Projekt.query.get(int(projekt_id))
if projekt is None:
raise ApplicationError(
"Das ausgewählte Projekt existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
call_log.projekt_id = int(projekt_id)
call_log.datum = (data.get("datum") 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.betreff = (data.get("betreff") or "").strip() or None
call_log.name_ansprechpartner = (data.get("name_ansprechpartner") or "").strip() or None
call_log.telefon = (data.get("telefon") or "").strip() or None
call_log.notiz = (data.get("notiz") or "").strip() or None
call_log.wiedervorlage_datum = (data.get("wiedervorlage_datum") or "").strip() or None
call_log.erledigt = bool(data.get("erledigt", False))
session.commit()
return call_log
def delete_call_log(call_log_id: int):
"""
Löscht eine Gesprächsnotiz anhand ihrer ID.
Wirft ApplicationError (404), falls die Notiz nicht existiert.
"""
call_log = get_call_log(call_log_id)
session.delete(call_log)
session.commit()
+65
View File
@@ -43,6 +43,71 @@ def _db_V01_00():
session.commit() session.commit()
def _db_V01_01():
"""
Legt die Tabelle 'kontakte' an. Bei bestehenden Datenbanken, die mit
Version 01.00 initialisiert wurden, fehlt diese Tabelle, da das
Kontakt-Modell erst nachträglich ergänzt wurde. create_all erzeugt
nur fehlende Tabellen, bestehende bleiben unangetastet.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.01')
session.commit()
def _db_V01_02():
"""
Legt die Tabelle 'projekte' an. Bei bestehenden Datenbanken, die mit
Version 01.01 initialisiert wurden, fehlt diese Tabelle, da das
Projekt-Modell erst nachträglich ergänzt wurde. create_all erzeugt
nur fehlende Tabellen, bestehende bleiben unangetastet.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.02')
session.commit()
def _db_V01_03():
"""
Legt die Tabellen 'kontaktadressen' und 'call_logs' an. Bei bestehenden
Datenbanken, die mit Version 01.02 initialisiert wurden, fehlen diese
Tabellen, da die Modelle erst nachträglich ergänzt wurden.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.03')
session.commit()
def _db_V01_04():
"""
Fügt die Spalte 'position' zur Tabelle 'kontaktadressen' hinzu.
Bei bestehenden Datenbanken, die mit Version 01.03 initialisiert wurden,
fehlt diese Spalte, da das Feld erst nachträglich ergänzt wurde.
"""
from sqlalchemy import text
session.execute(text("ALTER TABLE kontaktadressen ADD COLUMN position VARCHAR(200)"))
_updatesysteminfo('01.04')
session.commit()
def _db_V01_05():
"""
Legt Indizes auf den Fremdschlüsselspalten an, die für die
serverseitige Filterung der Listen-Endpunkte genutzt werden.
CREATE INDEX IF NOT EXISTS ist idempotent und überspringet bereits
vorhandene Indizes.
"""
from sqlalchemy import text
session.execute(text("CREATE INDEX IF NOT EXISTS ix_kontakte_kunden_id ON kontakte (kunden_id)"))
session.execute(text("CREATE INDEX IF NOT EXISTS ix_projekte_kunden_id ON projekte (kunden_id)"))
session.execute(text("CREATE INDEX IF NOT EXISTS ix_kontaktadressen_projekt_id ON kontaktadressen (projekt_id)"))
session.execute(text("CREATE INDEX IF NOT EXISTS ix_call_logs_projekt_id ON call_logs (projekt_id)"))
_updatesysteminfo('01.05')
session.commit()
def 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
+127
View File
@@ -0,0 +1,127 @@
"""
Geschäftslogik für die Entität Kontakt.
Diese Funktionen kapseln den DB-Zugriff und die fachlichen Validierungen.
Aufgerufen werden sie aus crm/routes.py; die Autorisierungsprüfung erfolgt
dort über den Decorator authorisation_required.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-03
:version: 1.0
"""
from db import dbapp
from crm.model import Kontakt, Kunde
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_kontakte(kunden_id: int | None = None):
"""
Liefert Kontakte, sortiert nach Name.
Ist kunden_id gesetzt, werden nur die Kontakte dieses Kunden
zurückgegeben (serverseitige Filterung). Ohne kunden_id werden
alle Kontakte geliefert.
"""
query = Kontakt.query
if kunden_id is not None:
query = query.filter(Kontakt.kunden_id == kunden_id)
return query.order_by(Kontakt.name).all()
def get_kontakt(kontakt_id: int) -> Kontakt:
"""
Liefert einen einzelnen Kontakt anhand seiner ID.
Wirft ApplicationError (404), falls der Kontakt nicht existiert.
"""
kontakt = Kontakt.query.get(kontakt_id)
if kontakt is None:
raise ApplicationError(
"Kontakt mit der ID {} wurde nicht gefunden.".format(kontakt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return kontakt
def create_kontakt(data: dict) -> Kontakt:
"""
Legt einen neuen Kontakt an. Die vom Client übergebene ID wird ignoriert;
die ID wird serverseitig per Auto-Increment vergeben.
- data: dict mit den Feldern kunden_id, anrede, name, position, telefon,
email, notiz
"""
kunden_id = data.get("kunden_id")
if kunden_id is None:
raise ApplicationError(
"Es muss ein Kunde ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kunde = Kunde.query.get(int(kunden_id))
if kunde is None:
raise ApplicationError(
"Der ausgewählte Kunde existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
if not data.get("name") or not str(data["name"]).strip():
raise ApplicationError(
"Der Kontaktname darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kontakt = Kontakt()
kontakt.kunden_id = int(kunden_id)
kontakt.anrede = (data.get("anrede") or "").strip() or None
kontakt.name = data["name"].strip()
kontakt.position = (data.get("position") or "").strip() or None
kontakt.telefon = (data.get("telefon") or "").strip() or None
kontakt.email = (data.get("email") or "").strip() or None
kontakt.notiz = (data.get("notiz") or "").strip() or None
session.add(kontakt)
session.commit()
return kontakt
def update_kontakt(data: dict) -> Kontakt:
"""
Aktualisiert einen bestehenden Kontakt. Die ID im data-Dict bestimmt den
zu ändernden Datensatz.
"""
kontakt = get_kontakt(data["id"])
kunden_id = data.get("kunden_id")
if kunden_id is None:
raise ApplicationError(
"Es muss ein Kunde ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kunde = Kunde.query.get(int(kunden_id))
if kunde is None:
raise ApplicationError(
"Der ausgewählte Kunde existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
if not data.get("name") or not str(data["name"]).strip():
raise ApplicationError(
"Der Kontaktname darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kontakt.kunden_id = int(kunden_id)
kontakt.anrede = (data.get("anrede") or "").strip() or None
kontakt.name = data["name"].strip()
kontakt.position = (data.get("position") or "").strip() or None
kontakt.telefon = (data.get("telefon") or "").strip() or None
kontakt.email = (data.get("email") or "").strip() or None
kontakt.notiz = (data.get("notiz") or "").strip() or None
session.commit()
return kontakt
def delete_kontakt(kontakt_id: int):
"""
Löscht einen Kontakt anhand seiner ID.
Wirft ApplicationError (404), falls der Kontakt nicht existiert.
"""
kontakt = get_kontakt(kontakt_id)
session.delete(kontakt)
session.commit()
+137
View File
@@ -0,0 +1,137 @@
"""
Geschäftslogik für die Entität Kontaktadresse.
Diese Funktionen kapseln den DB-Zugriff und die fachlichen Validierungen.
Aufgerufen werden sie aus crm/routes.py; die Autorisierungsprüfung erfolgt
dort über den Decorator authorisation_required.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-04
:version: 1.0
"""
from db import dbapp
from crm.model import Kontaktadresse, Projekt
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_kontaktadressen(projekt_id: int | None = None):
"""
Liefert Kontaktadressen, sortiert nach Name.
Ist projekt_id gesetzt, werden nur die Kontaktadressen dieses
Projekts zurückgegeben (serverseitige Filterung). Ohne projekt_id
werden alle Kontaktadressen geliefert.
"""
query = Kontaktadresse.query
if projekt_id is not None:
query = query.filter(Kontaktadresse.projekt_id == projekt_id)
return query.order_by(Kontaktadresse.name).all()
def get_kontaktadresse(kontaktadresse_id: int) -> Kontaktadresse:
"""
Liefert eine einzelne Kontaktadresse anhand ihrer ID.
Wirft ApplicationError (404), falls die Kontaktadresse nicht existiert.
"""
kontaktadresse = Kontaktadresse.query.get(kontaktadresse_id)
if kontaktadresse is None:
raise ApplicationError(
"Kontaktadresse mit der ID {} wurde nicht gefunden.".format(kontaktadresse_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return kontaktadresse
def create_kontaktadresse(data: dict) -> Kontaktadresse:
"""
Legt eine neue Kontaktadresse an. Die vom Client übergebene ID wird
ignoriert; die ID wird serverseitig per Auto-Increment vergeben.
- data: dict mit den Feldern projekt_id, anrede, name, firma, telefon,
email, strasse, plz, ort, land, notiz
"""
projekt_id = data.get("projekt_id")
if projekt_id is None:
raise ApplicationError(
"Es muss ein Projekt ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
projekt = Projekt.query.get(int(projekt_id))
if projekt is None:
raise ApplicationError(
"Das ausgewählte Projekt existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
if not data.get("name") or not str(data["name"]).strip():
raise ApplicationError(
"Der Name darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kontaktadresse = Kontaktadresse()
kontaktadresse.projekt_id = int(projekt_id)
kontaktadresse.anrede = (data.get("anrede") or "").strip() or None
kontaktadresse.name = data["name"].strip()
kontaktadresse.firma = (data.get("firma") or "").strip() or None
kontaktadresse.position = (data.get("position") or "").strip() or None
kontaktadresse.telefon = (data.get("telefon") or "").strip() or None
kontaktadresse.email = (data.get("email") or "").strip() or None
kontaktadresse.strasse = (data.get("strasse") or "").strip() or None
kontaktadresse.plz = (data.get("plz") or "").strip() or None
kontaktadresse.ort = (data.get("ort") or "").strip() or None
kontaktadresse.land = (data.get("land") or "").strip() or None
kontaktadresse.notiz = (data.get("notiz") or "").strip() or None
session.add(kontaktadresse)
session.commit()
return kontaktadresse
def update_kontaktadresse(data: dict) -> Kontaktadresse:
"""
Aktualisiert eine bestehende Kontaktadresse. Die ID im data-Dict
bestimmt den zu ändernden Datensatz.
"""
kontaktadresse = get_kontaktadresse(data["id"])
projekt_id = data.get("projekt_id")
if projekt_id is None:
raise ApplicationError(
"Es muss ein Projekt ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
projekt = Projekt.query.get(int(projekt_id))
if projekt is None:
raise ApplicationError(
"Das ausgewählte Projekt existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
if not data.get("name") or not str(data["name"]).strip():
raise ApplicationError(
"Der Name darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kontaktadresse.projekt_id = int(projekt_id)
kontaktadresse.anrede = (data.get("anrede") or "").strip() or None
kontaktadresse.name = data["name"].strip()
kontaktadresse.firma = (data.get("firma") or "").strip() or None
kontaktadresse.position = (data.get("position") or "").strip() or None
kontaktadresse.telefon = (data.get("telefon") or "").strip() or None
kontaktadresse.email = (data.get("email") or "").strip() or None
kontaktadresse.strasse = (data.get("strasse") or "").strip() or None
kontaktadresse.plz = (data.get("plz") or "").strip() or None
kontaktadresse.ort = (data.get("ort") or "").strip() or None
kontaktadresse.land = (data.get("land") or "").strip() or None
kontaktadresse.notiz = (data.get("notiz") or "").strip() or None
session.commit()
return kontaktadresse
def delete_kontaktadresse(kontaktadresse_id: int):
"""
Löscht eine Kontaktadresse anhand ihrer ID.
Wirft ApplicationError (404), falls die Kontaktadresse nicht existiert.
"""
kontaktadresse = get_kontaktadresse(kontaktadresse_id)
session.delete(kontaktadresse)
session.commit()
+123
View File
@@ -0,0 +1,123 @@
"""
Geschäftslogik für die Entität Projekt.
Diese Funktionen kapseln den DB-Zugriff und die fachlichen Validierungen.
Aufgerufen werden sie aus crm/routes.py; die Autorisierungsprüfung erfolgt
dort über den Decorator authorisation_required.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-04
:version: 1.0
"""
from db import dbapp
from crm.model import Projekt, Kunde
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_projekte(kunden_id: int | None = None):
"""
Liefert Projekte, sortiert nach projektname.
Ist kunden_id gesetzt, werden nur die Projekte dieses Kunden
zurückgegeben (serverseitige Filterung). Ohne kunden_id werden
alle Projekte geliefert.
"""
query = Projekt.query
if kunden_id is not None:
query = query.filter(Projekt.kunden_id == kunden_id)
return query.order_by(Projekt.projektname).all()
def get_projekt(projekt_id: int) -> Projekt:
"""
Liefert ein einzelnes Projekt anhand seiner ID.
Wirft ApplicationError (404), falls das Projekt nicht existiert.
"""
projekt = Projekt.query.get(projekt_id)
if projekt is None:
raise ApplicationError(
"Projekt mit der ID {} wurde nicht gefunden.".format(projekt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return projekt
def create_projekt(data: dict) -> Projekt:
"""
Legt ein neues Projekt an. Die vom Client übergebene ID wird ignoriert;
die ID wird serverseitig per Auto-Increment vergeben.
- data: dict mit den Feldern kunden_id, projektname, zweck,
verantwortlicher, startdatum
"""
kunden_id = data.get("kunden_id")
if kunden_id is None:
raise ApplicationError(
"Es muss ein Kunde ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kunde = Kunde.query.get(int(kunden_id))
if kunde is None:
raise ApplicationError(
"Der ausgewählte Kunde existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
if not data.get("projektname") or not str(data["projektname"]).strip():
raise ApplicationError(
"Der Projektname darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
projekt = Projekt()
projekt.kunden_id = int(kunden_id)
projekt.projektname = data["projektname"].strip()
projekt.zweck = (data.get("zweck") or "").strip() or None
projekt.verantwortlicher = (data.get("verantwortlicher") or "").strip() or None
projekt.startdatum = (data.get("startdatum") or "").strip() or None
session.add(projekt)
session.commit()
return projekt
def update_projekt(data: dict) -> Projekt:
"""
Aktualisiert ein bestehendes Projekt. Die ID im data-Dict bestimmt den
zu ändernden Datensatz.
"""
projekt = get_projekt(data["id"])
kunden_id = data.get("kunden_id")
if kunden_id is None:
raise ApplicationError(
"Es muss ein Kunde ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
kunde = Kunde.query.get(int(kunden_id))
if kunde is None:
raise ApplicationError(
"Der ausgewählte Kunde existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
if not data.get("projektname") or not str(data["projektname"]).strip():
raise ApplicationError(
"Der Projektname darf nicht leer sein.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
projekt.kunden_id = int(kunden_id)
projekt.projektname = data["projektname"].strip()
projekt.zweck = (data.get("zweck") or "").strip() or None
projekt.verantwortlicher = (data.get("verantwortlicher") or "").strip() or None
projekt.startdatum = (data.get("startdatum") or "").strip() or None
session.commit()
return projekt
def delete_projekt(projekt_id: int):
"""
Löscht ein Projekt anhand seiner ID.
Wirft ApplicationError (404), falls das Projekt nicht existiert.
"""
projekt = get_projekt(projekt_id)
session.delete(projekt)
session.commit()
+188
View File
@@ -19,6 +19,10 @@ from flask import jsonify, request
from auth.decorators import authorisation_required from auth.decorators import authorisation_required
from crm.model import KUNDEN_MANAGER, MITARBEITER from crm.model import KUNDEN_MANAGER, MITARBEITER
from crm.model import kunde as kunden from crm.model import kunde as kunden
from crm.model import kontakt as kontakte
from crm.model import projekt as projekte
from crm.model import kontaktadresse as kontaktadressen
from crm.model import call_log as call_logs
print("crm-routes") print("crm-routes")
@@ -68,4 +72,188 @@ def kunde_delete():
return jsonify({}), 200 return jsonify({}), 200
# --------------------------------------------------------------------------- #
# Kontakt #
# --------------------------------------------------------------------------- #
@app.get("/xhr/kontakt/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def kontakt_list():
"""Liefert Kontakte als Liste. Optional nach kunden_id gefiltert."""
kunden_id = request.args.get("kunden_id", type=int)
result = [k.to_dict() for k in kontakte.get_kontakte(kunden_id)]
return jsonify(result), 200
@app.get("/xhr/kontakt/get")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def kontakt_get():
"""Liefert einen einzelnen Kontakt anhand seiner ID (Query-Param id)."""
kontakt = kontakte.get_kontakt(int(request.args.get("id")))
return jsonify(kontakt.to_dict()), 200
@app.post("/xhr/kontakt/create")
@authorisation_required([KUNDEN_MANAGER])
def kontakt_create():
"""Legt einen neuen Kontakt an. ID wird serverseitig vergeben."""
kontakt = kontakte.create_kontakt(request.json)
return jsonify(kontakt.to_dict()), 200
@app.put("/xhr/kontakt/update")
@authorisation_required([KUNDEN_MANAGER])
def kontakt_update():
"""Aktualisiert einen bestehenden Kontakt."""
kontakt = kontakte.update_kontakt(request.json)
return jsonify(kontakt.to_dict()), 200
@app.delete("/xhr/kontakt/delete")
@authorisation_required([KUNDEN_MANAGER])
def kontakt_delete():
"""Löscht einen Kontakt anhand seiner ID (Query-Param id)."""
kontakte.delete_kontakt(int(request.args.get("id")))
return jsonify({}), 200
# --------------------------------------------------------------------------- #
# Projekt #
# --------------------------------------------------------------------------- #
@app.get("/xhr/projekt/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def projekt_list():
"""Liefert Projekte als Liste. Optional nach kunden_id gefiltert."""
kunden_id = request.args.get("kunden_id", type=int)
result = [p.to_dict() for p in projekte.get_projekte(kunden_id)]
return jsonify(result), 200
@app.get("/xhr/projekt/get")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def projekt_get():
"""Liefert ein einzelnes Projekt anhand seiner ID (Query-Param id)."""
projekt = projekte.get_projekt(int(request.args.get("id")))
return jsonify(projekt.to_dict()), 200
@app.post("/xhr/projekt/create")
@authorisation_required([KUNDEN_MANAGER])
def projekt_create():
"""Legt ein neues Projekt an. ID wird serverseitig vergeben."""
projekt = projekte.create_projekt(request.json)
return jsonify(projekt.to_dict()), 200
@app.put("/xhr/projekt/update")
@authorisation_required([KUNDEN_MANAGER])
def projekt_update():
"""Aktualisiert ein bestehendes Projekt."""
projekt = projekte.update_projekt(request.json)
return jsonify(projekt.to_dict()), 200
@app.delete("/xhr/projekt/delete")
@authorisation_required([KUNDEN_MANAGER])
def projekt_delete():
"""Löscht ein Projekt anhand seiner ID (Query-Param id)."""
projekte.delete_projekt(int(request.args.get("id")))
return jsonify({}), 200
# --------------------------------------------------------------------------- #
# Kontaktadresse #
# --------------------------------------------------------------------------- #
@app.get("/xhr/kontaktadresse/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def kontaktadresse_list():
"""Liefert Kontaktadressen als Liste. Optional nach projekt_id gefiltert."""
projekt_id = request.args.get("projekt_id", type=int)
result = [k.to_dict() for k in kontaktadressen.get_kontaktadressen(projekt_id)]
return jsonify(result), 200
@app.get("/xhr/kontaktadresse/get")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def kontaktadresse_get():
"""Liefert eine einzelne Kontaktadresse anhand ihrer ID (Query-Param id)."""
kontaktadresse = kontaktadressen.get_kontaktadresse(int(request.args.get("id")))
return jsonify(kontaktadresse.to_dict()), 200
@app.post("/xhr/kontaktadresse/create")
@authorisation_required([KUNDEN_MANAGER])
def kontaktadresse_create():
"""Legt eine neue Kontaktadresse an. ID wird serverseitig vergeben."""
kontaktadresse = kontaktadressen.create_kontaktadresse(request.json)
return jsonify(kontaktadresse.to_dict()), 200
@app.put("/xhr/kontaktadresse/update")
@authorisation_required([KUNDEN_MANAGER])
def kontaktadresse_update():
"""Aktualisiert eine bestehende Kontaktadresse."""
kontaktadresse = kontaktadressen.update_kontaktadresse(request.json)
return jsonify(kontaktadresse.to_dict()), 200
@app.delete("/xhr/kontaktadresse/delete")
@authorisation_required([KUNDEN_MANAGER])
def kontaktadresse_delete():
"""Löscht eine Kontaktadresse anhand ihrer ID (Query-Param id)."""
kontaktadressen.delete_kontaktadresse(int(request.args.get("id")))
return jsonify({}), 200
# --------------------------------------------------------------------------- #
# CallLog (Gesprächsnotiz) #
# --------------------------------------------------------------------------- #
@app.get("/xhr/calllog/list")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def calllog_list():
"""Liefert Gesprächsnotizen als Liste. Optional nach projekt_id gefiltert."""
projekt_id = request.args.get("projekt_id", type=int)
result = [l.to_dict() for l in call_logs.get_call_logs(projekt_id)]
return jsonify(result), 200
@app.get("/xhr/calllog/get")
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
def calllog_get():
"""Liefert eine einzelne Gesprächsnotiz anhand ihrer ID (Query-Param id)."""
call_log = call_logs.get_call_log(int(request.args.get("id")))
return jsonify(call_log.to_dict()), 200
@app.post("/xhr/calllog/create")
@authorisation_required([KUNDEN_MANAGER])
def calllog_create():
"""Legt eine neue Gesprächsnotiz an. ID wird serverseitig vergeben."""
call_log = call_logs.create_call_log(request.json)
return jsonify(call_log.to_dict()), 200
@app.put("/xhr/calllog/update")
@authorisation_required([KUNDEN_MANAGER])
def calllog_update():
"""Aktualisiert eine bestehende Gesprächsnotiz."""
call_log = call_logs.update_call_log(request.json)
return jsonify(call_log.to_dict()), 200
@app.delete("/xhr/calllog/delete")
@authorisation_required([KUNDEN_MANAGER])
def calllog_delete():
"""Löscht eine Gesprächsnotiz anhand ihrer ID (Query-Param id)."""
call_logs.delete_call_log(int(request.args.get("id")))
return jsonify({}), 200
print("########## crm.routes importiert") print("########## crm.routes importiert")
+190 -190
View File
@@ -3,28 +3,24 @@ 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, 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 {iCallLog, getCallLogsByProjektId, getNewCallLogId, addCallLogData, updateCallLogData} from "../models/call_log" import {iCallLog, fetchCallLogs, fetchCallLogById, createCallLogREST, updateCallLogREST} from "../models/call_log"
import {iKunde, getKunden} from "../models/kunde" import {iKunde, fetchKunden} from "../models/kunde"
import {getProjekteByKundenId, getProjektById} from "../models/projekt" import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
import {getKontaktadressenByProjektId, getKontaktadresseById} from "../models/kontaktadresse" import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden. // Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
// 1000 = jede Sekunde, 60000 = jede Minute.
const DAUER_UPDATE_INTERVALL = 1000 const DAUER_UPDATE_INTERVALL = 1000
function createCallLogTreenodes(projektId: number | null = null): iTreenode[] {
let filteredLogs = getCallLogsByProjektId(projektId)
return filteredLogs.map((log: iCallLog) => {
return new Treenode(log, true, [])
})
}
export const call_log_liste: m.ClosureComponent = (vnode) => { export const call_log_liste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = createCallLogTreenodes() let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null let selectedNode: iTreenode | null = null
let filterProjektId: number | null = null let filterProjektId: number | null = null
// Projektname für die Überschrift, asynchron per fetchProjektById
// aufgelöst. kunden_name wird ebenfalls aus dem Projekt bezogen,
// da es nicht in den Route-Params enthalten ist.
let projektName: string = ""
let kundenName: string = ""
const handleRowMarked = (node: iTreenode) => { const handleRowMarked = (node: iTreenode) => {
selectedNode = node selectedNode = node
} }
@@ -36,92 +32,91 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
const params: any = { const params: any = {
id: -1, id: -1,
datum: datum, datum: datum,
uhrzeitBeginn: uhrzeit, uhrzeit_beginn: uhrzeit,
dauer: "", dauer: "",
betreff: "", betreff: "",
nameAnsprechpartner: "", name_ansprechpartner: "",
telefon: "", telefon: "",
notiz: "", notiz: "",
wiedervorlageDatum: "", wiedervorlage_datum: "",
erledigt: false erledigt: false
} }
// Bei projektgefilterter Liste Kunde und Projekt vorbelegen.
if (filterProjektId !== null) { if (filterProjektId !== null) {
const projekt = getProjektById(filterProjektId) params.projekt_id = filterProjektId
if (projekt) { params.projekt_name = projektName
params.kundenName = projekt.kundenName params.kunden_name = kundenName
params.projektId = projekt.id
params.projektName = projekt.projektname
}
} }
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", { m.route.set("/call_log", {id: log.id})
id: log.id,
kundenName: log.kundenName,
projektId: log.projektId,
projektName: log.projektName,
datum: log.datum,
uhrzeitBeginn: log.uhrzeitBeginn,
dauer: log.dauer,
betreff: log.betreff,
nameAnsprechpartner: log.nameAnsprechpartner,
telefon: log.telefon,
notiz: log.notiz,
wiedervorlageDatum: log.wiedervorlageDatum,
erledigt: log.erledigt
})
} }
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let log: iCallLog = node.nodevalue let log: iCallLog = node.nodevalue
return [ return [
m("div", m("strong", log.kundenName + ": " + log.projektName)), m("div", m("strong", log.kunden_name + ": " + log.projekt_name)),
m("span", log.datum), m("span", log.datum),
m("span", log.uhrzeitBeginn), m("span", log.uhrzeit_beginn),
m("span", log.dauer + " min"), m("span", log.dauer + " min"),
m("span", log.betreff), m("span", log.betreff),
m("span", log.wiedervorlageDatum), m("span", log.wiedervorlage_datum),
m("span", log.erledigt ? "Erledigt" : "Offen"), 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"))
] ]
} }
const filterFunction = (node: iTreenode, searchvalue: string): boolean => { const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let log: iCallLog = node.nodevalue let log: iCallLog = node.nodevalue
let searchLower = searchvalue.toLowerCase() let searchLower = searchvalue.toLowerCase()
return log.kundenName.toLowerCase().includes(searchLower) || return (log.kunden_name || "").toLowerCase().includes(searchLower) ||
log.projektName.toLowerCase().includes(searchLower) || (log.projekt_name || "").toLowerCase().includes(searchLower) ||
log.datum.includes(searchLower) || (log.datum || "").includes(searchLower) ||
log.uhrzeitBeginn.toLowerCase().includes(searchLower) || (log.uhrzeit_beginn || "").toLowerCase().includes(searchLower) ||
log.betreff.toLowerCase().includes(searchLower) || (log.betreff || "").toLowerCase().includes(searchLower) ||
log.nameAnsprechpartner.toLowerCase().includes(searchLower) || (log.name_ansprechpartner || "").toLowerCase().includes(searchLower) ||
log.notiz.toLowerCase().includes(searchLower) || (log.notiz || "").toLowerCase().includes(searchLower) ||
log.wiedervorlageDatum.includes(searchLower) (log.wiedervorlage_datum || "").includes(searchLower)
} }
return { return {
oninit: (vnode) => { oninit: (vnode) => {
const routeParams = m.route.param() const routeParams = m.route.param()
if (routeParams && routeParams.projektId) { if (routeParams && routeParams.projekt_id) {
filterProjektId = parseInt(routeParams.projektId) filterProjektId = parseInt(routeParams.projekt_id)
projektName = routeParams.projekt_name || ""
} else { } else {
filterProjektId = null filterProjektId = null
projektName = ""
}
// Gesprächsnotizen asynchron vom Server laden. Die Filterung
// nach projekt_id erfolgt serverseitig, sofern ein Filter
// gesetzt ist.
fetchCallLogs(filterProjektId !== null ? filterProjektId : undefined).then((result) => {
treenodes = result.map((log: iCallLog) => new Treenode(log, true, []))
})
// Projektnamen und Kundennamen asynchron auflösen, falls
// nicht in den Route-Params enthalten.
if (filterProjektId !== null) {
fetchProjektById(filterProjektId).then((projekt: iProjekt) => {
if (projektName === "") {
projektName = projekt.projektname
}
kundenName = projekt.kunden_name
})
} }
treenodes = createCallLogTreenodes(filterProjektId)
}, },
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", "Gesprächsnotizen"), m("h1", projektName ? "Gesprächsnotizen für " + projektName : "Gesprächsnotizen"),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable, m(Treetable,
{ {
@@ -142,181 +137,190 @@ export const call_log_liste: m.ClosureComponent = (vnode) => {
} }
export const Call_Log: m.ClosureComponent = (vnode) => { export const Call_Log: m.ClosureComponent = (vnode) => {
// currentLog hält den Formularzustand. kundenId ist intern und wird über // currentLog hält den Formularzustand. projekt_id referenziert das
// das Projekt aufgelöst; kundenName/projektName dienen der Anzeige. // Projekt, kunden_name und projekt_name dienen der Anzeige.
let currentLog: { let currentLog: {
id: number id: number
kundenId: number projekt_id: number
kundenName: string projekt_name: string
projektId: number kunden_name: string
projektName: string
datum: string datum: string
uhrzeitBeginn: string uhrzeit_beginn: string
dauer: string dauer: string
betreff: string betreff: string
nameAnsprechpartner: string name_ansprechpartner: string
telefon: string telefon: string
notiz: string notiz: string
wiedervorlageDatum: string wiedervorlage_datum: string
erledigt: boolean erledigt: boolean
} = { } = {
id: -1, id: -1,
kundenId: -1, projekt_id: -1,
kundenName: "", projekt_name: "",
projektId: -1, kunden_name: "",
projektName: "",
datum: "", datum: "",
uhrzeitBeginn: "", uhrzeit_beginn: "",
dauer: "", dauer: "",
betreff: "", betreff: "",
nameAnsprechpartner: "", name_ansprechpartner: "",
telefon: "", telefon: "",
notiz: "", notiz: "",
wiedervorlageDatum: "", wiedervorlage_datum: "",
erledigt: false erledigt: false
} }
// Sind Kunde und Projekt voreingestellt (Liste wurde projektgefiltert // Ist das Projekt voreingestellt (Liste wurde projektgefiltert)?
// oder aus der Projektliste aufgerufen)? let projektVoreingestellt: boolean = false
let kundeVoreingestellt: boolean = false
// Ist der Ansprechpartner voreingestellt (aus Kontaktadressenliste)? // Ist der Ansprechpartner voreingestellt (aus Kontaktadressenliste)?
// Wenn ja, wird ein readonly Input gezeigt; sonst ein Select, das die
// Kontaktadressen des gewählten Projekts anbietet.
let ansprechpartnerVoreingestellt: boolean = false let ansprechpartnerVoreingestellt: boolean = false
// Startzeit für die Dauer-Messung (nur bei Neuanlage gesetzt). // Startzeit für die Dauer-Messung (nur bei Neuanlage gesetzt).
let startZeit: Date | null = null let startZeit: Date | null = null
// Timer-Handle für die Dauer-Aktualisierung. // Timer-Handle für die Dauer-Aktualisierung.
let timerId: number | null = null let timerId: number | null = null
// Direkte DOM-Referenz auf das Dauer-Feld, damit der Timer den Wert // Direkte DOM-Referenz auf das Dauer-Feld.
// ohne Redraw aktualisieren kann (ein Redraw würde die übrigen Felder
// mit den veralteten currentLog-Werten überschreiben).
let dauerInput: HTMLInputElement | null = null let dauerInput: HTMLInputElement | null = null
// Asynchron geladene Listen für Select-Optionen.
let kundenListe: iKunde[] = []
let projektListe: iProjekt[] = []
let kontaktadressenListe: iKontaktadresse[] = []
/** /**
* Formatiert verstrichene Sekunden als mm:ss (Minuten:Sekunden). * Formatiert verstrichene Sekunden als mm:ss.
*/ */
const formatDauer = (sekunden: number): string => { const formatDauer = (sekunden: number): string => {
const m = Math.floor(sekunden / 60) const min = Math.floor(sekunden / 60)
const s = sekunden % 60 const sek = sekunden % 60
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0') return min.toString().padStart(2, '0') + ':' + sek.toString().padStart(2, '0')
} }
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
const projektId = routeParams.projektId ? parseInt(routeParams.projektId) : -1 if (id !== -1) {
kundeVoreingestellt = projektId !== -1 // Gesprächsnotiz frisch vom Server holen.
// kundenId aus dem Projekt ableiten, falls nicht direkt gesetzt. fetchCallLogById(id).then((result) => {
let kundenId = -1 currentLog = result
if (kundeVoreingestellt) { projektVoreingestellt = true
const projekt = getProjektById(projektId) ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
if (projekt) { kundenId = projekt.kundenId } })
} } else {
currentLog = { projektVoreingestellt = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) !== -1 : false
id: routeParams.id ? parseInt(routeParams.id) : -1, currentLog.projekt_id = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) : -1
kundenId: kundenId, currentLog.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
kundenName: routeParams.kundenName || "", currentLog.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
projektId: projektId, currentLog.datum = routeParams && routeParams.datum ? routeParams.datum : ""
projektName: routeParams.projektName || "", currentLog.uhrzeit_beginn = routeParams && routeParams.uhrzeit_beginn ? routeParams.uhrzeit_beginn : ""
datum: routeParams.datum || "", currentLog.dauer = routeParams && routeParams.dauer ? routeParams.dauer : ""
uhrzeitBeginn: routeParams.uhrzeitBeginn || "", currentLog.betreff = routeParams && routeParams.betreff ? routeParams.betreff : ""
dauer: routeParams.dauer || "", currentLog.name_ansprechpartner = routeParams && routeParams.name_ansprechpartner ? routeParams.name_ansprechpartner : ""
betreff: routeParams.betreff || "", currentLog.telefon = routeParams && routeParams.telefon ? routeParams.telefon : ""
nameAnsprechpartner: routeParams.nameAnsprechpartner || "", currentLog.notiz = routeParams && routeParams.notiz ? routeParams.notiz : ""
telefon: routeParams.telefon || "", currentLog.wiedervorlage_datum = routeParams && routeParams.wiedervorlage_datum ? routeParams.wiedervorlage_datum : ""
notiz: routeParams.notiz || "", currentLog.erledigt = routeParams && (routeParams.erledigt === true || routeParams.erledigt === "true")
wiedervorlageDatum: routeParams.wiedervorlageDatum || "", ansprechpartnerVoreingestellt = currentLog.name_ansprechpartner !== ""
erledigt: routeParams.erledigt === true || routeParams.erledigt === "true"
}
// Bei Neuanlage Startzeit merken und Dauer-Timer starten. // Bei Neuanlage Startzeit merken und Dauer-Timer starten.
ansprechpartnerVoreingestellt = currentLog.nameAnsprechpartner !== "" startZeit = new Date()
if (currentLog.id === -1) { timerId = window.setInterval(() => {
startZeit = new Date() if (startZeit) {
timerId = window.setInterval(() => { const sekunden = Math.floor((Date.now() - startZeit.getTime()) / 1000)
if (startZeit) { const dauer = formatDauer(sekunden)
// Verstrichene Sekunden berechnen; das Intervall currentLog.dauer = dauer
// steuert nur die Update-Frequenz. if (dauerInput) { dauerInput.value = dauer }
const sekunden = Math.floor((Date.now() - startZeit.getTime()) / 1000) }
const dauer = formatDauer(sekunden) }, DAUER_UPDATE_INTERVALL)
currentLog.dauer = dauer
// Nur das Dauer-Feld direkt im DOM aktualisieren,
// ohne redraw (sonst werden andere Felder zurückgesetzt).
if (dauerInput) { dauerInput.value = dauer }
}
}, DAUER_UPDATE_INTERVALL)
}
} }
// Alle Listen asynchron laden, für Select-Optionen.
fetchKunden().then((result) => { kundenListe = result })
fetchProjekte().then((result) => { projektListe = result })
fetchKontaktadressen().then((result) => { kontaktadressenListe = result })
}, },
onremove: (vnode) => { onremove: (vnode) => {
// Timer stoppen, damit er nach Verlassen des Views nicht weiterläuft.
if (timerId != null) { window.clearInterval(timerId) } if (timerId != null) { window.clearInterval(timerId) }
}, },
view: (vnode) => { view: (vnode) => {
// Select-Optionen aus Kunden- und Projektliste aufbauen. // kunden_id aus dem geladenen Projekt ableiten (fuer abhaengiges
const kundenOptionen: iSelectOption[] = getKunden().map((k: iKunde) => { // Projekt-Select). Wird nur bei nicht voreingestelltem Projekt
// benoetigt.
const internKundenId = (() => {
if (currentLog.projekt_id !== -1) {
const p = projektListe.find((p: iProjekt) => p.id === currentLog.projekt_id)
return p ? p.kunden_id : -1
}
return -1
})()
const kundenOptionen: iSelectOption[] = kundenListe.map((k: iKunde) => {
return {value: k.id, label: k.name} 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[] = getProjekteByKundenId(currentLog.kundenId).map((p) => { const projektOptionen: iSelectOption[] = projektListe
return {value: p.id, label: p.projektname} .filter((p: iProjekt) => internKundenId === -1 || p.kunden_id === internKundenId)
}) .map((p: iProjekt) => {
// Kontaktadressen-Optionen sind vom gewählten Projekt abhängig. return {value: p.id, label: p.projektname}
// Werden nur benötigt, wenn der Ansprechpartner nicht voreingestellt ist.
const kontaktadressenOptionen: iSelectOption[] = !ansprechpartnerVoreingestellt
? getKontaktadressenByProjektId(currentLog.projektId).map((ka) => {
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
}) })
// Kontaktadressen-Optionen sind vom gewählten Projekt abhängig.
const kontaktadressenOptionen: iSelectOption[] = !ansprechpartnerVoreingestellt
? kontaktadressenListe
.filter((ka: iKontaktadresse) => ka.projekt_id === currentLog.projekt_id)
.map((ka: iKontaktadresse) => {
return {value: ka.id, label: ka.name + (ka.firma ? " (" + ka.firma + ")" : "")}
})
: [] : []
// Einheit der Dauer-Anzeige im Format mm:ss.
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
const gespeichertesLog: iCallLog = {
id: currentLog.id,
projekt_id: currentLog.projekt_id,
projekt_name: "",
kunden_name: "",
datum: (formData.get("datum") as string) || "",
uhrzeit_beginn: (formData.get("uhrzeit_beginn") as string) || "",
dauer: (formData.get("dauer") as string) || "",
betreff: (formData.get("betreff") as string) || "",
name_ansprechpartner: (formData.get("name_ansprechpartner") as string) || "",
telefon: (formData.get("telefon") as string) || "",
notiz: (formData.get("notiz") as string) || "",
wiedervorlage_datum: (formData.get("wiedervorlage_datum") as string) || "",
erledigt: formData.get("erledigt") === "on"
}
if (currentLog.id === -1) {
createCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name}))
} else {
updateCallLogREST(gespeichertesLog).then(() => m.route.set("/calllogliste", {projekt_id: currentLog.projekt_id, projekt_name: currentLog.projekt_name}))
}
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"),
// Form füllt den verbleibenden Platz; das Form-Widget selbst
// sorgt (mit stickyButtons, Default) dafür, dass nur der
// Inhalt scrollt und die Buttons fixiert bleiben.
m("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, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}),
// Kunde und Projekt als Read-Only-Text, da voreingestellt.
m("label", "Kunde", m("label", "Kunde",
m(Select, { m("input.w3-input.w3-border", {
name: "kundenId", type: "text",
value: currentLog.kundenId, value: currentLog.kunden_name,
options: kundenOptionen, readonly: true,
readonly: kundeVoreingestellt, style: {'margin-bottom': '5px'}
required: true, })),
onchanged: (kundenId: number) => {
// Bei Kundenwechsel die kundenId übernehmen
// und projektId zurücksetzen, da die Projekte
// kundenabhängig sind.
currentLog.kundenId = kundenId
currentLog.projektId = -1
}
} as iSelect)),
m("label", "Projekt", m("label", "Projekt",
m(Select, { m("input.w3-input.w3-border", {
name: "projektId", type: "text",
value: currentLog.projektId, value: currentLog.projekt_name,
options: projektOptionen, readonly: true,
readonly: kundeVoreingestellt, style: {'margin-bottom': '5px'}
required: true, })),
onchanged: (projektId: number) => {
currentLog.projektId = projektId
// Bei Projektwechsel Ansprechpartner und Telefon
// zurücksetzen, falls nicht voreingestellt.
if (!ansprechpartnerVoreingestellt) {
currentLog.nameAnsprechpartner = ""
currentLog.telefon = ""
}
}
} 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"} as iInput)),
m("label", {style: {flex: '1'}}, "Uhrzeit Beginn", m("label", {style: {flex: '1'}}, "Uhrzeit Beginn",
m(Input, {name: "uhrzeitBeginn", value: currentLog.uhrzeitBeginn, type: "time"} as iInput)), m(Input, {name: "uhrzeit_beginn", value: currentLog.uhrzeit_beginn, type: "time"} as iInput)),
m("label", {style: {flex: '1'}}, "Dauer (mm:ss)", m("label", {style: {flex: '1'}}, "Dauer (mm:ss)",
// Raw input statt Input-Widget, damit oncreate die
// DOM-Referenz für den Timer greifen kann. Text-Typ,
// da das Format mm:ss keine Zahl ist.
m("input.w3-input.w3-border", { m("input.w3-input.w3-border", {
name: "dauer", name: "dauer",
type: "text", type: "text",
@@ -328,22 +332,18 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
), ),
m("label", "Betreff", m(Input, {name: "betreff", value: currentLog.betreff, type: "text"} as iInput)), m("label", "Betreff", m(Input, {name: "betreff", value: currentLog.betreff, type: "text"} as iInput)),
// Ansprechpartner und Telefon in einer Zeile. // Ansprechpartner und Telefon in einer Zeile.
// Wenn der Ansprechpartner voreingestellt ist (aus Kontaktadressenliste),
// wird ein readonly Input gezeigt; sonst ein Select, das die
// Kontaktadressen des gewählten Projekts anbietet. Bei Auswahl
// werden Name und Telefon automatisch übernommen.
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}}, m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
m("label", {style: {flex: '1'}}, "Name Ansprechpartner", m("label", {style: {flex: '1'}}, "Name Ansprechpartner",
ansprechpartnerVoreingestellt ansprechpartnerVoreingestellt
? m(Input, {name: "nameAnsprechpartner", value: currentLog.nameAnsprechpartner, type: "text", readonly: true} as iInput) ? m(Input, {name: "name_ansprechpartner", value: currentLog.name_ansprechpartner, type: "text", readonly: true} as iInput)
: m(Select, { : m(Select, {
name: "kontaktadresseId", name: "kontaktadresse_id",
value: -1, value: -1,
options: kontaktadressenOptionen, options: kontaktadressenOptionen,
onchanged: (kontaktadresseId: number) => { onchanged: (kontaktadresseId: number) => {
const ka = getKontaktadresseById(kontaktadresseId) const ka = kontaktadressenListe.find((k: iKontaktadresse) => k.id === kontaktadresseId)
if (ka) { if (ka) {
currentLog.nameAnsprechpartner = ka.name currentLog.name_ansprechpartner = ka.name
currentLog.telefon = ka.telefon currentLog.telefon = ka.telefon
} }
} }
@@ -352,7 +352,7 @@ 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: "wiedervorlageDatum", value: currentLog.wiedervorlageDatum, type: "date"} as iInput)), m("label", "Wiedervorlage Datum", m(Input, {name: "wiedervorlage_datum", value: currentLog.wiedervorlage_datum, type: "date"} as iInput)),
m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt") m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt")
]) ])
) )
+59 -92
View File
@@ -1,62 +1,35 @@
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 {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {iKontakt, getKontakteByKundenId, getNewKontaktId, addKontakt, updateKontakt} from "../models/kontakt" import {iKontakt, fetchKontakte, fetchKontaktById, createKontaktREST, updateKontaktREST} from "../models/kontakt"
import {iKunde, getKunden} from "../models/kunde" import {iKunde, fetchKundeById} from "../models/kunde"
function createKontaktTreenodes(kundenId: number | null = null): iTreenode[] {
let filteredKontakte = getKontakteByKundenId(kundenId)
return filteredKontakte.map((kontakt: iKontakt) => {
return new Treenode(kontakt, true, [])
})
}
export const kontaktliste: m.ClosureComponent = (vnode) => { export const kontaktliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = createKontaktTreenodes() let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null let selectedNode: iTreenode | null = null
let filterKundenId: number | null = null let filterKundenId: number | null = null
// Kundenname für die Überschrift, asynchron per fetchKundeById
// aufgelöst, falls nicht in den Route-Params enthalten.
let kundenName: string = ""
const handleRowMarked = (node: iTreenode) => { const handleRowMarked = (node: iTreenode) => {
selectedNode = node selectedNode = node
} }
const addKontaktRoute = () => { const addKontaktRoute = () => {
const params: any = { const params: any = {id: -1}
id: -1,
anrede: "",
name: "",
position: "",
telefon: "",
email: "",
notiz: ""
}
// Nur bei kundengefilterter Liste den Kunden vorbelegen. // Nur bei kundengefilterter Liste den Kunden vorbelegen.
if (filterKundenId !== null) { if (filterKundenId !== null) {
const kunde = getKunden().find((k: iKunde) => k.id === filterKundenId) params.kunden_id = filterKundenId
if (kunde) { params.kunden_name = kundenName
params.kundenId = kunde.id
params.kundenname = kunde.name
}
} }
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", { m.route.set("/kontakt", {id: kontakt.id})
id: kontakt.id,
kundenId: kontakt.kundenId,
kundenname: kontakt.kundenName,
anrede: kontakt.anrede,
name: kontakt.name,
position: kontakt.position,
telefon: kontakt.telefon,
email: kontakt.email,
notiz: kontakt.notiz
})
} }
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
@@ -67,7 +40,7 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
m("span", kontakt.position), m("span", kontakt.position),
m("span", kontakt.telefon), m("span", kontakt.telefon),
m("span", kontakt.email), m("span", kontakt.email),
m("span", kontakt.kundenName), m("span", kontakt.kunden_name),
m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editKontakt(kontakt, e)}, m("i.material-icons", "edit")) m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editKontakt(kontakt, e)}, m("i.material-icons", "edit"))
] ]
} }
@@ -75,22 +48,34 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
const filterFunction = (node: iTreenode, searchvalue: string): boolean => { const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let kontakt: iKontakt = node.nodevalue let kontakt: iKontakt = node.nodevalue
let searchLower = searchvalue.toLowerCase() let searchLower = searchvalue.toLowerCase()
return kontakt.name.toLowerCase().includes(searchLower) || return (kontakt.name || "").toLowerCase().includes(searchLower) ||
kontakt.position.toLowerCase().includes(searchLower) || (kontakt.position || "").toLowerCase().includes(searchLower) ||
kontakt.telefon.toLowerCase().includes(searchLower) || (kontakt.telefon || "").toLowerCase().includes(searchLower) ||
kontakt.email.toLowerCase().includes(searchLower) || (kontakt.email || "").toLowerCase().includes(searchLower) ||
kontakt.kundenName.toLowerCase().includes(searchLower) (kontakt.kunden_name || "").toLowerCase().includes(searchLower)
} }
return { return {
oninit: (vnode) => { oninit: (vnode) => {
const routeParams = m.route.param() const routeParams = m.route.param()
if (routeParams && routeParams.kundenId) { if (routeParams && routeParams.kunden_id) {
filterKundenId = parseInt(routeParams.kundenId) filterKundenId = parseInt(routeParams.kunden_id)
kundenName = routeParams.kunden_name || ""
} else { } else {
filterKundenId = null filterKundenId = null
kundenName = ""
}
// Kontakte asynchron vom Server laden. Die Filterung nach
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
fetchKontakte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
treenodes = result.map((kontakt: iKontakt) => new Treenode(kontakt, true, []))
})
// Kundennamen asynchron auflösen, falls nicht in Route-Params.
if (filterKundenId !== null && kundenName === "") {
fetchKundeById(filterKundenId).then((kunde: iKunde) => {
kundenName = kunde.name
})
} }
treenodes = createKontaktTreenodes(filterKundenId)
}, },
view: (vnode) => { view: (vnode) => {
return m("div.w3-container.w3-auto", { return m("div.w3-container.w3-auto", {
@@ -99,7 +84,7 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
'flex-direction': 'column', 'flex-direction': 'column',
height: 'inherit', height: 'inherit',
'overflow-y': 'inherit'}}, 'overflow-y': 'inherit'}},
m("h1", "Kontaktliste"), m("h1", kundenName ? "Kontaktliste für " + kundenName : "Kontaktliste"),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable, m(Treetable,
{ {
@@ -120,12 +105,12 @@ export const kontaktliste: m.ClosureComponent = (vnode) => {
} }
export const Kontakt: m.ClosureComponent = (vnode) => { export const Kontakt: m.ClosureComponent = (vnode) => {
// currentKontakt hält den Formularzustand. kundenId referenziert den // currentKontakt hält den Formularzustand. kunden_id referenziert den
// Kunden eindeutig, kundenname dient der Anzeige. // Kunden eindeutig, kunden_name dient der Anzeige in der Überschrift.
let currentKontakt: { let currentKontakt: {
id: number id: number
kundenId: number kunden_id: number
kundenname: string kunden_name: string
anrede: string anrede: string
name: string name: string
position: string position: string
@@ -134,8 +119,8 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
notiz: string notiz: string
} = { } = {
id: -1, id: -1,
kundenId: -1, kunden_id: -1,
kundenname: "", kunden_name: "",
anrede: "", anrede: "",
name: "", name: "",
position: "", position: "",
@@ -143,38 +128,31 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
email: "", email: "",
notiz: "" notiz: ""
} }
// Ist der Kunde voreingestellt (gefilterte Kontaktliste)?
let kundeVoreingestellt: boolean = false
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
const kundenId = routeParams.kundenId ? parseInt(routeParams.kundenId) : -1 if (id !== -1) {
kundeVoreingestellt = kundenId !== -1 // Kontaktdaten frisch vom Server holen, um zwischenzeitliche
currentKontakt = { // Änderungen anderer Nutzer nicht zu überschreiben.
id: routeParams.id ? parseInt(routeParams.id) : -1, fetchKontaktById(id).then((result) => {
kundenId: kundenId, currentKontakt = result
kundenname: routeParams.kundenname || "", })
anrede: routeParams.anrede || "", } else {
name: routeParams.name || "", // Neuanlegen: kunden_id und kunden_name aus Route-Params
position: routeParams.position || "", // (voreingestellt aus der Kontaktliste).
telefon: routeParams.telefon || "", currentKontakt.kunden_id = routeParams && routeParams.kunden_id ? parseInt(routeParams.kunden_id) : -1
email: routeParams.email || "", currentKontakt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
notiz: routeParams.notiz || ""
}
} }
}, },
view: (vnode) => { view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => { const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
const kundenId = parseInt(formData.get("kundenId") as string)
// Kundennamen zur gewählten kundenId auflösen.
const kunde = getKunden().find((k: iKunde) => k.id === kundenId)
const gespeicherterKontakt: iKontakt = { const gespeicherterKontakt: iKontakt = {
id: currentKontakt.id, id: currentKontakt.id,
kundenId: kundenId, kunden_id: currentKontakt.kunden_id,
kundenName: kunde ? kunde.name : "", 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) || "",
position: (formData.get("position") as string) || "", position: (formData.get("position") as string) || "",
@@ -183,33 +161,22 @@ 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) {
gespeicherterKontakt.id = getNewKontaktId() createKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name}))
addKontakt(gespeicherterKontakt)
} else { } else {
updateKontakt(gespeicherterKontakt) updateKontaktREST(gespeicherterKontakt).then(() => m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name}))
} }
m.route.set("/kontaktliste")
return true return true
} }
const cancel = (e: Event) => { const cancel = (e: Event) => {
m.route.set("/kontaktliste") m.route.set("/kontaktliste", {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name})
} }
// Select-Optionen aus der Kundenliste aufbauen.
const kundenOptionen: iSelectOption[] = getKunden().map((k: iKunde) => {
return {value: k.id, label: k.name}
})
return m("div.w3-container.w3-auto.form-view-container", return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentKontakt.id === -1 ? "Neuen Kontakt anlegen" : "Kontakt-Stammblatt"), m("h1", currentKontakt.id === -1
? "Neuer Kontakt für " + currentKontakt.kunden_name
: "Kontakt-Stammblatt"),
m("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, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m("label", "Kunde", m("input", {type: "hidden", name: "kunden_id", value: currentKontakt.kunden_id}),
m(Select, {
name: "kundenId",
value: currentKontakt.kundenId,
options: kundenOptionen,
readonly: kundeVoreingestellt,
required: true
} as iSelect)),
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)),
m("label", "Position", m(Input, {name: "position", value: currentKontakt.position, type: "text"} as iInput)), m("label", "Position", m(Input, {name: "position", value: currentKontakt.position, type: "text"} as iInput)),
+69 -115
View File
@@ -1,70 +1,34 @@
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 {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2"
import {iKontaktadresse, getKontaktadressenByProjektId, getNewKontaktadresseId, addKontaktadresse, updateKontaktadresse} from "../models/kontaktadresse" import {iKontaktadresse, fetchKontaktadressen, fetchKontaktadresseById, createKontaktadresseREST, updateKontaktadresseREST} from "../models/kontaktadresse"
import {iProjekt, getProjekte, getProjektById} from "../models/projekt" import {iProjekt, fetchProjektById} from "../models/projekt"
function createKontaktadresseTreenodes(projektId: number | null = null): iTreenode[] {
let filteredKontaktadressen = getKontaktadressenByProjektId(projektId)
return filteredKontaktadressen.map((kontaktadresse: iKontaktadresse) => {
return new Treenode(kontaktadresse, true, [])
})
}
export const kontaktadresseliste: m.ClosureComponent = (vnode) => { export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = createKontaktadresseTreenodes() let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null let selectedNode: iTreenode | null = null
let filterProjektId: number | null = null let filterProjektId: number | null = null
// Projektname für die Überschrift, asynchron per fetchProjektById
// aufgelöst, falls nicht in den Route-Params enthalten.
let projektName: string = ""
const handleRowMarked = (node: iTreenode) => { const handleRowMarked = (node: iTreenode) => {
selectedNode = node selectedNode = node
} }
const addKontaktadresseRoute = () => { const addKontaktadresseRoute = () => {
const params: any = { const params: any = {id: -1}
id: -1,
anrede: "",
name: "",
firma: "",
telefon: "",
email: "",
strasse: "",
plz: "",
ort: "",
land: "",
notiz: ""
}
// Nur bei projektgefilterter Liste das Projekt vorbelegen.
if (filterProjektId !== null) { if (filterProjektId !== null) {
const projekt = getProjekte().find((p: iProjekt) => p.id === filterProjektId) params.projekt_id = filterProjektId
if (projekt) { params.projekt_name = projektName
params.projektId = projekt.id
params.projektname = projekt.projektname
}
} }
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", { m.route.set("/kontaktadresse", {id: kontaktadresse.id})
id: kontaktadresse.id,
projektId: kontaktadresse.projektId,
projektname: kontaktadresse.projektName,
anrede: kontaktadresse.anrede,
name: kontaktadresse.name,
firma: kontaktadresse.firma,
telefon: kontaktadresse.telefon,
email: kontaktadresse.email,
strasse: kontaktadresse.strasse,
plz: kontaktadresse.plz,
ort: kontaktadresse.ort,
land: kontaktadresse.land,
notiz: kontaktadresse.notiz
})
} }
const addCallLog = (kontaktadresse: iKontaktadresse, e: Event) => { const addCallLog = (kontaktadresse: iKontaktadresse, e: Event) => {
@@ -72,21 +36,18 @@ 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]
// Kundennamen über das Projekt auflösen.
const projekt = getProjektById(kontaktadresse.projektId)
m.route.set("/call_log", { m.route.set("/call_log", {
id: -1, id: -1,
kundenName: projekt ? projekt.kundenName : "", projekt_id: kontaktadresse.projekt_id,
projektId: kontaktadresse.projektId, projekt_name: kontaktadresse.projekt_name,
projektName: kontaktadresse.projektName,
datum: datum, datum: datum,
uhrzeitBeginn: uhrzeit, uhrzeit_beginn: uhrzeit,
dauer: "", dauer: "",
betreff: "", betreff: "",
nameAnsprechpartner: kontaktadresse.name, name_ansprechpartner: kontaktadresse.name,
telefon: kontaktadresse.telefon, telefon: kontaktadresse.telefon,
notiz: "", notiz: "",
wiedervorlageDatum: "", wiedervorlage_datum: "",
erledigt: false erledigt: false
}) })
} }
@@ -97,10 +58,10 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
return [ return [
m("div", m("strong", kontaktadresse.name)), m("div", m("strong", kontaktadresse.name)),
m("span", kontaktadresse.firma), m("span", kontaktadresse.firma),
m("span", kontaktadresse.position),
m("span", kontaktadresse.telefon), m("span", kontaktadresse.telefon),
m("span", kontaktadresse.email), m("span", kontaktadresse.email),
m("span", kontaktadresse.ort), m("span", kontaktadresse.ort),
m("span", kontaktadresse.projektName),
m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap'}}, m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap'}},
m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editKontaktadresse(kontaktadresse, e)}, m("i.material-icons", "edit")), m("button.w3-button.w3-small", {style: {padding: '0 2px'}, onclick: (e: Event) => editKontaktadresse(kontaktadresse, e)}, m("i.material-icons", "edit")),
m("button.w3-button.w3-small", {title: "Gesprächsnotiz hinzufügen", style: {padding: '0 2px'}, onclick: (e: Event) => addCallLog(kontaktadresse, e)}, m("i.material-icons", "add_call")) m("button.w3-button.w3-small", {title: "Gesprächsnotiz hinzufügen", style: {padding: '0 2px'}, onclick: (e: Event) => addCallLog(kontaktadresse, e)}, m("i.material-icons", "add_call"))
@@ -111,23 +72,37 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
const filterFunction = (node: iTreenode, searchvalue: string): boolean => { const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let kontaktadresse: iKontaktadresse = node.nodevalue let kontaktadresse: iKontaktadresse = node.nodevalue
let searchLower = searchvalue.toLowerCase() let searchLower = searchvalue.toLowerCase()
return kontaktadresse.name.toLowerCase().includes(searchLower) || return (kontaktadresse.name || "").toLowerCase().includes(searchLower) ||
kontaktadresse.firma.toLowerCase().includes(searchLower) || (kontaktadresse.firma || "").toLowerCase().includes(searchLower) ||
kontaktadresse.telefon.toLowerCase().includes(searchLower) || (kontaktadresse.position || "").toLowerCase().includes(searchLower) ||
kontaktadresse.email.toLowerCase().includes(searchLower) || (kontaktadresse.telefon || "").toLowerCase().includes(searchLower) ||
kontaktadresse.ort.toLowerCase().includes(searchLower) || (kontaktadresse.email || "").toLowerCase().includes(searchLower) ||
kontaktadresse.projektName.toLowerCase().includes(searchLower) (kontaktadresse.ort || "").toLowerCase().includes(searchLower) ||
(kontaktadresse.projekt_name || "").toLowerCase().includes(searchLower)
} }
return { return {
oninit: (vnode) => { oninit: (vnode) => {
const routeParams = m.route.param() const routeParams = m.route.param()
if (routeParams && routeParams.projektId) { if (routeParams && routeParams.projekt_id) {
filterProjektId = parseInt(routeParams.projektId) filterProjektId = parseInt(routeParams.projekt_id)
projektName = routeParams.projekt_name || ""
} else { } else {
filterProjektId = null filterProjektId = null
projektName = ""
}
// Kontaktadressen asynchron vom Server laden. Die Filterung
// nach projekt_id erfolgt serverseitig, sofern ein Filter
// gesetzt ist.
fetchKontaktadressen(filterProjektId !== null ? filterProjektId : undefined).then((result) => {
treenodes = result.map((kontaktadresse: iKontaktadresse) => new Treenode(kontaktadresse, true, []))
})
// Projektnamen asynchron auflösen, falls nicht in Route-Params.
if (filterProjektId !== null && projektName === "") {
fetchProjektById(filterProjektId).then((projekt: iProjekt) => {
projektName = projekt.projektname
})
} }
treenodes = createKontaktadresseTreenodes(filterProjektId)
}, },
view: (vnode) => { view: (vnode) => {
return m("div.w3-container.w3-auto", { return m("div.w3-container.w3-auto", {
@@ -136,7 +111,7 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
'flex-direction': 'column', 'flex-direction': 'column',
height: 'inherit', height: 'inherit',
'overflow-y': 'inherit'}}, 'overflow-y': 'inherit'}},
m("h1", "Kontaktadressen"), m("h1", projektName ? "Kontaktadressen für " + projektName : "Kontaktadressen"),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable, m(Treetable,
{ {
@@ -157,15 +132,16 @@ export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
} }
export const Kontaktadresse: m.ClosureComponent = (vnode) => { export const Kontaktadresse: m.ClosureComponent = (vnode) => {
// currentKontaktadresse hält den Formularzustand. projektId referenziert das // currentKontaktadresse hält den Formularzustand. projekt_id referenziert
// Projekt eindeutig, projektname dient der Anzeige. // das Projekt eindeutig, projekt_name dient der Anzeige in der Überschrift.
let currentKontaktadresse: { let currentKontaktadresse: {
id: number id: number
projektId: number projekt_id: number
projektname: string projekt_name: string
anrede: string anrede: string
name: string name: string
firma: string firma: string
position: string
telefon: string telefon: string
email: string email: string
strasse: string strasse: string
@@ -175,11 +151,12 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
notiz: string notiz: string
} = { } = {
id: -1, id: -1,
projektId: -1, projekt_id: -1,
projektname: "", projekt_name: "",
anrede: "", anrede: "",
name: "", name: "",
firma: "", firma: "",
position: "",
telefon: "", telefon: "",
email: "", email: "",
strasse: "", strasse: "",
@@ -188,45 +165,33 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
land: "", land: "",
notiz: "" notiz: ""
} }
// Ist das Projekt voreingestellt (gefilterte Kontaktadressenliste)?
let projektVoreingestellt: boolean = false
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
const projektId = routeParams.projektId ? parseInt(routeParams.projektId) : -1 if (id !== -1) {
projektVoreingestellt = projektId !== -1 // Kontaktadresse frisch vom Server holen.
currentKontaktadresse = { fetchKontaktadresseById(id).then((result) => {
id: routeParams.id ? parseInt(routeParams.id) : -1, currentKontaktadresse = result
projektId: projektId, })
projektname: routeParams.projektname || "", } else {
anrede: routeParams.anrede || "", // Neuanlegen: projekt_id und projekt_name aus Route-Params.
name: routeParams.name || "", currentKontaktadresse.projekt_id = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) : -1
firma: routeParams.firma || "", currentKontaktadresse.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
telefon: routeParams.telefon || "",
email: routeParams.email || "",
strasse: routeParams.strasse || "",
plz: routeParams.plz || "",
ort: routeParams.ort || "",
land: routeParams.land || "",
notiz: routeParams.notiz || ""
}
} }
}, },
view: (vnode) => { view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => { const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
const projektId = parseInt(formData.get("projektId") as string)
// Projektnamen zur gewählten projektId auflösen.
const projekt = getProjekte().find((p: iProjekt) => p.id === projektId)
const gespeicherteKontaktadresse: iKontaktadresse = { const gespeicherteKontaktadresse: iKontaktadresse = {
id: currentKontaktadresse.id, id: currentKontaktadresse.id,
projektId: projektId, projekt_id: currentKontaktadresse.projekt_id,
projektName: projekt ? projekt.projektname : "", projekt_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) || "",
position: (formData.get("position") as string) || "",
telefon: (formData.get("telefon") as string) || "", telefon: (formData.get("telefon") as string) || "",
email: (formData.get("email") as string) || "", email: (formData.get("email") as string) || "",
strasse: (formData.get("strasse") as string) || "", strasse: (formData.get("strasse") as string) || "",
@@ -236,40 +201,29 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
notiz: (formData.get("notiz") as string) || "" notiz: (formData.get("notiz") as string) || ""
} }
if (currentKontaktadresse.id === -1) { if (currentKontaktadresse.id === -1) {
gespeicherteKontaktadresse.id = getNewKontaktadresseId() createKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name}))
addKontaktadresse(gespeicherteKontaktadresse)
} else { } else {
updateKontaktadresse(gespeicherteKontaktadresse) updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name}))
} }
m.route.set("/kontaktadresseliste")
return true return true
} }
const cancel = (e: Event) => { const cancel = (e: Event) => {
m.route.set("/kontaktadresseliste") m.route.set("/kontaktadresseliste", {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name})
} }
// Select-Optionen aus der Projektliste aufbauen.
const projektOptionen: iSelectOption[] = getProjekte().map((p: iProjekt) => {
return {value: p.id, label: p.projektname}
})
return m("div.w3-container.w3-auto.form-view-container", return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentKontaktadresse.id === -1 ? "Neue Kontaktadresse anlegen" : "Kontaktadresse-Stammblatt"), m("h1", currentKontaktadresse.id === -1
? "Neue Kontaktadresse für " + currentKontaktadresse.projekt_name
: "Kontaktadresse-Stammblatt"),
m("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, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m("label", "Projekt", m("input", {type: "hidden", name: "projekt_id", value: currentKontaktadresse.projekt_id}),
m(Select, {
name: "projektId",
value: currentKontaktadresse.projektId,
options: projektOptionen,
readonly: projektVoreingestellt,
required: true
} as iSelect)),
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)),
m("label", "Firma", m(Input, {name: "firma", value: currentKontaktadresse.firma, type: "text"} as iInput)), m("label", "Firma", m(Input, {name: "firma", value: currentKontaktadresse.firma, type: "text"} as iInput)),
m("label", "Position", m(Input, {name: "position", value: currentKontaktadresse.position, type: "text"} as iInput)),
m("label", "Telefon", m(Input, {name: "telefon", value: currentKontaktadresse.telefon, type: "tel"} as iInput)), m("label", "Telefon", m(Input, {name: "telefon", value: currentKontaktadresse.telefon, type: "tel"} as iInput)),
m("label", "E-Mail", m(Input, {name: "email", value: currentKontaktadresse.email, type: "email"} as iInput)), m("label", "E-Mail", m(Input, {name: "email", value: currentKontaktadresse.email, type: "email"} as iInput)),
m("label", "Straße", m(Input, {name: "strasse", value: currentKontaktadresse.strasse, type: "text"} as iInput)), m("label", "Straße", m(Input, {name: "strasse", value: currentKontaktadresse.strasse, type: "text"} as iInput)),
// PLZ und Ort in einer Zeile.
m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}}, m("div", {style: {display: 'flex', gap: '8px', 'margin-bottom': '5px'}},
m("label", {style: {flex: '0 0 120px'}}, "PLZ", m("label", {style: {flex: '0 0 120px'}}, "PLZ",
m(Input, {name: "plz", value: currentKontaktadresse.plz, type: "text"} as iInput)), m(Input, {name: "plz", value: currentKontaktadresse.plz, type: "text"} as iInput)),
+29 -38
View File
@@ -2,7 +2,7 @@ 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 {iKunde, kundenDaten, getNewKundeId, addKunde, updateKunde} from "../models/kunde" import {iKunde, fetchKunden, fetchKundeById, createKundeREST, updateKundeREST} from "../models/kunde"
export const kunde: m.ClosureComponent = (vnode) => { export const kunde: m.ClosureComponent = (vnode) => {
let currentKunde: iKunde = { let currentKunde: iKunde = {
@@ -17,15 +17,13 @@ export const kunde: m.ClosureComponent = (vnode) => {
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
currentKunde = { if (id !== -1) {
id: routeParams.id ? parseInt(routeParams.id) : -1, // Kundendaten frisch vom Server holen, um zwischenzeitliche
name: routeParams.name || "", // Änderungen anderer Nutzer nicht zu überschreiben.
strasse: routeParams.strasse || "", fetchKundeById(id).then((result) => {
plz: routeParams.plz || "", currentKunde = result
ort: routeParams.ort || "", })
land: routeParams.land || ""
}
} }
}, },
view: (vnode) => { view: (vnode) => {
@@ -40,12 +38,10 @@ 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) {
gespeicherterKunde.id = getNewKundeId() createKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste"))
addKunde(gespeicherterKunde)
} else { } else {
updateKunde(gespeicherterKunde) updateKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste"))
} }
m.route.set("/kundenliste")
return true return true
} }
const cancel = (e: Event) => { const cancel = (e: Event) => {
@@ -56,7 +52,7 @@ export const kunde: m.ClosureComponent = (vnode) => {
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", {kundenId: currentKunde.id})}, {onclick: () => m.route.set("/kontaktliste", {kunden_id: currentKunde.id})},
m("i.material-icons", "contact_page"), " Kontakte") m("i.material-icons", "contact_page"), " Kontakte")
: null : null
), ),
@@ -74,43 +70,31 @@ export const kunde: m.ClosureComponent = (vnode) => {
} }
} }
/**
* Erzeugt aus der Kundendatenliste die Treenode-Struktur für die Treetable der
* Kundenliste. Jeder Kunde wird zu einem expandierten Treenode ohne Kinder
* (expand=true, children=[]) - d.h. ein Blattknoten, der sofort in der
* Tabelle sichtbar ist und sich nicht weiter aufklappen lässt.
*/
function createKundenTreenodes(): iTreenode[] {
return kundenDaten.map((kunde: iKunde) => {
return new Treenode(kunde, true, [])
})
}
export const kundenliste: m.ClosureComponent = (vnode) => { export const kundenliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = createKundenTreenodes() let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null let selectedNode: iTreenode | null = null
const handleRowMarked = (node: iTreenode) => { const handleRowMarked = (node: iTreenode) => {
selectedNode = node selectedNode = node
} }
const editKunde = (kunde: iKunde, e: Event) => { const editKunde = (kunde: iKunde, e: Event) => {
e.stopPropagation() e.stopPropagation()
m.route.set("/kunde", {id: kunde.id, name: kunde.name, strasse: kunde.strasse, plz: kunde.plz, ort: kunde.ort, land: kunde.land}) m.route.set("/kunde", {id: kunde.id})
} }
const showProjekte = (kunde: iKunde, e: Event) => { const showProjekte = (kunde: iKunde, e: Event) => {
e.stopPropagation() e.stopPropagation()
m.route.set("/projektliste", {kundenId: kunde.id}) m.route.set("/projektliste", {kunden_id: kunde.id})
} }
const showKontakte = (kunde: iKunde, e: Event) => { const showKontakte = (kunde: iKunde, e: Event) => {
e.stopPropagation() e.stopPropagation()
m.route.set("/kontaktliste", {kundenId: kunde.id}) m.route.set("/kontaktliste", {kunden_id: kunde.id})
} }
const addKundeRoute = () => { const addKundeRoute = () => {
m.route.set("/kunde", {id: -1, name: "", strasse: "", plz: "", ort: "", land: ""}) m.route.set("/kunde", {id: -1})
} }
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
@@ -133,14 +117,21 @@ export const kundenliste: m.ClosureComponent = (vnode) => {
const filterFunction = (node: iTreenode, searchvalue: string): boolean => { const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let kunde: iKunde = node.nodevalue let kunde: iKunde = node.nodevalue
let searchLower = searchvalue.toLowerCase() let searchLower = searchvalue.toLowerCase()
return kunde.name.toLowerCase().includes(searchLower) || return (kunde.name || "").toLowerCase().includes(searchLower) ||
kunde.strasse.toLowerCase().includes(searchLower) || (kunde.strasse || "").toLowerCase().includes(searchLower) ||
kunde.plz.includes(searchLower) || (kunde.plz || "").includes(searchLower) ||
kunde.ort.toLowerCase().includes(searchLower) || (kunde.ort || "").toLowerCase().includes(searchLower) ||
kunde.land.toLowerCase().includes(searchLower) (kunde.land || "").toLowerCase().includes(searchLower)
} }
return { return {
oninit: (vnode) => {
// Kundenliste asynchron vom Server laden. m.request löst nach
// Promise-Auflösung automatisch ein Redraw aus.
fetchKunden().then((kunden: iKunde[]) => {
treenodes = kunden.map((kunde: iKunde) => new Treenode(kunde, true, []))
})
},
view: (vnode) => { view: (vnode) => {
return m("div.w3-container.w3-auto", { return m("div.w3-container.w3-auto", {
style: { style: {
+78 -106
View File
@@ -1,71 +1,47 @@
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 {Select, iSelect, 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 {iProjekt, getProjekteByKundenId, getNewProjektId, addProjekt, updateProjekt} from "../models/projekt" import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt"
import {iKunde, getKunden} from "../models/kunde" import {iKunde, fetchKundeById} from "../models/kunde"
function createProjektTreenodes(kundenId: number | null = null): iTreenode[] {
let filteredProjekte = getProjekteByKundenId(kundenId)
return filteredProjekte.map((projekt: iProjekt) => {
return new Treenode(projekt, true, [])
})
}
export const projektliste: m.ClosureComponent = (vnode) => { export const projektliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = createProjektTreenodes() let treenodes: iTreenode[] = []
let selectedNode: iTreenode | null = null let selectedNode: iTreenode | null = null
let filterKundenId: number | null = null let filterKundenId: number | null = null
// Kundenname für die Überschrift, asynchron per fetchKundeById
// aufgelöst, falls nicht in den Route-Params enthalten.
let kundenName: string = ""
const handleRowMarked = (node: iTreenode) => { const handleRowMarked = (node: iTreenode) => {
selectedNode = node selectedNode = node
} }
const addProjektRoute = () => { const addProjektRoute = () => {
const params: any = { const params: any = {id: -1}
id: -1,
kundenname: "",
projektname: "",
zweck: "",
verantwortlicher: "",
startdatum: ""
}
// Nur bei kundengefilterter Liste den Kunden vorbelegen. // Nur bei kundengefilterter Liste den Kunden vorbelegen.
if (filterKundenId !== null) { if (filterKundenId !== null) {
const kunde = getKunden().find((k: iKunde) => k.id === filterKundenId) params.kunden_id = filterKundenId
if (kunde) { params.kunden_name = kundenName
params.kundenId = kunde.id
params.kundenname = kunde.name
}
} }
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", { m.route.set("/projekt", {id: projekt.id})
id: projekt.id,
kundenId: projekt.kundenId,
kundenname: projekt.kundenName,
projektname: projekt.projektname,
zweck: projekt.zweck,
verantwortlicher: projekt.verantwortlicher,
startdatum: projekt.startdatum
})
} }
const showCallLogs = (projekt: iProjekt, e: Event) => { const showCallLogs = (projekt: iProjekt, e: Event) => {
e.stopPropagation() e.stopPropagation()
m.route.set("/calllogliste", {projektId: projekt.id}) m.route.set("/calllogliste", {projekt_id: projekt.id})
} }
const showKontaktadressen = (projekt: iProjekt, e: Event) => { const showKontaktadressen = (projekt: iProjekt, e: Event) => {
e.stopPropagation() e.stopPropagation()
m.route.set("/kontaktadresseliste", {projektId: projekt.id}) m.route.set("/kontaktadresseliste", {projekt_id: projekt.id})
} }
const addCallLog = (projekt: iProjekt, e: Event) => { const addCallLog = (projekt: iProjekt, e: Event) => {
e.stopPropagation() e.stopPropagation()
const now = new Date() const now = new Date()
@@ -73,26 +49,26 @@ export const projektliste: m.ClosureComponent = (vnode) => {
const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1] const uhrzeit = now.toTimeString().split(':')[0] + ':' + now.toTimeString().split(':')[1]
m.route.set("/call_log", { m.route.set("/call_log", {
id: -1, id: -1,
kundenName: projekt.kundenName, kunden_name: projekt.kunden_name,
projektId: projekt.id, projekt_id: projekt.id,
projektName: projekt.projektname, projekt_name: projekt.projektname,
datum: datum, datum: datum,
uhrzeitBeginn: uhrzeit, uhrzeit_beginn: uhrzeit,
dauer: "", dauer: "",
betreff: "", betreff: "",
nameAnsprechpartner: "", name_ansprechpartner: "",
telefon: "", telefon: "",
notiz: "", notiz: "",
wiedervorlageDatum: "", wiedervorlage_datum: "",
erledigt: false erledigt: false
}) })
} }
const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => {
let projekt: iProjekt = node.nodevalue let projekt: iProjekt = node.nodevalue
return [ return [
m("div", m("strong", projekt.kundenName)), m("div", m("strong", projekt.kunden_name)),
m("span", projekt.projektname), m("span", projekt.projektname),
m("span", projekt.zweck), m("span", projekt.zweck),
m("span", projekt.verantwortlicher), m("span", projekt.verantwortlicher),
@@ -105,35 +81,47 @@ export const projektliste: m.ClosureComponent = (vnode) => {
) )
] ]
} }
const filterFunction = (node: iTreenode, searchvalue: string): boolean => { const filterFunction = (node: iTreenode, searchvalue: string): boolean => {
let projekt: iProjekt = node.nodevalue let projekt: iProjekt = node.nodevalue
let searchLower = searchvalue.toLowerCase() let searchLower = searchvalue.toLowerCase()
return projekt.kundenName.toLowerCase().includes(searchLower) || return (projekt.kunden_name || "").toLowerCase().includes(searchLower) ||
projekt.projektname.toLowerCase().includes(searchLower) || (projekt.projektname || "").toLowerCase().includes(searchLower) ||
projekt.zweck.toLowerCase().includes(searchLower) || (projekt.zweck || "").toLowerCase().includes(searchLower) ||
projekt.verantwortlicher.toLowerCase().includes(searchLower) || (projekt.verantwortlicher || "").toLowerCase().includes(searchLower) ||
projekt.startdatum.includes(searchLower) (projekt.startdatum || "").includes(searchLower)
} }
return { return {
oninit: (vnode) => { oninit: (vnode) => {
const routeParams = m.route.param() const routeParams = m.route.param()
if (routeParams && routeParams.kundenId) { if (routeParams && routeParams.kunden_id) {
filterKundenId = parseInt(routeParams.kundenId) filterKundenId = parseInt(routeParams.kunden_id)
kundenName = routeParams.kunden_name || ""
} else { } else {
filterKundenId = null filterKundenId = null
kundenName = ""
}
// Projekte asynchron vom Server laden. Die Filterung nach
// kunden_id erfolgt serverseitig, sofern ein Filter gesetzt ist.
fetchProjekte(filterKundenId !== null ? filterKundenId : undefined).then((result) => {
treenodes = result.map((projekt: iProjekt) => new Treenode(projekt, true, []))
})
// Kundennamen asynchron auflösen, falls nicht in Route-Params.
if (filterKundenId !== null && kundenName === "") {
fetchKundeById(filterKundenId).then((kunde: iKunde) => {
kundenName = kunde.name
})
} }
treenodes = createProjektTreenodes(filterKundenId)
}, },
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", "Projektliste"), m("h1", kundenName ? "Projektliste für " + kundenName : "Projektliste"),
m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, m("div", {style: {flex: '1', 'overflow-y': 'inherit'}},
m(Treetable, m(Treetable,
{ {
@@ -154,95 +142,79 @@ export const projektliste: m.ClosureComponent = (vnode) => {
} }
export const Projekt: m.ClosureComponent = (vnode) => { export const Projekt: m.ClosureComponent = (vnode) => {
// currentProjekt hält den Formularzustand. kundenId referenziert den // currentProjekt hält den Formularzustand. kunden_id referenziert den
// Kunden eindeutig, kundenname dient der Anzeige. // Kunden eindeutig, kunden_name dient der Anzeige in der Überschrift.
let currentProjekt: { let currentProjekt: {
id: number id: number
kundenId: number kunden_id: number
kundenname: string kunden_name: string
projektname: string projektname: string
zweck: string zweck: string
verantwortlicher: string verantwortlicher: string
startdatum: string startdatum: string
} = { } = {
id: -1, id: -1,
kundenId: -1, kunden_id: -1,
kundenname: "", kunden_name: "",
projektname: "", projektname: "",
zweck: "", zweck: "",
verantwortlicher: "", verantwortlicher: "",
startdatum: "" startdatum: ""
} }
// Ist der Kunde voreingestellt (gefilterte Projektliste)?
let kundeVoreingestellt: boolean = false
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
const kundenId = routeParams.kundenId ? parseInt(routeParams.kundenId) : -1 if (id !== -1) {
kundeVoreingestellt = kundenId !== -1 // Projektdaten frisch vom Server holen, um zwischenzeitliche
currentProjekt = { // Änderungen anderer Nutzer nicht zu überschreiben.
id: routeParams.id ? parseInt(routeParams.id) : -1, fetchProjektById(id).then((result) => {
kundenId: kundenId, currentProjekt = result
kundenname: routeParams.kundenname || "", })
projektname: routeParams.projektname || "", } else {
zweck: routeParams.zweck || "", // Neuanlegen: kunden_id und kunden_name aus Route-Params
verantwortlicher: routeParams.verantwortlicher || "", // (voreingestellt aus der Projektliste).
startdatum: routeParams.startdatum || "" currentProjekt.kunden_id = routeParams && routeParams.kunden_id ? parseInt(routeParams.kunden_id) : -1
} currentProjekt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
} }
}, },
view: (vnode) => { view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => { const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
const kundenId = parseInt(formData.get("kundenId") as string)
// Kundennamen zur gewählten kundenId auflösen.
const kunde = getKunden().find((k: iKunde) => k.id === kundenId)
const gespeichertesProjekt: iProjekt = { const gespeichertesProjekt: iProjekt = {
id: currentProjekt.id, id: currentProjekt.id,
kundenId: kundenId, kunden_id: currentProjekt.kunden_id,
kundenName: kunde ? kunde.name : "", kunden_name: "",
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) || ""
} }
if (currentProjekt.id === -1) { if (currentProjekt.id === -1) {
gespeichertesProjekt.id = getNewProjektId() createProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name}))
addProjekt(gespeichertesProjekt)
} else { } else {
updateProjekt(gespeichertesProjekt) updateProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name}))
} }
m.route.set("/projektliste")
return true return true
} }
const cancel = (e: Event) => { const cancel = (e: Event) => {
m.route.set("/projektliste") m.route.set("/projektliste", {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name})
} }
// Select-Optionen aus der Kundenliste aufbauen.
const kundenOptionen: iSelectOption[] = getKunden().map((k: iKunde) => {
return {value: k.id, label: k.name}
})
return m("div.w3-container.w3-auto.form-view-container", 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 ? "Neues Projekt anlegen" : "Projekt-Stammblatt"), m("h1", currentProjekt.id === -1
? "Neues Projekt für " + currentProjekt.kunden_name
: "Projekt-Stammblatt"),
currentProjekt.id !== -1 currentProjekt.id !== -1
? m("button.w3-button.w3-theme", ? m("button.w3-button.w3-theme",
{onclick: () => m.route.set("/kontaktadresseliste", {projektId: currentProjekt.id})}, {onclick: () => m.route.set("/kontaktadresseliste", {projekt_id: currentProjekt.id})},
m("i.material-icons", "contact_page"), " Kontaktadressen") m("i.material-icons", "contact_page"), " Kontaktadressen")
: null : null
), ),
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, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m("label", "Kunde", m("input", {type: "hidden", name: "kunden_id", value: currentProjekt.kunden_id}),
m(Select, {
name: "kundenId",
value: currentProjekt.kundenId,
options: kundenOptionen,
readonly: kundeVoreingestellt,
required: true
} as iSelect)),
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", "Zweck", m(Input, {name: "zweck", value: currentProjekt.zweck, type: "text"} as iInput)),
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)),
+20 -67
View File
@@ -2,86 +2,31 @@ import m from "mithril"
/** /**
* 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).
* projekt_name und kunden_name sind keine ORM-Spalten, sondern werden
* serverseitig aus der Projekt-Relationship abgeleitet und im JSON ergänzt.
*/ */
export interface iCallLog { export interface iCallLog {
id: number id: number
kundenName: string projekt_id: number
projektId: number projekt_name: string
projektName: string kunden_name: string
datum: string datum: string
uhrzeitBeginn: string uhrzeit_beginn: string
dauer: string dauer: string
betreff: string betreff: string
nameAnsprechpartner: string name_ansprechpartner: string
telefon: string telefon: string
notiz: string notiz: string
wiedervorlageDatum: string wiedervorlage_datum: string
erledigt: boolean erledigt: boolean
} }
/**
* In-Memory-Datenspeicher für Gesprächsnotizen. Wird als Fallback solange
* verwendet, bis die serverseitigen REST-Endpunkte verfügbar sind.
*/
export const callLogDaten: iCallLog[] = [
{id: 1, kundenName: "Musterfirma GmbH", projektId: 1, projektName: "Website-Relaunch", datum: "2024-01-20", uhrzeitBeginn: "09:30", dauer: "45", betreff: "Anforderungen klären", nameAnsprechpartner: "Herr Schmidt", telefon: "+49 123 45678", notiz: "Kunde möchte responsive Design und neue Farbgestaltung. Budget noch offen.", wiedervorlageDatum: "2024-01-27", erledigt: false},
{id: 2, kundenName: "Musterfirma GmbH", projektId: 1, projektName: "Website-Relaunch", datum: "2024-01-25", uhrzeitBeginn: "14:00", dauer: "30", betreff: "Feedback zum Design", nameAnsprechpartner: "Frau Müller", telefon: "+49 123 45679", notiz: "Design-Entwürfe wurden positiv aufgenommen. Kleine Änderungen an der Navigation nötig.", wiedervorlageDatum: "2024-02-01", erledigt: false},
{id: 3, kundenName: "Beispiel AG", projektId: 2, projektName: "E-Commerce Plattform", datum: "2024-02-10", uhrzeitBeginn: "10:00", dauer: "60", betreff: "Technische Anforderungen", nameAnsprechpartner: "Herr Weber", telefon: "+49 123 45680", notiz: "Payment-Gateway und Versand-Schnittstellen müssen integriert werden. Zeitplan besprochen.", wiedervorlageDatum: "2024-02-15", erledigt: true},
{id: 4, kundenName: "Beispiel AG", projektId: 3, projektName: "CRM-System", datum: "2024-02-18", uhrzeitBeginn: "11:15", dauer: "20", betreff: "Schulungstermin", nameAnsprechpartner: "Frau Bauer", telefon: "+49 123 45681", notiz: "Schulung für 10 Mitarbeiter geplant. Raum noch zu buchen.", wiedervorlageDatum: "2024-02-20", erledigt: false},
{id: 5, kundenName: "Test Unternehmen", projektId: 4, projektName: "Mobile App", datum: "2024-03-05", uhrzeitBeginn: "15:45", dauer: "35", betreff: "Feature-Request", nameAnsprechpartner: "Herr König", telefon: "+49 123 45682", notiz: "Kunde wünscht Push-Benachrichtigungen und Offline-Modus.", wiedervorlageDatum: "2024-03-12", erledigt: false}
]
/**
* Liefert die nächste freie Call-Log-ID auf Basis des In-Memory-Speichers.
* Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion.
*/
export function getNewCallLogId(): number {
if (callLogDaten.length === 0) {
return 1
}
return Math.max(...callLogDaten.map((log: iCallLog) => log.id)) + 1
}
/**
* Fügt eine neue Gesprächsnotiz dem In-Memory-Speicher hinzu.
*/
export function addCallLogData(log: iCallLog): void {
callLogDaten.push(log)
}
/**
* Aktualisiert eine bestehende Gesprächsnotiz im In-Memory-Speicher.
*/
export function updateCallLogData(log: iCallLog): void {
const index = callLogDaten.findIndex((l: iCallLog) => l.id === log.id)
if (index !== -1) {
callLogDaten[index] = log
}
}
/**
* Liefert eine Kopie aller Gesprächsnotizen aus dem In-Memory-Speicher.
*/
export function getCallLogs(): iCallLog[] {
return [...callLogDaten]
}
/**
* Liefert die Gesprächsnotizen eines bestimmten Projekts. Ist projektId null,
* werden alle Gesprächsnotizen zurückgegeben.
*/
export function getCallLogsByProjektId(projektId: number | null): iCallLog[] {
return projektId
? callLogDaten.filter((l: iCallLog) => l.projektId === projektId)
: [...callLogDaten]
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */ /* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */ /* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten. Die Endpunkte sind noch nicht */ /* serverseitigen REST-Endpunkten (/xhr/calllog/*). */
/* implementiert und werden später nachgereicht. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/** /**
@@ -91,11 +36,19 @@ const CALLLOG_REST_BASE = "/xhr/calllog"
/** /**
* Lädt die Liste der Gesprächsnotizen vom Server. * Lädt die Liste der Gesprächsnotizen vom Server.
* Ist projektId gesetzt (>= 0), werden nur die Notizen dieses Projekts
* geliefert (serverseitige Filterung). Ohne Parameter werden alle
* Notizen geliefert.
*/ */
export function fetchCallLogs(): Promise<iCallLog[]> { export function fetchCallLogs(projektId?: number): Promise<iCallLog[]> {
const params: Record<string, any> = {}
if (projektId !== undefined && projektId >= 0) {
params.projekt_id = projektId
}
return m.request({ return m.request({
method: "GET", method: "GET",
url: CALLLOG_REST_BASE + "/list" url: CALLLOG_REST_BASE + "/list",
params: params
}).then((result) => result as iCallLog[]) }).then((result) => result as iCallLog[])
} }
+17 -70
View File
@@ -2,12 +2,15 @@ import m from "mithril"
/** /**
* Datenmodell für einen Kontakt (Ansprechpartner beim Kunden). * Datenmodell für einen Kontakt (Ansprechpartner beim Kunden).
* Jeder Kontakt ist genau einem Kunden zugeordnet (FK kundenId). * Jeder Kontakt ist genau einem Kunden zugeordnet (FK kunden_id).
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* kunden_name ist keine ORM-Spalte, sondern wird serverseitig aus der
* Kunde-Relationship abgeleitet und im JSON ergänzt.
*/ */
export interface iKontakt { export interface iKontakt {
id: number id: number
kundenId: number kunden_id: number
kundenName: string kunden_name: string
anrede: string anrede: string
name: string name: string
position: string position: string
@@ -16,75 +19,11 @@ export interface iKontakt {
notiz: string notiz: string
} }
/**
* In-Memory-Datenspeicher für Kontakte. Wird als Fallback solange verwendet,
* bis die serverseitigen REST-Endpunkte verfügbar sind.
*/
export const kontaktDaten: iKontakt[] = [
{id: 1, kundenId: 1, kundenName: "Musterfirma GmbH", anrede: "Herr", name: "Max Mustermann", position: "Geschäftsführer", telefon: "+49 123 45678", email: "m.mustermann@musterfirma.de", notiz: "Erster Ansprechpartner für das Website-Projekt."},
{id: 2, kundenId: 1, kundenName: "Musterfirma GmbH", anrede: "Frau", name: "Anna Schmidt", position: "Marketingleiterin", telefon: "+49 123 45679", email: "a.schmidt@musterfirma.de", notiz: "Zuständig für Design-Fragen."},
{id: 3, kundenId: 2, kundenName: "Beispiel AG", anrede: "Herr", name: "Peter Müller", position: "IT-Leiter", telefon: "+49 543 21012", email: "p.mueller@beispiel-ag.de", notiz: "Technischer Ansprechpartner."},
{id: 4, kundenId: 3, kundenName: "Test Unternehmen", anrede: "Frau", name: "Lisa Bauer", position: "Projektmanagerin", telefon: "+49 678 90123", email: "l.bauer@test-unternehmen.com", notiz: ""}
]
/**
* Liefert die nächste freie Kontakt-ID auf Basis des In-Memory-Speichers.
* Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion.
*/
export function getNewKontaktId(): number {
if (kontaktDaten.length === 0) {
return 1
}
return Math.max(...kontaktDaten.map((kontakt: iKontakt) => kontakt.id)) + 1
}
/**
* Fügt einen neuen Kontakt dem In-Memory-Speicher hinzu.
*/
export function addKontakt(kontakt: iKontakt): void {
kontaktDaten.push(kontakt)
}
/**
* Aktualisiert einen bestehenden Kontakt im In-Memory-Speicher.
*/
export function updateKontakt(kontakt: iKontakt): void {
const index = kontaktDaten.findIndex((k: iKontakt) => k.id === kontakt.id)
if (index !== -1) {
kontaktDaten[index] = kontakt
}
}
/**
* Liefert eine Kopie aller Kontakte aus dem In-Memory-Speicher.
*/
export function getKontakte(): iKontakt[] {
return [...kontaktDaten]
}
/**
* Liefert die Kontakte eines bestimmten Kunden. Ist kundenId null, werden
* alle Kontakte zurückgegeben.
*/
export function getKontakteByKundenId(kundenId: number | null): iKontakt[] {
return kundenId
? kontaktDaten.filter((k: iKontakt) => k.kundenId === kundenId)
: [...kontaktDaten]
}
/**
* Liefert einen einzelnen Kontakt anhand seiner ID oder undefined.
*/
export function getKontaktById(id: number): iKontakt | undefined {
return kontaktDaten.find((k: iKontakt) => k.id === id)
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */ /* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */ /* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten. Die Endpunkte sind noch nicht */ /* serverseitigen REST-Endpunkten (/xhr/kontakt/*). */
/* implementiert und werden später nachgereicht. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/** /**
@@ -94,11 +33,19 @@ const KONTAKT_REST_BASE = "/xhr/kontakt"
/** /**
* Lädt die Kontaktliste vom Server. * Lädt die Kontaktliste vom Server.
* Ist kundenId gesetzt (>= 0), werden nur die Kontakte dieses Kunden
* geliefert (serverseitige Filterung). Ohne Parameter werden alle
* Kontakte geliefert.
*/ */
export function fetchKontakte(): Promise<iKontakt[]> { export function fetchKontakte(kundenId?: number): Promise<iKontakt[]> {
const params: Record<string, any> = {}
if (kundenId !== undefined && kundenId >= 0) {
params.kunden_id = kundenId
}
return m.request({ return m.request({
method: "GET", method: "GET",
url: KONTAKT_REST_BASE + "/list" url: KONTAKT_REST_BASE + "/list",
params: params
}).then((result) => result as iKontakt[]) }).then((result) => result as iKontakt[])
} }
+18 -70
View File
@@ -2,15 +2,19 @@ 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 projektId). * Jede Kontaktadresse ist genau einem Projekt zugeordnet (FK projekt_id).
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* projekt_name ist keine ORM-Spalte, sondern wird serverseitig aus der
* Projekt-Relationship abgeleitet und im JSON ergänzt.
*/ */
export interface iKontaktadresse { export interface iKontaktadresse {
id: number id: number
projektId: number projekt_id: number
projektName: string projekt_name: string
anrede: string anrede: string
name: string name: string
firma: string firma: string
position: string
telefon: string telefon: string
email: string email: string
strasse: string strasse: string
@@ -20,75 +24,11 @@ export interface iKontaktadresse {
notiz: string notiz: string
} }
/**
* In-Memory-Datenspeicher für Kontaktadressen. Wird als Fallback solange verwendet,
* bis die serverseitigen REST-Endpunkte verfügbar sind.
*/
export const kontaktadresseDaten: iKontaktadresse[] = [
{id: 1, projektId: 1, projektName: "Website-Relaunch", anrede: "Herr", name: "Thomas Becker", firma: "Becker Webdesign GmbH", telefon: "+49 201 123456", email: "t.becker@becker-webdesign.de", strasse: "Industriestr. 12", plz: "45131", ort: "Essen", land: "Deutschland", notiz: "Interessent für Website-Relaunch, Erstkontakt auf Messe."},
{id: 2, projektId: 1, projektName: "Website-Relaunch", anrede: "Frau", name: "Sandra Klein", firma: "Klein Marketing", telefon: "+49 211 987654", email: "s.klein@klein-marketing.de", strasse: "Königsallee 5", plz: "40212", ort: "Düsseldorf", land: "Deutschland", notiz: ""},
{id: 3, projektId: 2, projektName: "E-Commerce Plattform", anrede: "Herr", name: "Michael Wagner", firma: "Wagner Online Shop", telefon: "+49 221 555123", email: "m.wagner@wagner-shop.de", strasse: "Hauptstr. 88", plz: "50667", ort: "Köln", land: "Deutschland", notiz: "Sucht E-Commerce-Lösung, Budget geklärt."},
{id: 4, projektId: 3, projektName: "CRM-System", anrede: "Frau", name: "Julia Hoffmann", firma: "Hoffmann Consulting", telefon: "+49 89 444333", email: "j.hoffmann@hoffmann-consulting.de", strasse: "Marienplatz 3", plz: "80331", ort: "München", land: "Deutschland", notiz: "Wiedervorlage im nächsten Quartal."}
]
/**
* Liefert die nächste freie Kontaktadressen-ID auf Basis des In-Memory-Speichers.
* Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion.
*/
export function getNewKontaktadresseId(): number {
if (kontaktadresseDaten.length === 0) {
return 1
}
return Math.max(...kontaktadresseDaten.map((ka: iKontaktadresse) => ka.id)) + 1
}
/**
* Fügt eine neue Kontaktadresse dem In-Memory-Speicher hinzu.
*/
export function addKontaktadresse(kontaktadresse: iKontaktadresse): void {
kontaktadresseDaten.push(kontaktadresse)
}
/**
* Aktualisiert eine bestehende Kontaktadresse im In-Memory-Speicher.
*/
export function updateKontaktadresse(kontaktadresse: iKontaktadresse): void {
const index = kontaktadresseDaten.findIndex((k: iKontaktadresse) => k.id === kontaktadresse.id)
if (index !== -1) {
kontaktadresseDaten[index] = kontaktadresse
}
}
/**
* Liefert eine Kopie aller Kontaktadressen aus dem In-Memory-Speicher.
*/
export function getKontaktadressen(): iKontaktadresse[] {
return [...kontaktadresseDaten]
}
/**
* Liefert die Kontaktadressen eines bestimmten Projekts. Ist projektId null,
* werden alle Kontaktadressen zurückgegeben.
*/
export function getKontaktadressenByProjektId(projektId: number | null): iKontaktadresse[] {
return projektId
? kontaktadresseDaten.filter((k: iKontaktadresse) => k.projektId === projektId)
: [...kontaktadresseDaten]
}
/**
* Liefert eine einzelne Kontaktadresse anhand ihrer ID oder undefined.
*/
export function getKontaktadresseById(id: number): iKontaktadresse | undefined {
return kontaktadresseDaten.find((k: iKontaktadresse) => k.id === id)
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */ /* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */ /* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten. Die Endpunkte sind noch nicht */ /* serverseitigen REST-Endpunkten (/xhr/kontaktadresse/*). */
/* implementiert und werden später nachgereicht. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/** /**
@@ -98,11 +38,19 @@ const KONTAKTADRESSE_REST_BASE = "/xhr/kontaktadresse"
/** /**
* Lädt die Kontaktadressenliste vom Server. * Lädt die Kontaktadressenliste vom Server.
* Ist projektId gesetzt (>= 0), werden nur die Kontaktadressen dieses
* Projekts geliefert (serverseitige Filterung). Ohne Parameter werden
* alle Kontaktadressen geliefert.
*/ */
export function fetchKontaktadressen(): Promise<iKontaktadresse[]> { export function fetchKontaktadressen(projektId?: number): Promise<iKontaktadresse[]> {
const params: Record<string, any> = {}
if (projektId !== undefined && projektId >= 0) {
params.projekt_id = projektId
}
return m.request({ return m.request({
method: "GET", method: "GET",
url: KONTAKTADRESSE_REST_BASE + "/list" url: KONTAKTADRESSE_REST_BASE + "/list",
params: params
}).then((result) => result as iKontaktadresse[]) }).then((result) => result as iKontaktadresse[])
} }
+3 -48
View File
@@ -12,57 +12,11 @@ export interface iKunde {
land: string land: string
} }
/**
* In-Memory-Datenspeicher für Kunden. Wird als Fallback solange verwendet,
* bis die serverseitigen REST-Endpunkte verfügbar sind.
*/
export const kundenDaten: iKunde[] = [
{id: 1, name: "Musterfirma GmbH", strasse: "Musterstraße 1", plz: "12345", ort: "Musterstadt", land: "Deutschland"},
{id: 2, name: "Beispiel AG", strasse: "Beispielweg 2", plz: "54321", ort: "Beispielstadt", land: "Deutschland"},
{id: 3, name: "Test Unternehmen", strasse: "Testallee 3", plz: "67890", ort: "Testdorf", land: "Österreich"}
]
/**
* Liefert die nächste freie Kunden-ID auf Basis des In-Memory-Speichers.
* Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion.
*/
export function getNewKundeId(): number {
if (kundenDaten.length === 0) {
return 1
}
return Math.max(...kundenDaten.map((kunde: iKunde) => kunde.id)) + 1
}
/**
* Fügt einen neuen Kunden dem In-Memory-Speicher hinzu.
*/
export function addKunde(kunde: iKunde): void {
kundenDaten.push(kunde)
}
/**
* Aktualisiert einen bestehenden Kunden im In-Memory-Speicher.
*/
export function updateKunde(kunde: iKunde): void {
const index = kundenDaten.findIndex((k: iKunde) => k.id === kunde.id)
if (index !== -1) {
kundenDaten[index] = kunde
}
}
/**
* Liefert eine Kopie aller Kunden aus dem In-Memory-Speicher.
*/
export function getKunden(): iKunde[] {
return [...kundenDaten]
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */ /* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */ /* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten. Die Endpunkte sind noch nicht */ /* serverseitigen REST-Endpunkten (/xhr/kunde/*). */
/* implementiert und werden später nachgereicht. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/** /**
@@ -92,7 +46,8 @@ export function fetchKundeById(id: number): Promise<iKunde> {
} }
/** /**
* Legt einen neuen Kunden serverseitig an. * Legt einen neuen Kunden serverseitig an. Die ID wird vom Server
* vergeben und im zurückgegebenen Kundenobjekt gesetzt.
*/ */
export function createKundeREST(kunde: iKunde): Promise<iKunde> { export function createKundeREST(kunde: iKunde): Promise<iKunde> {
return m.request({ return m.request({
+16 -69
View File
@@ -2,86 +2,25 @@ import m from "mithril"
/** /**
* Datenmodell für ein Projekt. * Datenmodell für ein Projekt.
* Die Feldnamen entsprechen den serverseitigen ORM-Spalten (snake_case).
* kunden_name ist keine ORM-Spalte, sondern wird serverseitig aus der
* Kunde-Relationship abgeleitet und im JSON ergänzt.
*/ */
export interface iProjekt { export interface iProjekt {
id: number id: number
kundenId: number kunden_id: number
kundenName: string kunden_name: string
projektname: string projektname: string
zweck: string zweck: string
verantwortlicher: string verantwortlicher: string
startdatum: string startdatum: string
} }
/**
* In-Memory-Datenspeicher für Projekte. Wird als Fallback solange verwendet,
* bis die serverseitigen REST-Endpunkte verfügbar sind.
*/
export const projektDaten: iProjekt[] = [
{id: 1, kundenId: 1, kundenName: "Musterfirma GmbH", projektname: "Website-Relaunch", zweck: "Neugestaltung der Unternehmenswebsite", verantwortlicher: "Max Mustermann", startdatum: "2024-01-15"},
{id: 2, kundenId: 1, kundenName: "Musterfirma GmbH", projektname: "E-Commerce Plattform", zweck: "Online-Shop Entwicklung", verantwortlicher: "Anna Schmidt", startdatum: "2024-03-01"},
{id: 3, kundenId: 2, kundenName: "Beispiel AG", projektname: "CRM-System", zweck: "Kundenbeziehungsmanagement", verantwortlicher: "Peter Müller", startdatum: "2024-02-20"},
{id: 4, kundenId: 3, kundenName: "Test Unternehmen", projektname: "Mobile App", zweck: "iOS und Android Entwicklung", verantwortlicher: "Lisa Bauer", startdatum: "2024-04-10"}
]
/**
* Liefert die nächste freie Projekt-ID auf Basis des In-Memory-Speichers.
* Sobald die serverseitige ID-Vergabe verfügbar ist, entfällt diese Funktion.
*/
export function getNewProjektId(): number {
if (projektDaten.length === 0) {
return 1
}
return Math.max(...projektDaten.map((projekt: iProjekt) => projekt.id)) + 1
}
/**
* Fügt ein neues Projekt dem In-Memory-Speicher hinzu.
*/
export function addProjekt(projekt: iProjekt): void {
projektDaten.push(projekt)
}
/**
* Aktualisiert ein bestehendes Projekt im In-Memory-Speicher.
*/
export function updateProjekt(projekt: iProjekt): void {
const index = projektDaten.findIndex((p: iProjekt) => p.id === projekt.id)
if (index !== -1) {
projektDaten[index] = projekt
}
}
/**
* Liefert eine Kopie aller Projekte aus dem In-Memory-Speicher.
*/
export function getProjekte(): iProjekt[] {
return [...projektDaten]
}
/**
* Liefert die Projekte eines bestimmten Kunden. Ist kundenId null, werden
* alle Projekte zurückgegeben.
*/
export function getProjekteByKundenId(kundenId: number | null): iProjekt[] {
return kundenId
? projektDaten.filter((p: iProjekt) => p.kundenId === kundenId)
: [...projektDaten]
}
/**
* Liefert ein einzelnes Projekt anhand seiner ID oder undefined.
*/
export function getProjektById(id: number): iProjekt | undefined {
return projektDaten.find((p: iProjekt) => p.id === id)
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* REST-API-Aufrufe (Client-Seite) */ /* REST-API-Aufrufe (Client-Seite) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Die folgenden Funktionen kapseln die Kommunikation mit den */ /* Die folgenden Funktionen kapseln die Kommunikation mit den */
/* serverseitigen REST-Endpunkten. Die Endpunkte sind noch nicht */ /* serverseitigen REST-Endpunkten (/xhr/projekt/*). */
/* implementiert und werden später nachgereicht. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/** /**
@@ -91,11 +30,19 @@ const PROJEKT_REST_BASE = "/xhr/projekt"
/** /**
* Lädt die Projektliste vom Server. * Lädt die Projektliste vom Server.
* Ist kundenId gesetzt (>= 0), werden nur die Projekte dieses Kunden
* geliefert (serverseitige Filterung). Ohne Parameter werden alle
* Projekte geliefert.
*/ */
export function fetchProjekte(): Promise<iProjekt[]> { export function fetchProjekte(kundenId?: number): Promise<iProjekt[]> {
const params: Record<string, any> = {}
if (kundenId !== undefined && kundenId >= 0) {
params.kunden_id = kundenId
}
return m.request({ return m.request({
method: "GET", method: "GET",
url: PROJEKT_REST_BASE + "/list" url: PROJEKT_REST_BASE + "/list",
params: params
}).then((result) => result as iProjekt[]) }).then((result) => result as iProjekt[])
} }