refactor: Projektstruktur in app/ und backend/ aufgeteilt

This commit is contained in:
2026-03-24 15:06:44 +01:00
parent 5f5d5995c5
commit 2673ef658d
449 changed files with 28551 additions and 167 deletions

8
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/target/
/node_modules/
/src/main/frontend/generated/
/vite.generated.ts
/logs/
/.env
*.log
.DS_Store

View File

@@ -0,0 +1,19 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
wrapperVersion=3.3.2
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip

6
backend/.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"printWidth": 120,
"bracketSameLine": true
}

12
backend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM eclipse-temurin:21-jre
ARG JAR_FILE=target/*.jar
# Zeitzone auf Berlin setzen und 24h-Format konfigurieren
ENV TZ=Europe/Berlin
ENV LC_TIME=de_DE.UTF-8
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
COPY ${JAR_FILE} app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=production"]

441
backend/HANDBUCH.md Normal file
View File

@@ -0,0 +1,441 @@
# VotianLT - Benutzerhandbuch
**Version:** 1.0
**Stand:** Februar 2026
**Anwendung:** VotianLT - Ihr digitaler Transportpartner
---
## Inhaltsverzeichnis
1. [Einleitung](#1-einleitung)
2. [Registrierung und Anmeldung](#2-registrierung-und-anmeldung)
3. [Dashboard](#3-dashboard)
4. [Auftragsverwaltung](#4-auftragsverwaltung)
5. [Kundenverwaltung](#5-kundenverwaltung)
6. [App-Nutzer verwalten](#6-app-nutzer-verwalten)
7. [Nachrichten](#7-nachrichten)
8. [Rechnungen](#8-rechnungen)
9. [KI-Statistiken](#9-ki-statistiken)
10. [Profil bearbeiten](#10-profil-bearbeiten)
11. [Administration](#11-administration)
---
## 1. Einleitung
VotianLT ist eine webbasierte Anwendung zur digitalen Verwaltung von Transportaufträgen. Die Software richtet sich an Solo-Selbstständige und Kleinunternehmer im Transportgewerbe und bietet folgende Kernfunktionen:
- **Auftragserstellung und -verwaltung** mit vollständiger Abwicklung von der Erstellung bis zur Fertigstellung
- **Digitale Auftragsbearbeitung** über eine mobile App für Fahrer und Mitarbeiter im Außendienst
- **Echtzeit-Kommunikation** zwischen Disponent und App-Nutzern per Chat
- **Rechnungserstellung** für abgeschlossene Aufträge
- **KI-gestützte Statistiken** zur Auswertung von Geschäftsdaten
- **GPS-Tracking** zur Nachverfolgung von Lieferungen in Echtzeit
---
## 2. Registrierung und Anmeldung
### 2.1 Neues Konto registrieren
1. Öffnen Sie die Startseite und klicken Sie auf **"Jetzt kostenlos testen"** oder **"Registrieren"**.
2. Füllen Sie das Registrierungsformular aus:
- **Firmenname** (Pflichtfeld)
- **E-Mail-Adresse** (wird als Benutzername verwendet)
- **Passwort** und **Passwort bestätigen** (mindestens 6 Zeichen)
- **Vorname** und **Nachname**
- **Telefonnummer**
- **Anschrift** (Straße, Hausnummer, PLZ, Ort)
3. Klicken Sie auf **"Registrieren"**. Ein 6-stelliger Bestätigungscode wird an Ihre E-Mail-Adresse gesendet.
4. Geben Sie den Code im angezeigten Feld ein und klicken Sie auf **"Code prüfen und registrieren"**.
5. Nach erfolgreicher Registrierung werden Sie zur Anmeldeseite weitergeleitet.
> **Hinweis:** Der Bestätigungscode ist 10 Minuten gültig. Über den Button **"Code erneut senden"** können Sie nach 60 Sekunden einen neuen Code anfordern.
### 2.2 Anmelden
1. Geben Sie auf der Anmeldeseite Ihre **E-Mail-Adresse** und Ihr **Passwort** ein.
2. Klicken Sie auf **"Anmelden"**.
3. Falls die Zwei-Faktor-Authentifizierung (2FA) aktiviert ist, wird ein 6-stelliger Code an Ihre E-Mail gesendet. Geben Sie diesen im angezeigten Feld ein und klicken Sie auf **"Code prüfen"**.
### 2.3 Passwort zurücksetzen
1. Klicken Sie auf der Anmeldeseite auf **"Passwort vergessen?"**.
2. Geben Sie Ihre registrierte E-Mail-Adresse ein und klicken Sie auf **"E-Mail senden"**.
3. Sie erhalten eine E-Mail mit einem Link zum Zurücksetzen. Klicken Sie auf den Link.
4. Vergeben Sie ein neues Passwort und bestätigen Sie es. Klicken Sie auf **"Passwort speichern"**.
---
## 3. Dashboard
Nach der Anmeldung gelangen Sie zum Dashboard. Es zeigt eine Willkommensnachricht und eine Übersicht der wichtigsten Funktionen:
- **Einrichtungsassistent** - Hilfe beim Einstieg in die Anwendung
- **Kunden- und Auftragsverwaltung** - Schnellzugriff auf die Verwaltungsbereiche
- **Auftragserstellung** - Direkter Link zur Erstellung neuer Aufträge
Die **Hauptnavigation** am linken Rand bietet Zugriff auf alle Bereiche der Anwendung.
---
## 4. Auftragsverwaltung
### 4.1 Aufträge anzeigen
Öffnen Sie den Bereich **"Aufträge"** über die Navigation oder den Pfad `/jobs`.
**Filtermöglichkeiten:**
- **Zeitraum**: Wählen Sie Start- und Enddatum (Standard: letzte 30 Tage)
- **Auftragsnummer**: Suchen Sie nach einer bestimmten Auftragsnummer
- **Status**: Filtern Sie nach "Alle", "Offen" oder "Erledigt"
- *Offen* umfasst: Erstellt, In Bearbeitung, Abholung geplant, Abgeholt, In Zustellung
- *Erledigt* umfasst: Zugestellt, Abgeschlossen, Storniert
Klicken Sie auf **"Anwenden"**, um die Filter zu übernehmen.
**Verfügbare Aktionen pro Auftrag:**
| Symbol | Aktion | Sichtbar wenn |
|--------|--------|---------------|
| Häkchen | Auftrag manuell abschließen | Nicht-digitale Aufträge, die noch nicht abgeschlossen sind |
| Dollar | Rechnung erstellen | Auftrag ist abgeschlossen |
| Papierkorb | Auftrag löschen | Auftrag ist nicht abgeschlossen/storniert/zugestellt |
**CSV-Export:** Klicken Sie auf **"CSV Export"**, um die aktuell gefilterte Auftragsliste als CSV-Datei herunterzuladen.
Klicken Sie auf eine Zeile, um die **Auftragszusammenfassung** zu öffnen.
### 4.2 Neuen Auftrag anlegen
Navigieren Sie zu **"Auftragserstellung"** oder dem Pfad `/add_job`. Das Formular ist in fünf Tabs unterteilt:
#### Tab 1: Auftraggeber & Adressen
1. **Auftraggeber wählen**: Wählen Sie aus der Dropdown-Liste einen bestehenden Kunden. Die Abholadresse wird automatisch mit den Kundendaten vorbefüllt.
2. **Abholadresse**: Füllen Sie alle Pflichtfelder aus (Firma, Anrede, Vorname, Nachname, Telefon, Straße, Hausnummer, PLZ, Ort). Optional können Sie die Adresse für zukünftige Aufträge speichern.
3. **Zustelladresse**: Geben Sie die Lieferadresse ein. Firmennamen werden per Autovervollständigung vorgeschlagen.
4. Mit dem Button **"Vorbelegte Adressfelder leeren"** können Sie alle vorausgefüllten Felder zurücksetzen.
> **Hinweis:** Beim Wechsel zum nächsten Tab werden die Adressen automatisch validiert und eine Routenberechnung (Entfernung und Dauer) durchgeführt. Bei ungültigen Adressen wird ein Hinweisdialog angezeigt.
#### Tab 2: Termine & Verarbeitung
1. **Abholtermin**: Wählen Sie Datum und Uhrzeit für die Abholung.
2. **Zustelltermin**: Wählen Sie Datum und Uhrzeit für die Zustellung.
3. **Digitale Abwicklung per App**: Aktivieren Sie diese Option, wenn der Auftrag über die mobile App abgewickelt werden soll.
4. **App-Nutzer**: Wählen Sie den zuständigen App-Nutzer aus (nur sichtbar bei digitaler Abwicklung).
#### Tab 3: Ladung
1. Klicken Sie auf **"Ladung hinzufügen"**, um eine neue Ladungsposition anzulegen.
2. Für jede Position geben Sie ein:
- **Beschreibung** der Ladung
- **Menge**
- **Maße** (Länge, Breite, Höhe in cm)
- **Gewicht** in kg
3. Über das Papierkorb-Symbol können Sie einzelne Positionen entfernen.
#### Tab 4: Aufgaben (nur bei digitaler Abwicklung)
Aufgaben definieren, welche Schritte der App-Nutzer bei der Ausführung des Auftrags erledigen muss.
**Verfügbare Aufgabentypen:**
| Typ | Beschreibung |
|-----|-------------|
| Bestätigung | Der App-Nutzer bestätigt eine Aktion (z.B. "Ware übernommen") |
| Unterschrift | Einholen einer digitalen Unterschrift |
| Foto | Aufnahme eines Fotos (z.B. Liefernachweis) |
| Barcode | Scannen eines Barcodes |
| Checkliste | Abarbeiten einer Aufgabenliste |
| Kommentar | Freitextfeld für Anmerkungen |
1. Optional: Wählen Sie eine **Aufgabenvorlage** aus der Dropdown-Liste, um vordefinierte Aufgaben zu laden.
2. Klicken Sie auf **"Aufgabe hinzufügen"** und wählen Sie den gewünschten Typ.
3. Geben Sie eine Beschreibung für die Aufgabe ein.
4. Die Reihenfolge der Aufgaben kann per **Drag-and-Drop** angepasst werden.
#### Tab 5: Leistungen und Preis
1. **Pflichtleistungen** werden automatisch geladen.
2. Über die Dropdown-Liste können Sie weitere **Leistungen hinzufügen**.
3. Die Berechnung von **Netto**, **USt.** und **Brutto** erfolgt automatisch.
4. Die **Routeninformationen** (Entfernung und Fahrtdauer) werden aus der Adressvalidierung angezeigt.
5. Im Feld **Bemerkung** können Sie zusätzliche Anmerkungen zum Auftrag hinterlegen.
Klicken Sie abschließend auf **"Auftrag anlegen"**, um den Auftrag zu erstellen. Bei digitaler Abwicklung wird der Auftrag automatisch an den ausgewählten App-Nutzer übermittelt.
### 4.3 Auftragszusammenfassung
Die Zusammenfassung zeigt alle Details eines Auftrags auf einen Blick:
- **Abholinformationen**: Adresse, Ansprechpartner, Termin
- **Zustellinformationen**: Adresse, Ansprechpartner, Termin
- **Aufgaben**: Übersicht aller Aufgaben mit Erledigungsstatus. Abgeschlossene Aufgaben zeigen das Ergebnis (Foto, Barcode-Wert, Unterschrift-Vorschau etc.)
- **Ladung**: Liste aller Ladungspositionen mit Maßen und Gewicht
- **Auftragsinformationen**: Preis, Bemerkung, Verarbeitungsart, zugewiesener App-Nutzer
- **Routenkarte**: Google Maps mit der Route von Abhol- zu Zustelladresse. Bei aktiver digitaler Abwicklung wird die aktuelle **GPS-Position des App-Nutzers** in Echtzeit angezeigt.
**Verfügbare Aktionen:**
- **"Nachricht senden"**: Öffnet den Chat mit dem zugewiesenen App-Nutzer im Kontext dieses Auftrags
- **"Job Historie"**: Zeigt den vollständigen Änderungsverlauf des Auftrags
- **"Auftrag manuell abschließen"**: Schließt nicht-digitale Aufträge manuell ab (nach Bestätigung)
### 4.4 Auftragsverlauf (Job Historie)
Die Job Historie zeigt eine chronologische Zeitleiste aller Ereignisse eines Auftrags:
- **Statusänderungen** (z.B. "Erstellt", "In Bearbeitung", "Zugestellt")
- **Aufgabenabschlüsse** mit zugehörigen Daten (Fotos, Barcodes, Unterschriften)
- **Änderungen** an Auftragsdaten
Jeder Eintrag zeigt den **Zeitstempel**, die **Art der Änderung** und ggf. **zugehörige Daten** (z.B. Foto-Vorschau).
---
## 5. Kundenverwaltung
### 5.1 Kunden anzeigen
Navigieren Sie zu **"Kunden"** (`/customers`). Die Kundenliste zeigt alle Ihre Kunden mit folgenden Spalten:
- Firma, Name, E-Mail, Telefon, Straße, Ort
Die Liste ist **sortierbar** (Klick auf Spaltenüberschrift). Klicken Sie auf einen Eintrag, um den Kunden zu bearbeiten.
### 5.2 Neuen Kunden anlegen
1. Klicken Sie auf **"Kunde hinzufügen"** oder navigieren Sie zu `/add-customer`.
2. Füllen Sie das Formular aus:
- **Firmenname** (Pflichtfeld)
- **Anrede** (Herr / Frau / Divers)
- **Vorname** und **Nachname** (Pflichtfelder)
- **Telefon** (Pflichtfeld)
- **Fax** (optional)
- **E-Mail** (Pflichtfeld, wird auf Gültigkeit geprüft)
- **Straße** und **Hausnummer** (Pflichtfelder)
- **Adresszusatz** (optional)
- **PLZ** und **Ort** (Pflichtfelder)
3. Klicken Sie auf **"Kunden anlegen"**.
### 5.3 Kunden bearbeiten
1. Klicken Sie in der Kundenliste auf den gewünschten Kunden.
2. Ändern Sie die gewünschten Felder.
3. Klicken Sie auf **"Speichern"**, um die Änderungen zu übernehmen.
4. Über **"Löschen"** können Sie den Kunden nach Bestätigung endgültig entfernen.
---
## 6. App-Nutzer verwalten
App-Nutzer sind Fahrer oder Mitarbeiter, die die mobile VotianLT-App verwenden, um Aufträge digital abzuwickeln.
### 6.1 App-Nutzer anzeigen
Navigieren Sie zu **"App-Nutzer"** (`/app-user`). Die Liste zeigt alle Ihre App-Nutzer mit:
- Bezeichnung (Kennzeichen/Code), Vorname, Nachname, Telefon, App-Code, E-Mail
### 6.2 Neuen App-Nutzer anlegen
1. Klicken Sie auf **"Neuen App-Nutzer anlegen"**.
2. Füllen Sie das Formular aus:
- **Bezeichnung** (z.B. Fahrzeugkennzeichen im Format "HH H 000", Pflichtfeld)
- **Vorname** und **Nachname** (Pflichtfelder)
- **Telefon** (Pflichtfeld)
- **E-Mail** (Pflichtfeld, muss eindeutig sein)
- **Passwort** und **Passwort bestätigen** (mindestens 6 Zeichen)
3. Klicken Sie auf **"App-Nutzer anlegen"**.
> **Hinweis:** Der App-Nutzer meldet sich in der mobilen App mit seiner E-Mail-Adresse und dem vergebenen Passwort an.
### 6.3 App-Nutzer bearbeiten
1. Klicken Sie in der Liste auf den gewünschten App-Nutzer.
2. Ändern Sie die gewünschten Felder.
3. **Passwort ändern**: Lassen Sie die Passwortfelder leer, wenn das Passwort nicht geändert werden soll. Füllen Sie beide Felder aus, um ein neues Passwort zu vergeben.
4. Klicken Sie auf **"Speichern"**.
5. Über **"Löschen"** können Sie den App-Nutzer nach Bestätigung entfernen.
---
## 7. Nachrichten
Das Nachrichtensystem ermöglicht die Echtzeit-Kommunikation zwischen Ihnen (Disponent) und Ihren App-Nutzern.
### 7.1 Nachrichtenübersicht
Navigieren Sie zu **"Nachrichten"** (`/messages`). Die Übersicht zeigt alle Ihre App-Nutzer als Kontakte mit:
- **Status-Anzeige** (farbiger Punkt bei ungelesenen Nachrichten)
- **Name** und **E-Mail** des App-Nutzers
- **Gesamtanzahl** der Nachrichten
- **Ungelesene Nachrichten** (fett hervorgehoben)
- **Letzte Nachricht** (Vorschau, Datum und Uhrzeit)
> **Hinweis:** Die Übersicht aktualisiert sich automatisch. Bei eingehenden Nachrichten ertönt ein **Benachrichtigungston** und es wird eine **Browser-Benachrichtigung** angezeigt (sofern Benachrichtigungen im Browser erlaubt sind).
### 7.2 Konversationen eines App-Nutzers
Klicken Sie auf einen App-Nutzer, um dessen Konversationen zu sehen:
- **Allgemeine Nachrichten**: Ein allgemeiner Chat ohne Auftragsbezug
- **Nachrichten zu Aufträgen**: Separate Konversationen für jeden Auftrag (gruppiert nach Auftragsnummer)
Jede Konversation zeigt eine Vorschau, den Zeitpunkt der letzten Nachricht und die Anzahl ungelesener Nachrichten.
### 7.3 Chat / Nachrichtenverlauf
Öffnen Sie eine Konversation, um den Chatverlauf zu sehen. Die Darstellung ähnelt bekannten Messenger-Apps:
- **Ihre Nachrichten** (vom Disponenten): rechts, grüner Hintergrund
- **Nachrichten des App-Nutzers**: links, weißer Hintergrund
- **Datumstrenner** zwischen verschiedenen Tagen
- **Zeitstempel** bei jeder Nachricht
**Nachricht senden:**
1. Geben Sie Ihre Nachricht im Textfeld am unteren Rand ein.
2. Klicken Sie auf **"Senden"**.
**Bild senden:**
1. Klicken Sie auf das **Büroklammer-Symbol** neben dem Textfeld.
2. Wählen Sie ein Bild aus (PNG, JPEG, GIF oder WebP, max. 32 MB).
3. Eine Vorschau wird angezeigt. Bestätigen Sie den Versand.
> **Hinweis:** Neue Nachrichten erscheinen in Echtzeit. Die Ansicht scrollt automatisch zur neuesten Nachricht. Geöffnete Nachrichten werden automatisch als gelesen markiert.
---
## 8. Rechnungen
### 8.1 Rechnung für einen Auftrag erstellen
1. Navigieren Sie zur **Auftragsliste** (`/jobs`).
2. Filtern Sie nach Status **"Erledigt"**.
3. Klicken Sie auf das **Dollar-Symbol** neben dem gewünschten Auftrag.
4. Das Rechnungsformular zeigt:
- **Auftragsdetails** (Abhol- und Zustelladresse, Termine)
- **Leistungsdaten**: Geben Sie Kilometer und Zeitaufwand ein
- **Leistungen**: Wählen Sie die abzurechnenden Leistungen aus Ihrem Leistungskatalog
- **Zusammenfassung**: Automatische Berechnung von Netto, USt. und Brutto
5. Klicken Sie auf **"Rechnung erstellen"**.
### 8.2 Meine Rechnungen
Unter **"Meine Rechnungen"** (`/my-invoices`) finden Sie eine Übersicht aller erstellten Rechnungen:
- **Statusanzeige**: Offen (gelb), Bezahlt (grün), Überfällig (rot)
- **Rechnungsnummer**, **Datum**, **Betrag**
- **Suchfunktion** zum Filtern nach Rechnungsnummer, Status oder Betrag
Klicken Sie auf eine Rechnung, um die **PDF-Version** herunterzuladen.
---
## 9. KI-Statistiken
Der KI-Statistik-Assistent (`/statistics`) bietet eine interaktive Auswertung Ihrer Geschäftsdaten per Chat-Schnittstelle.
### Verwendung
1. Navigieren Sie zu **"KI-Statistiken"**.
2. Verwenden Sie die **Schnellaktionen** für häufige Abfragen:
- **"Aufträge zählen"**: Zeigt die Gesamtanzahl Ihrer Aufträge
- **"Umsatz"**: Berechnet Ihren Gesamtumsatz
- **"Monatstrend"**: Zeigt die monatliche Entwicklung als Diagramm
3. Oder geben Sie eine **freie Frage** in das Textfeld ein, z.B.:
- "Wie viele Aufträge hatte ich im Januar?"
- "Zeige mir den Umsatz der letzten 6 Monate als Diagramm"
- "Welcher Kunde hat die meisten Aufträge?"
Der Assistent analysiert Ihre Daten und antwortet mit **Text** und/oder **interaktiven Diagrammen** (Balken-, Linien-, Kreis- und weitere Diagrammtypen).
---
## 10. Profil bearbeiten
Unter **"Profil bearbeiten"** (`/edit-profile`) können Sie Ihre persönlichen Daten und Rechnungseinstellungen verwalten. Die Seite ist in drei Tabs unterteilt:
### Tab 1: Persönliche Daten / Kontakt
- **Firmendaten**: Firmenname (Pflicht), Firmenzusatz
- **Persönliche Daten**: Vorname, Nachname
- **Kontakt**: Telefon (Pflicht), Fax, Mobil, E-Mail (Pflicht)
- **Anschrift**: Straße, Hausnummer (Pflicht), Adresszusatz, PLZ, Ort (Pflicht)
- **Abweichende Rechnungsadresse**: Separate Adressfelder für die Rechnungsstellung
- **Digitale Abwicklung per App vorausgewählt**: Wenn aktiviert, wird bei neuen Aufträgen die digitale Abwicklung standardmäßig vorausgewählt
- **Zwei-Faktor-Authentifizierung (2FA)**: Aktivieren/deaktivieren Sie die zusätzliche Sicherheitsabfrage beim Login
### Tab 2: Rechnungsdaten
- **Rechnungspräfix** (z.B. "VLT" - wird der Rechnungsnummer vorangestellt)
- **USt-IdNr.** und **Steuernummer**
- **Bankverbindung**: Bankname und IBAN
- **Steuersatz** (z.B. 19%)
- **Einleitungstext**: Freitext, der auf Rechnungen als Einleitung erscheint
- **AGB/Zahlungsbedingungen**: Text für die Rechnungsfußzeile
- **Logo hochladen**: Laden Sie Ihr Firmenlogo hoch, das auf Rechnungen angezeigt wird
- **Abrechnung aktiviert**: Aktivieren Sie die Rechnungsfunktion
### Tab 3: Leistungen / Preisliste
Verwalten Sie Ihren **Leistungskatalog**, der bei der Auftragserstellung und Rechnungsstellung verwendet wird:
1. Klicken Sie auf **"Leistung hinzufügen"**, um eine neue Leistung anzulegen.
2. Wählen Sie die **Leistungsbezeichnung** aus oder geben Sie eine neue ein.
3. Geben Sie den **Preis** und den **USt.-Satz** ein.
4. Über das Papierkorb-Symbol können Sie einzelne Leistungen entfernen.
Rechts neben dem Formular wird eine **Live-Vorschau** Ihrer Rechnungsvorlage angezeigt, die sich bei Änderungen automatisch aktualisiert.
---
## 11. Administration
Die folgenden Bereiche stehen nur Benutzern mit Administratorrechten zur Verfügung.
### 11.1 Admin Dashboard
Das Admin Dashboard (`/admin-dashboard`) bietet eine Gesamtübersicht des Systems:
- **System-Übersicht**: Anzahl Aufträge, Benutzer, App-Benutzer
- **Job-Statistiken**: Offene, in Bearbeitung befindliche und abgeschlossene Aufträge sowie Frachtgüter
- **Aufgaben-Statistiken**: Gesamtanzahl, abgeschlossene und offene Aufgaben mit Erfolgsquote
- **Benutzer-Aktivität**: Anzahl Fotos, Barcodes, Unterschriften und Kommentare
- **System-Status**: Datenbankverbindung, WebSocket-Status, Anwendungsstatus, Speicherverbrauch
### 11.2 Preis-Tabelle
Unter **"Preis-Tabelle"** (`/admin-price-table`) können Administratoren die Systempreise konfigurieren:
- **Monatliche Grundpauschale**
- **App-Nutzungslizenz**
- **Umsatzbeteiligung** (in Prozent)
### 11.3 Rechnungsgenerator
Der Rechnungsgenerator (`/invoice-generator`) ist ein visueller Editor zum Erstellen von Rechnungsvorlagen:
- **Drag-and-Drop** von Vorlagenblöcken (Textfeld, Überschrift, Datum, Kundeninfo, Firmeninfo, Betrag, Linie, Bild) auf eine Arbeitsfläche
- **Eigenschaften-Panel** zur Bearbeitung des ausgewählten Elements
- **Funktionen**: Speichern, Laden, PDF-Vorschau, Template-Verwaltung
### 11.4 PDF-Test
Unter `/pdf-test` können Administratoren die PDF-Generierung testen:
- **System-Rechnung generieren**: Erzeugt eine PDF aus der System-Rechnungsvorlage
- **Kunden-Rechnung generieren**: Erzeugt eine PDF aus der Kunden-Rechnungsvorlage
---
## Impressum
Das Impressum ist unter `/impressum` einsehbar und enthält die gesetzlich vorgeschriebenen Angaben des Betreibers.
---
*Dieses Handbuch beschreibt den Funktionsumfang von VotianLT. Bei Fragen oder Problemen wenden Sie sich bitte an den Support.*

BIN
backend/HANDBUCH.pdf Normal file

Binary file not shown.

1410
backend/STYLEGUIDE.md Normal file

File diff suppressed because it is too large Load Diff

82
backend/docker_push.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly REGISTRY_IMAGE="registry.assecutor.org/votianlt"
readonly BACKEND_DIR="${SCRIPT_DIR}/backend"
usage() {
cat <<'EOF'
Verwendung:
./docker_push.sh [x.y.z]
Beispiel:
./docker_push.sh 0.9.13
./docker_push.sh
Voraussetzungen:
- Docker Buildx ist installiert
- Login zur Registry wurde bereits ausgeführt:
docker login registry.assecutor.org
Ohne Versionsargument wird automatisch die Version aus der pom.xml verwendet.
EOF
}
fail() {
echo "Fehler: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "'$1' wurde nicht gefunden."
}
resolve_pom_version() {
[[ -x "${BACKEND_DIR}/mvnw" ]] || fail "'${BACKEND_DIR}/mvnw' wurde nicht gefunden oder ist nicht ausführbar."
local version
version="$(
cd "${BACKEND_DIR}" && ./mvnw -q -DforceStdout help:evaluate -Dexpression=project.version \
| awk 'NF { last = $0 } END { print last }'
)"
[[ -n "${version}" ]] || fail "Version konnte nicht aus der pom.xml ermittelt werden."
echo "${version}"
}
VERSION="${1:-$(resolve_pom_version)}"
if [[ "${VERSION}" == "-h" || "${VERSION}" == "--help" ]]; then
usage
exit 0
fi
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
fail "Versionsnummer muss das Format x.y.z haben."
fi
require_command docker
docker buildx version >/dev/null 2>&1 || fail "Docker Buildx ist nicht verfügbar."
cd "${SCRIPT_DIR}"
echo "Verwende Release-Version ${VERSION}."
echo "Baue Production-JAR für Version ${VERSION} ..."
(cd "${BACKEND_DIR}" && ./mvnw -Pproduction -DskipTests -Drevision="${VERSION}" package)
JAR_FILE_REL="target/votianlt-${VERSION}.jar"
JAR_FILE_ABS="${BACKEND_DIR}/${JAR_FILE_REL}"
[[ -f "${JAR_FILE_ABS}" ]] || fail "Release-JAR wurde nicht gefunden: ${JAR_FILE_ABS}"
echo "Pushe Image ${REGISTRY_IMAGE}:${VERSION} ..."
docker buildx build \
--platform linux/amd64 \
-f "${BACKEND_DIR}/Dockerfile" \
--build-arg "JAR_FILE=${JAR_FILE_REL}" \
-t "${REGISTRY_IMAGE}:${VERSION}" \
--push \
"${BACKEND_DIR}"
echo "Fertig: ${REGISTRY_IMAGE}:${VERSION}"

View File

@@ -0,0 +1,283 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<profiles version="13">
<profile kind="CodeFormatterProfile" name="Vaadin Java Conventions 20241010" version="13">
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_before_root_tags" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.disabling_tag" value="@formatter:off"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_annotation" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_case" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.new_lines_at_block_boundaries" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_annotation_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_field" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_while" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.use_on_off_tags" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_annotation_type_member_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_prefix_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_ellipsis" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_annotation_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_breaks_compare_to_cases" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_multiple_fields" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression" value="80"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_for" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_binary_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_question_in_wildcard" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_array_initializer" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_enum_constant" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_while" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_package" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_parameters" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation" value="2"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_postfix_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_superinterfaces" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_binary_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_package" value="0"/>
<setting id="org.eclipse.jdt.core.compiler.source" value="17"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_constant_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_constructor_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_line_comments" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_declarations" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.join_wrapped_lines" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_block" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_invocation_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_member_type" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.align_type_members_on_columns" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_enum_constant" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_for" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_method_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_switch" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_unary_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_case" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.indent_parameter_description" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_switch" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_block_comment" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.lineSplit" value="120"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_if" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_brackets_in_array_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_parenthesized_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_explicitconstructorcall_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_constructor_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.indentation.size" value="8"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.enabling_tag" value="@formatter:on"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_enum_constant" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_assignment" value="0"/>
<setting id="org.eclipse.jdt.core.compiler.problem.assertIdentifier" value="error"/>
<setting id="org.eclipse.jdt.core.formatter.tabulation.char" value="space"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_parameters" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_prefix_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_statements_compare_to_body" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_method" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_outer_expressions_when_nested" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.format_guardian_clause_on_one_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_for" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_cast" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_labeled_statement" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_method_declaration" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_invocation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_constant" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation_type_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_throws" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_if" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_switch" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_throws" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_return" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_question_in_conditional" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_question_in_wildcard" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.preserve_white_space_between_code_and_line_comments" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_throw" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.compiler.problem.enumIdentifier" value="error"/>
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_ellipsis" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_inits" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_method_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.compact_else_if" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_array_initializer" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_increments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.format_line_comment_starting_on_first_column" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_constant" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.comment.indent_root_tags" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_declarations" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_explicitconstructorcall_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_switch" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_superinterfaces" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.tabulation.size" value="4"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_brace_in_block" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_constant" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_constructor_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_if" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_throws" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_javadoc_comment" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_assignment_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_assignment_operator" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_empty_lines" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_synchronized" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_paren_in_cast" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_parameters" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block_in_case" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_catch" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_constructor_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_invocation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_qualified_allocation_expression" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_and_in_type_parameter" value="insert"/>
<setting id="org.eclipse.jdt.core.compiler.compliance" value="17"/>
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer" value="2"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_brackets_in_array_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_at_in_annotation_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_cast" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_unary_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_parameterized_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_anonymous_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_parameters" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_at_end_of_file_if_missing" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_for" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_labeled_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_parameterized_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_binary_expression" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_while" value="do not insert"/>
<setting id="org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode" value="enabled"/>
<setting id="org.eclipse.jdt.core.formatter.put_empty_statement_on_new_line" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_label" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_invocation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_javadoc_comments" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.comment.line_length" value="80"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_package" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_between_import_groups" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_constant_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_semicolon" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_method_body" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_conditional" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation_type_member_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.wrap_before_binary_operator" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_between_type_declarations" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_synchronized" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_statements_compare_to_block" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_enum_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.join_lines_in_comments" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_question_in_conditional" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_field_declarations" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_compact_if" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_inits" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_array_initializer" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_default" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_and_in_type_parameter" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_constructor_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_imports" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_assert" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_html" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_parameters" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_allocation_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_conditional" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_parameterized_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_for" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_postfix_operator" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_source_code" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_synchronized" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_allocation_expression" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_throws" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration" value="16"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer" value="insert"/>
<setting id="org.eclipse.jdt.core.compiler.codegen.targetPlatform" value="17"/>
<setting id="org.eclipse.jdt.core.formatter.use_tabs_only_for_leading_indentations" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation" value="0"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_header" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.comment.format_block_comments" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_enum_constant" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.alignment_for_enum_constants" value="48"/>
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_parenthesized_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_parenthesized_expression" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_catch" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_local_declarations" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_switch" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_increments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_invocation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_assert" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_type_declaration" value="end_of_line"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_declaration" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_for" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_catch" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_parameterized_type_reference" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_field_declarations" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_annotation" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_parameterized_type_reference" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_invocation_arguments" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.comment.new_lines_at_javadoc_boundaries" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_imports" value="1"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_local_declarations" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header" value="true"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_for" value="insert"/>
<setting id="org.eclipse.jdt.core.formatter.never_indent_line_comments_on_first_column" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_arguments" value="do not insert"/>
<setting id="org.eclipse.jdt.core.formatter.never_indent_block_comments_on_first_column" value="false"/>
<setting id="org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line" value="false"/>
</profile>
</profiles>

View File

@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flutter WebSocket Test</title>
<script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@7.0.0/bundles/stomp.umd.min.js"></script>
</head>
<body>
<h1>Flutter WebSocket STOMP Test</h1>
<div id="status">Nicht verbunden</div>
<button onclick="connectWebSocket()">WebSocket Verbinden</button>
<button onclick="connectSockJS()">SockJS Verbinden</button>
<button onclick="disconnect()">Trennen</button>
<br><br>
<input type="text" id="messageInput" placeholder="Nachricht eingeben" />
<button onclick="sendMessage()">Nachricht senden</button>
<br><br>
<div id="messages"></div>
<script>
let stompClient = null;
function connectWebSocket() {
// WICHTIG: Für native WebSocket MUSS ws:// verwendet werden, NICHT http://
const socket = new WebSocket('ws://192.168.180.196:8080/websocket');
stompClient = Stomp.over(socket);
stompClient.debug = function(str) {
console.log('STOMP Debug: ' + str);
addMessage('DEBUG: ' + str);
};
stompClient.connect({}, function(frame) {
console.log('WebSocket Connected: ' + frame);
document.getElementById('status').innerHTML = 'WebSocket Verbunden';
addMessage('WebSocket erfolgreich verbunden');
// Nachrichten abonnieren
stompClient.subscribe('/topic/messages', function(message) {
addMessage('Empfangen: ' + message.body);
});
}, function(error) {
console.error('WebSocket Connection Error:', error);
document.getElementById('status').innerHTML = 'WebSocket Fehler: ' + error;
addMessage('WebSocket Fehler: ' + error);
});
}
function connectSockJS() {
// Für SockJS kann http:// verwendet werden
const socket = new SockJS('http://192.168.180.196:8080/ws');
stompClient = Stomp.over(socket);
stompClient.debug = function(str) {
console.log('STOMP Debug: ' + str);
addMessage('DEBUG: ' + str);
};
stompClient.connect({}, function(frame) {
console.log('SockJS Connected: ' + frame);
document.getElementById('status').innerHTML = 'SockJS Verbunden';
addMessage('SockJS erfolgreich verbunden');
// Nachrichten abonnieren
stompClient.subscribe('/topic/messages', function(message) {
addMessage('Empfangen: ' + message.body);
});
}, function(error) {
console.error('SockJS Connection Error:', error);
document.getElementById('status').innerHTML = 'SockJS Fehler: ' + error;
addMessage('SockJS Fehler: ' + error);
});
}
function disconnect() {
if (stompClient !== null) {
stompClient.disconnect();
document.getElementById('status').innerHTML = 'Getrennt';
addMessage('Verbindung getrennt');
}
}
function sendMessage() {
const messageInput = document.getElementById('messageInput');
if (stompClient && messageInput.value) {
stompClient.send('/app/message', {}, JSON.stringify({
'content': messageInput.value,
'sender': 'WebTest'
}));
addMessage('Gesendet: ' + messageInput.value);
messageInput.value = '';
}
}
function addMessage(message) {
const messagesDiv = document.getElementById('messages');
const messageElement = document.createElement('div');
messageElement.innerHTML = new Date().toLocaleTimeString() + ': ' + message;
messagesDiv.appendChild(messageElement);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
</script>
<h2>Flutter Dart Code Beispiel:</h2>
<pre><code>
import 'package:stomp_dart_client/stomp.dart';
import 'package:stomp_dart_client/stomp_config.dart';
import 'package:stomp_dart_client/stomp_frame.dart';
import 'dart:convert';
// RICHTIG: ws:// für WebSocket verwenden
final stompClient = StompClient(
config: StompConfig(
url: 'ws://192.168.180.196:8080/websocket', // ws:// NICHT http://
onConnect: (StompFrame frame) {
print('Connected to STOMP server');
// Nachrichten abonnieren
stompClient.subscribe(
destination: '/topic/messages',
callback: (StompFrame frame) {
print('Received: ${frame.body}');
},
);
},
onWebSocketError: (dynamic error) => print('WebSocket Error: $error'),
onStompError: (StompFrame frame) => print('Stomp Error: ${frame.body}'),
onDisconnect: (StompFrame frame) => print('Disconnected'),
),
);
// Verbindung aktivieren
stompClient.activate();
// Nachricht senden
void sendMessage(String content) {
stompClient.send(
destination: '/app/message',
body: jsonEncode({
'content': content,
'sender': 'FlutterApp',
}),
);
}
</code></pre>
<h2>Verfügbare Endpunkte:</h2>
<ul>
<li><strong>ws://192.168.180.196:8080/websocket</strong> - Native WebSocket (empfohlen für Flutter)</li>
<li><strong>ws://192.168.180.196:8080/stomp</strong> - Alternative WebSocket Endpunkt</li>
<li><strong>http://192.168.180.196:8080/ws</strong> - SockJS Endpunkt (nur für Browser)</li>
</ul>
<h2>Häufiger Fehler:</h2>
<p><strong>FALSCH:</strong> <code>http://192.168.180.196:8080/websocket</code></p>
<p><strong>RICHTIG:</strong> <code>ws://192.168.180.196:8080/websocket</code></p>
</body>
</html>

259
backend/mvnw vendored Executable file
View File

@@ -0,0 +1,259 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.2
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

149
backend/mvnw.cmd vendored Normal file
View File

@@ -0,0 +1,149 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.2
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
if ($env:MAVEN_USER_HOME) {
$MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
}
$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

8626
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

110
backend/package.json Normal file
View File

@@ -0,0 +1,110 @@
{
"name": "no-name",
"license": "UNLICENSED",
"type": "module",
"dependencies": {
"@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.7.1",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/polymer-legacy-adapter": "24.7.1",
"@vaadin/react-components": "24.7.1",
"@vaadin/react-components-pro": "24.7.1",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.7.1",
"@vaadin/vaadin-material-styles": "24.7.1",
"@vaadin/vaadin-themable-mixin": "24.7.1",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"lit": "3.2.1",
"proj4": "2.15.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router": "7.2.0"
},
"devDependencies": {
"@babel/preset-react": "7.26.3",
"@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "4.3.4",
"async": "3.2.6",
"glob": "11.0.1",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.7.3",
"vite": "6.2.2",
"vite-plugin-checker": "0.8.0",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
},
"vaadin": {
"dependencies": {
"@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.7.1",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/polymer-legacy-adapter": "24.7.1",
"@vaadin/react-components": "24.7.1",
"@vaadin/react-components-pro": "24.7.1",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.7.1",
"@vaadin/vaadin-material-styles": "24.7.1",
"@vaadin/vaadin-themable-mixin": "24.7.1",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"lit": "3.2.1",
"proj4": "2.15.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router": "7.2.0"
},
"devDependencies": {
"@babel/preset-react": "7.26.3",
"@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "4.3.4",
"async": "3.2.6",
"glob": "11.0.1",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.7.3",
"vite": "6.2.2",
"vite-plugin-checker": "0.8.0",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
},
"hash": "45492b1b7298b15a429b43657e872572fe696eb0499802a234b6743768eb5105"
},
"overrides": {
"@vaadin/bundles": "$@vaadin/bundles",
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
"@vaadin/react-components": "$@vaadin/react-components",
"@vaadin/react-components-pro": "$@vaadin/react-components-pro",
"@vaadin/common-frontend": "$@vaadin/common-frontend",
"react-dom": "$react-dom",
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
"lit": "$lit",
"@polymer/polymer": "$@polymer/polymer",
"react": "$react",
"react-router": "$react-router",
"date-fns": "$date-fns",
"proj4": "$proj4",
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles"
}
}

367
backend/pom.xml Normal file
View File

@@ -0,0 +1,367 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.assecutor.votianlt</groupId>
<artifactId>votianlt</artifactId>
<version>${revision}</version>
<packaging>jar</packaging>
<properties>
<revision>0.9.13</revision>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<vaadin.version>24.7.0</vaadin.version>
<archunit.version>1.3.0</archunit.version>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.3</version>
<relativePath/>
</parent>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-bom</artifactId>
<version>${vaadin.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring AI BOM -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Vaadin -->
<dependency>
<groupId>com.vaadin</groupId>
<!-- Replace artifactId with vaadin-core to use only free components -->
<artifactId>vaadin</artifactId>
</dependency>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-spring-boot-starter</artifactId>
</dependency>
<!-- Additional Spring Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Spring WebSocket for messaging plugin -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<!-- Security Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.38</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.github.librepdf</groupId>
<artifactId>openpdf</artifactId>
<version>1.3.30</version>
</dependency>
<!-- Spring Boot Mail Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- .env file support -->
<dependency>
<groupId>me.paulschwarz</groupId>
<artifactId>spring-dotenv</artifactId>
<version>4.0.0</version>
</dependency>
<!-- Spring WebFlux for direct LLM API calls (like aimailassistant) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Jackson JSR310 module for Java 8 date/time support -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<!-- iText8 for PDF generation -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itext-core</artifactId>
<version>8.0.5</version>
<type>pom</type>
</dependency>
<!-- iText8 Kernel (PDF core functionality) -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>kernel</artifactId>
<version>8.0.5</version>
</dependency>
<!-- iText8 Layout (document layout) -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>layout</artifactId>
<version>8.0.5</version>
</dependency>
<!-- iText8 HTML to PDF converter -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>html2pdf</artifactId>
<version>5.0.5</version>
</dependency>
<!-- Spring AI OpenAI (LM Studio kompatibel) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- MCP Server mit WebMVC Transport -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<!-- Test Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<defaultGoal>spring-boot:run</defaultGoal>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
<release>21</release>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.38</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.43.0</version>
<configuration>
<java>
<eclipse>
<version>4.33</version>
<file>${project.basedir}/eclipse-formatter.xml</file>
</eclipse>
</java>
<typescript>
<includes>
<include>src/main/frontend/**/*.ts</include>
<include>src/main/frontend/**/*.tsx</include>
</includes>
<excludes>
<exclude>src/main/frontend/generated/**</exclude>
</excludes>
<prettier>
<prettierVersion>3.3.3</prettierVersion>
<configFile>${project.basedir}/.prettierrc.json</configFile>
</prettier>
</typescript>
</configuration>
</plugin>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.version}</version>
<executions>
<execution>
<goals>
<goal>prepare-frontend</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-wrapper-plugin</artifactId>
<version>3.3.2</version>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>application.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>application.properties</exclude>
</excludes>
</resource>
</resources>
</build>
<profiles>
<profile>
<id>production</id>
<dependencies>
<!-- Exclude development dependencies from production -->
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-core</artifactId>
<exclusions>
<exclusion>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-dev</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.version}</version>
<executions>
<execution>
<goals>
<goal>build-frontend</goal>
</goals>
<phase>compile</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>integration-test</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
<configuration>
<trimStackTrace>false</trimStackTrace>
<enableAssertions>true</enableAssertions>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
<repositories>
<repository>
<id>vaadin-directory</id>
<url>https://maven.vaadin.com/vaadin-addons</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>vaadin-prereleases</id>
<url>https://maven.vaadin.com/vaadin-prereleases</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>vaadin-prereleases</id>
<url>https://maven.vaadin.com/vaadin-prereleases</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</project>

View File

@@ -0,0 +1,32 @@
This directory is automatically generated by Vaadin and contains the pre-compiled
frontend files/resources for your project (frontend development bundle).
It should be added to Version Control System and committed, so that other developers
do not have to compile it again.
Frontend development bundle is automatically updated when needed:
- an npm/pnpm package is added with @NpmPackage or directly into package.json
- CSS, JavaScript or TypeScript files are added with @CssImport, @JsModule or @JavaScript
- Vaadin add-on with front-end customizations is added
- Custom theme imports/assets added into 'theme.json' file
- Exported web component is added.
If your project development needs a hot deployment of the frontend changes,
you can switch Flow to use Vite development server (default in Vaadin 23.3 and earlier versions):
- set `vaadin.frontend.hotdeploy=true` in `application.properties`
- configure `vaadin-maven-plugin`:
```
<configuration>
<frontendHotdeploy>true</frontendHotdeploy>
</configuration>
```
- configure `jetty-maven-plugin`:
```
<configuration>
<systemProperties>
<vaadin.frontend.hotdeploy>true</vaadin.frontend.hotdeploy>
</systemProperties>
</configuration>
```
Read more [about Vaadin development mode](https://vaadin.com/docs/next/flow/configuration/development-mode#precompiled-bundle).

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<!--
This file is auto-generated by Vaadin.
-->
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body, #outlet {
height: 100vh;
width: 100%;
margin: 0;
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
</head>
<body>
<!-- This outlet div is where the views are rendered -->
<div id="outlet"></div>
</body>
</html>

View File

@@ -0,0 +1,878 @@
// Invoice Generator - Native HTML5 Canvas Implementation
(function() {
'use strict';
class InvoiceGenerator {
constructor(containerId) {
this.canvas = null;
this.ctx = null;
this.elements = new Map();
this.selectedElement = null;
this.elementCounter = 0;
this.isDragging = false;
this.isResizing = false;
this.dragStart = { x: 0, y: 0 };
this.elementStart = { x: 0, y: 0, w: 0, h: 0 };
this.activeHandle = null;
this.container = null;
this.containerId = containerId || 'invoice-canvas-container';
// Page dimensions (A4 at 96 DPI)
this.pageX = 50;
this.pageY = 30;
this.pageWidth = 794;
this.pageHeight = 1123;
this.scale = 1;
// Handle configuration
this.handleSize = 10;
this.handlePadding = 3;
// Grid configuration (Snap to Grid)
this.gridSize = 5;
this.showGrid = true;
}
// Snap value to grid
snapToGrid(value) {
return Math.round(value / this.gridSize) * this.gridSize;
}
init() {
this.container = document.getElementById(this.containerId);
if (!this.container) {
console.error('Canvas container not found: ' + this.containerId);
return;
}
// Create canvas
this.canvas = document.createElement('canvas');
this.canvas.style.display = 'block';
this.updateCanvasSize();
this.ctx = this.canvas.getContext('2d');
this.container.innerHTML = '';
this.container.appendChild(this.canvas);
// Bind events
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
this.canvas.addEventListener('mouseleave', this.onMouseUp.bind(this));
// Keyboard navigation for selected element
this.canvas.setAttribute('tabindex', '0');
this.canvas.addEventListener('keydown', this.onKeyDown.bind(this));
window.addEventListener('resize', () => {
this.updateCanvasSize();
this.draw();
});
this.draw();
console.log('Invoice generator initialized');
}
updateCanvasSize() {
const rect = this.container.getBoundingClientRect();
this.canvas.width = rect.width;
this.canvas.height = rect.height;
// Calculate scale to maximize page size while maintaining aspect ratio
// Use full available space with minimal padding
const padding = 10;
const availableWidth = this.canvas.width - padding * 2;
const availableHeight = this.canvas.height - padding * 2;
// Scale to fit the available space (can be larger than 1 if container is bigger than A4)
this.scale = Math.min(availableWidth / this.pageWidth, availableHeight / this.pageHeight);
// Center the scaled page
const scaledPageWidth = this.pageWidth * this.scale;
const scaledPageHeight = this.pageHeight * this.scale;
this.pageX = (this.canvas.width - scaledPageWidth) / 2;
this.pageY = (this.canvas.height - scaledPageHeight) / 2;
}
getMousePos(e) {
const rect = this.canvas.getBoundingClientRect();
// Convert screen coordinates to canvas coordinates
const canvasX = e.clientX - rect.left;
const canvasY = e.clientY - rect.top;
// Convert to page coordinates (accounting for scale and offset)
return {
x: (canvasX - this.pageX) / this.scale,
y: (canvasY - this.pageY) / this.scale
};
}
// Check if point is inside element
hitTest(x, y, el) {
const w = el.width || 150;
const h = el.height || 30;
return x >= el.x && x <= el.x + w && y >= el.y && y <= el.y + h;
}
// Check if point is on a resize handle
getHandleAt(x, y, el) {
if (!el) return null;
const w = el.width || 150;
const h = el.height || 30;
const hs = this.handleSize + this.handlePadding;
// Define handle positions (center points)
const handles = {
'nw': { x: el.x, y: el.y },
'n': { x: el.x + w / 2, y: el.y },
'ne': { x: el.x + w, y: el.y },
'w': { x: el.x, y: el.y + h / 2 },
'e': { x: el.x + w, y: el.y + h / 2 },
'sw': { x: el.x, y: el.y + h },
's': { x: el.x + w / 2, y: el.y + h },
'se': { x: el.x + w, y: el.y + h }
};
for (const [name, pos] of Object.entries(handles)) {
const dx = x - pos.x;
const dy = y - pos.y;
if (Math.abs(dx) <= hs && Math.abs(dy) <= hs) {
return name;
}
}
return null;
}
onMouseDown(e) {
const pos = this.getMousePos(e);
// First check if clicking on a handle of selected element
if (this.selectedElement) {
const handle = this.getHandleAt(pos.x, pos.y, this.selectedElement);
if (handle) {
console.log('Resize handle clicked:', handle);
this.isResizing = true;
this.activeHandle = handle;
this.dragStart = { x: pos.x, y: pos.y };
this.elementStart = {
x: this.selectedElement.x,
y: this.selectedElement.y,
w: this.selectedElement.width || 100,
h: this.selectedElement.height || 30
};
return;
}
}
// Check if clicking on an element
let clickedElement = null;
for (const el of Array.from(this.elements.values()).reverse()) {
if (this.hitTest(pos.x, pos.y, el)) {
clickedElement = el;
break;
}
}
if (clickedElement) {
this.selectElement(clickedElement.id);
this.isDragging = true;
this.dragStart = { x: pos.x, y: pos.y };
this.elementStart = { x: clickedElement.x, y: clickedElement.y };
this.canvas.style.cursor = 'move';
} else {
this.deselectAll();
}
this.draw();
}
onMouseMove(e) {
const pos = this.getMousePos(e);
if (this.isResizing && this.selectedElement) {
this.doResize(pos.x, pos.y);
return;
}
if (this.isDragging && this.selectedElement) {
const dx = pos.x - this.dragStart.x;
const dy = pos.y - this.dragStart.y;
let newX = this.elementStart.x + dx;
let newY = this.elementStart.y + dy;
// Constrain to page (in page coordinates, page starts at 0,0)
newX = Math.max(0, Math.min(newX,
this.pageWidth - (this.selectedElement.width || 100)));
newY = Math.max(0, Math.min(newY,
this.pageHeight - (this.selectedElement.height || 30)));
// Snap to grid
this.selectedElement.x = this.snapToGrid(newX);
this.selectedElement.y = this.snapToGrid(newY);
this.draw();
this.notifyChange();
return;
}
// Update cursor
if (this.selectedElement) {
const handle = this.getHandleAt(pos.x, pos.y, this.selectedElement);
if (handle) {
const cursors = {
'nw': 'nw-resize', 'ne': 'ne-resize', 'sw': 'sw-resize', 'se': 'se-resize',
'n': 'ns-resize', 's': 'ns-resize', 'e': 'ew-resize', 'w': 'ew-resize'
};
this.canvas.style.cursor = cursors[handle] || 'default';
return;
}
}
// Check hover over elements
let hovering = false;
for (const el of Array.from(this.elements.values()).reverse()) {
if (this.hitTest(pos.x, pos.y, el)) {
hovering = true;
break;
}
}
this.canvas.style.cursor = hovering ? 'move' : 'default';
}
onMouseUp(e) {
this.isDragging = false;
this.isResizing = false;
this.activeHandle = null;
this.canvas.style.cursor = 'default';
}
onKeyDown(e) {
if (!this.selectedElement) return;
const step = this.gridSize; // Move by grid size (5px)
let moved = false;
switch(e.key) {
case 'ArrowUp':
this.selectedElement.y = Math.max(0, this.selectedElement.y - step);
moved = true;
e.preventDefault();
break;
case 'ArrowDown':
this.selectedElement.y = Math.min(
this.pageHeight - (this.selectedElement.height || 30),
this.selectedElement.y + step
);
moved = true;
e.preventDefault();
break;
case 'ArrowLeft':
this.selectedElement.x = Math.max(0, this.selectedElement.x - step);
moved = true;
e.preventDefault();
break;
case 'ArrowRight':
this.selectedElement.x = Math.min(
this.pageWidth - (this.selectedElement.width || 100),
this.selectedElement.x + step
);
moved = true;
e.preventDefault();
break;
}
if (moved) {
this.draw();
this.notifyChange();
}
}
doResize(mouseX, mouseY) {
if (!this.selectedElement || !this.activeHandle) return;
const el = this.selectedElement;
el.manuallyResized = true; // Mark as manually resized
const start = this.elementStart;
const minSize = 20;
// Calculate new dimensions based on handle
switch (this.activeHandle) {
case 'se':
el.width = Math.max(minSize, mouseX - start.x);
el.height = Math.max(minSize, mouseY - start.y);
break;
case 'sw':
const newWsw = start.w + (start.x - mouseX);
if (newWsw >= minSize) {
el.x = mouseX;
el.width = newWsw;
}
el.height = Math.max(minSize, mouseY - start.y);
break;
case 'ne':
el.width = Math.max(minSize, mouseX - start.x);
const newHne = start.h + (start.y - mouseY);
if (newHne >= minSize) {
el.y = mouseY;
el.height = newHne;
}
break;
case 'nw':
const newWnw = start.w + (start.x - mouseX);
const newHnw = start.h + (start.y - mouseY);
if (newWnw >= minSize) {
el.x = mouseX;
el.width = newWnw;
}
if (newHnw >= minSize) {
el.y = mouseY;
el.height = newHnw;
}
break;
case 'e':
el.width = Math.max(minSize, mouseX - start.x);
break;
case 'w':
const newWw = start.w + (start.x - mouseX);
if (newWw >= minSize) {
el.x = mouseX;
el.width = newWw;
}
break;
case 's':
el.height = Math.max(minSize, mouseY - start.y);
break;
case 'n':
const newHn = start.h + (start.y - mouseY);
if (newHn >= minSize) {
el.y = mouseY;
el.height = newHn;
}
break;
}
this.draw();
this.notifyChange();
}
notifyChange() {
if (this.selectedElement && window.invoiceGeneratorView?.$server) {
const el = this.selectedElement;
window.invoiceGeneratorView.$server.updatePropertiesPanel(
el.id, el.type, el.text || '', el.x, el.y, el.fontSize || 14, el.color || '#000000'
);
}
}
draw() {
if (!this.ctx) return;
const ctx = this.ctx;
// Clear
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Background
ctx.fillStyle = '#e8e8e8';
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
ctx.save();
// Apply scale and offset
ctx.translate(this.pageX, this.pageY);
ctx.scale(this.scale, this.scale);
// Page shadow (drawn before scaling offset)
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillRect(this.pageX + 4 * this.scale, this.pageY + this.pageHeight * this.scale, this.pageWidth * this.scale, 4);
ctx.fillRect(this.pageX + this.pageWidth * this.scale, this.pageY + 4 * this.scale, 4, this.pageHeight * this.scale);
ctx.restore();
// Page
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, this.pageWidth, this.pageHeight);
ctx.strokeStyle = '#cccccc';
ctx.lineWidth = 1 / this.scale;
ctx.strokeRect(0, 0, this.pageWidth, this.pageHeight);
// Draw grid if enabled
if (this.showGrid) {
this.drawGrid(ctx);
}
// Draw elements
this.elements.forEach(el => this.drawElement(el));
// Draw selection
if (this.selectedElement) {
this.drawSelection(this.selectedElement);
}
ctx.restore();
}
drawImagePlaceholder(ctx, el) {
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(el.x, el.y, el.width || 100, el.height || 100);
ctx.strokeStyle = '#999999';
ctx.lineWidth = 1;
ctx.strokeRect(el.x, el.y, el.width || 100, el.height || 100);
ctx.fillStyle = '#666666';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Bild', el.x + (el.width || 100) / 2, el.y + (el.height || 100) / 2);
}
drawElement(el) {
const ctx = this.ctx;
ctx.save();
switch (el.type) {
case 'line':
ctx.strokeStyle = el.color || '#333333';
ctx.lineWidth = el.strokeWidth || 1;
ctx.beginPath();
ctx.moveTo(el.x, el.y);
ctx.lineTo(el.x + (el.width || 200), el.y);
ctx.stroke();
break;
case 'vline':
ctx.strokeStyle = el.color || '#333333';
ctx.lineWidth = el.strokeWidth || 1;
ctx.beginPath();
ctx.moveTo(el.x, el.y);
ctx.lineTo(el.x, el.y + (el.height || 200));
ctx.stroke();
break;
case 'image':
if (el.imageData) {
// Draw actual image
const img = el.imageObj;
if (img && img.complete) {
// Draw image scaled to fit element while maintaining aspect ratio
const imgAspect = img.width / img.height;
const elAspect = (el.width || 100) / (el.height || 100);
let drawWidth, drawHeight, drawX, drawY;
if (imgAspect > elAspect) {
// Image is wider than element
drawWidth = el.width || 100;
drawHeight = drawWidth / imgAspect;
drawX = el.x;
drawY = el.y + ((el.height || 100) - drawHeight) / 2;
} else {
// Image is taller than element
drawHeight = el.height || 100;
drawWidth = drawHeight * imgAspect;
drawX = el.x + ((el.width || 100) - drawWidth) / 2;
drawY = el.y;
}
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
// Draw border
ctx.strokeStyle = '#cccccc';
ctx.lineWidth = 1;
ctx.strokeRect(el.x, el.y, el.width || 100, el.height || 100);
} else {
// Image still loading or failed
this.drawImagePlaceholder(ctx, el);
}
} else {
// No image uploaded yet - draw placeholder
this.drawImagePlaceholder(ctx, el);
}
break;
default: // text elements
this.drawTextElement(el, ctx);
}
ctx.restore();
}
// Wrap text to fit within maxWidth
wrapText(ctx, text, maxWidth) {
const words = text.split(' ');
const lines = [];
let currentLine = words[0];
for (let i = 1; i < words.length; i++) {
const word = words[i];
const width = ctx.measureText(currentLine + ' ' + word).width;
if (width < maxWidth) {
currentLine += ' ' + word;
} else {
lines.push(currentLine);
currentLine = word;
}
}
lines.push(currentLine);
return lines;
}
drawTextElement(el, ctx) {
ctx.font = `${el.fontStyle || ''} ${el.fontSize || 14}px ${el.fontFamily || 'Arial'}`;
ctx.fillStyle = el.color || '#333333';
ctx.textBaseline = 'top';
const maxWidth = el.width || 150;
const lineHeight = (el.fontSize || 14) * 1.2;
const maxHeight = el.height || 1000;
// Calculate max lines that fit
const maxLines = Math.floor(maxHeight / lineHeight);
// Split by explicit newlines first, then wrap each line
const explicitLines = (el.text || '').split('\n');
const allLines = [];
for (const line of explicitLines) {
const metrics = ctx.measureText(line);
if (metrics.width <= maxWidth) {
allLines.push(line);
} else {
const wrapped = this.wrapText(ctx, line, maxWidth);
allLines.push(...wrapped);
}
}
// Determine if we need ellipsis
const needsEllipsis = allLines.length > maxLines;
const linesToDraw = needsEllipsis ? maxLines : allLines.length;
// Draw lines
let y = el.y;
for (let i = 0; i < linesToDraw; i++) {
const line = allLines[i];
// If this is the last line and we need ellipsis
if (needsEllipsis && i === linesToDraw - 1) {
const ellipsis = '...';
// Check if the line with ellipsis would fit
if (ctx.measureText(line + ellipsis).width <= maxWidth) {
ctx.fillText(line + ellipsis, el.x, y);
} else {
// Need to trim the line to fit ellipsis
let trimmed = line;
while (trimmed.length > 0 && ctx.measureText(trimmed + ellipsis).width > maxWidth) {
trimmed = trimmed.slice(0, -1);
}
ctx.fillText(trimmed + ellipsis, el.x, y);
}
} else {
ctx.fillText(line, el.x, y);
}
y += lineHeight;
}
// Update height based on actual content if not manually resized
if (!el.manuallyResized) {
el.height = Math.max(lineHeight, allLines.length * lineHeight);
}
}
drawSelection(el) {
const ctx = this.ctx;
const x = el.x;
const y = el.y;
const w = el.width || 150;
const h = el.height || 30;
const hs = this.handleSize / this.scale;
// Selection border
ctx.strokeStyle = '#1976d2';
ctx.lineWidth = 2;
ctx.setLineDash([5, 3]);
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
// Handles
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#1976d2';
ctx.lineWidth = 2;
const positions = [
[x - hs/2, y - hs/2], // nw
[x + w/2 - hs/2, y - hs/2], // n
[x + w - hs/2, y - hs/2], // ne
[x - hs/2, y + h/2 - hs/2], // w
[x + w - hs/2, y + h/2 - hs/2], // e
[x - hs/2, y + h - hs/2], // sw
[x + w/2 - hs/2, y + h - hs/2], // s
[x + w - hs/2, y + h - hs/2] // se
];
positions.forEach(([hx, hy]) => {
ctx.fillRect(hx, hy, hs, hs);
ctx.strokeRect(hx, hy, hs, hs);
});
}
drawGrid(ctx) {
ctx.save();
ctx.strokeStyle = 'rgba(200, 200, 200, 0.3)';
ctx.lineWidth = 0.5 / this.scale;
// Vertical lines (draw in page coordinates)
for (let x = 0; x <= this.pageWidth; x += this.gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, this.pageHeight);
ctx.stroke();
}
// Horizontal lines
for (let y = 0; y <= this.pageHeight; y += this.gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(this.pageWidth, y);
ctx.stroke();
}
ctx.restore();
}
addElement(type, label, dropX, dropY) {
this.elementCounter++;
const id = `element-${this.elementCounter}`;
// dropX and dropY are in container coordinates, convert to page coordinates
const pageCoordX = (dropX - this.pageX) / this.scale;
const pageCoordY = (dropY - this.pageY) / this.scale;
// Snap initial position to grid (relative to page origin)
const rawX = Math.max(10, pageCoordX - 50);
const rawY = Math.max(10, pageCoordY - 15);
const x = this.snapToGrid(rawX);
const y = this.snapToGrid(rawY);
let el = {
id, type, x, y,
width: 150,
height: 30,
fontSize: 14,
fontFamily: 'Arial',
color: '#333333'
};
switch (type) {
case 'text':
el.text = 'Text eingeben...';
el.height = 20;
break;
case 'header':
el.text = 'Überschrift';
el.fontSize = 24;
el.fontStyle = 'bold';
el.color = '#000000';
el.width = 200;
el.height = 30;
break;
case 'date':
el.text = `Datum: ${new Date().toLocaleDateString('de-DE')}`;
el.fontSize = 12;
el.color = '#666666';
el.height = 16;
break;
case 'customer':
case 'company':
el.text = type === 'customer' ? 'Kundenname\nStraße Nr.\nPLZ Ort' : 'Ihr Unternehmen\nIhre Straße\nIhre PLZ Ort';
el.height = 50;
el.fontSize = 12;
break;
case 'amount':
el.text = 'Gesamtbetrag: 0,00 €';
el.fontStyle = 'bold';
el.width = 180;
el.height = 20;
break;
case 'line':
el.text = '';
el.width = 200;
el.height = 2;
break;
case 'vline':
el.text = '';
el.width = 2;
el.height = 200;
break;
case 'image':
el.text = 'Bild';
el.width = 100;
el.height = 100;
break;
default:
el.text = label || 'Neues Element';
el.height = 20;
}
this.elements.set(id, el);
this.selectElement(id);
this.draw();
}
selectElement(id) {
this.selectedElement = this.elements.get(id) || null;
this.draw();
if (this.selectedElement) {
this.notifyChange();
// Focus canvas for keyboard navigation
if (this.canvas) {
this.canvas.focus();
}
}
}
deselectAll() {
this.selectedElement = null;
this.draw();
if (window.invoiceGeneratorView?.$server) {
window.invoiceGeneratorView.$server.resetPropertiesPanel();
}
}
updateElementText(id, text) {
const el = this.elements.get(id);
if (el) {
el.text = text;
this.draw();
}
}
updateElementPosition(id, x, y) {
const el = this.elements.get(id);
if (el) {
if (x !== null) el.x = this.snapToGrid(x);
if (y !== null) el.y = this.snapToGrid(y);
this.draw();
}
}
updateElementFontSize(id, size) {
const el = this.elements.get(id);
if (el) {
el.fontSize = size;
this.draw();
}
}
updateElementColor(id, color) {
const el = this.elements.get(id);
if (el) {
el.color = color;
this.draw();
}
}
updateElementImage(id, imageDataUrl) {
const el = this.elements.get(id);
if (el && el.type === 'image') {
el.imageData = imageDataUrl;
// Create image object
const img = new Image();
img.onload = () => {
el.imageObj = img;
this.draw();
};
img.onerror = () => {
console.error('Failed to load image');
el.imageData = null;
el.imageObj = null;
this.draw();
};
img.src = imageDataUrl;
}
}
deleteElement(id) {
this.elements.delete(id);
if (this.selectedElement?.id === id) {
this.selectedElement = null;
}
this.draw();
}
clearCanvas() {
this.elements.clear();
this.selectedElement = null;
this.draw();
}
getCanvasData() {
return {
elements: Array.from(this.elements.values())
};
}
exportTemplate() {
const data = JSON.stringify(this.getCanvasData(), null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'template.json';
a.click();
URL.revokeObjectURL(url);
}
exportTemplateJson() {
// Return template data as JSON string for Java processing
const data = this.getCanvasData();
return JSON.stringify(data);
}
generatePreview() {
const temp = document.createElement('canvas');
temp.width = this.pageWidth;
temp.height = this.pageHeight;
const tctx = temp.getContext('2d');
tctx.fillStyle = '#ffffff';
tctx.fillRect(0, 0, temp.width, temp.height);
// Draw elements offset by page position
const originalPageX = this.pageX;
const originalPageY = this.pageY;
this.pageX = 0;
this.pageY = 0;
this.elements.forEach(el => {
const ox = el.x, oy = el.y;
el.x = ox - originalPageX;
el.y = oy - originalPageY;
this.drawElement.call({ ctx: tctx, elements: this.elements }, el);
el.x = ox;
el.y = oy;
});
this.pageX = originalPageX;
this.pageY = originalPageY;
const win = window.open();
if (win) {
win.document.write(`<img src="${temp.toDataURL()}" style="max-width:100%">`);
}
}
}
window.invoiceGenerator = new InvoiceGenerator();
window.InvoiceGenerator = InvoiceGenerator;
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
/* Breite des linken Menüs (Drawer) */
vaadin-app-layout {
--vaadin-app-layout-drawer-width: 286px;
}

View File

@@ -0,0 +1,9 @@
{
"lumoImports": [
"typography",
"color",
"spacing",
"badge",
"utility"
]
}

View File

@@ -0,0 +1,5 @@
[part="tabs-container"] {
background: white;
border-radius: 24px 24px 0 0;
border-bottom: 1px solid #e0e0e0;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
{
"lumoImports": [
"typography",
"color",
"spacing",
"badge",
"utility"
]
}

View File

@@ -0,0 +1,56 @@
/**
* Cookie names for language preference
*/
const LANGUAGE_COOKIE_NAME = 'votianlt.language';
const COOKIE_MAX_AGE_DAYS = 365; // Cookie gültig für 1 Jahr
/**
* Sets the language cookie with the selected language code
* @param languageCode - The language code (e.g., 'de', 'en', 'fr', 'es')
*/
export function setLanguageCookie(languageCode: string): void {
const maxAge = COOKIE_MAX_AGE_DAYS * 24 * 60 * 60; // Convert days to seconds
document.cookie = `${LANGUAGE_COOKIE_NAME}=${languageCode};path=/;max-age=${maxAge};SameSite=Lax`;
}
/**
* Gets the language code from the cookie
* @returns The language code or null if not found
*/
export function getLanguageCookie(): string | null {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === LANGUAGE_COOKIE_NAME) {
return decodeURIComponent(value);
}
}
return null;
}
/**
* Clears the language cookie
*/
export function clearLanguageCookie(): void {
document.cookie = `${LANGUAGE_COOKIE_NAME}=;path=/;max-age=0;SameSite=Lax`;
}
/**
* Maps Language enum values to locale strings
*/
export const languageToLocale: Record<string, string> = {
DE: 'de',
EN: 'en',
FR: 'fr',
ES: 'es',
};
/**
* Maps locale strings to Language enum values
*/
export const localeToLanguage: Record<string, string> = {
de: 'DE',
en: 'EN',
fr: 'FR',
es: 'ES',
};

View File

@@ -0,0 +1,30 @@
package de.assecutor.votianlt;
import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.component.page.Push;
import com.vaadin.flow.theme.Theme;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.time.Clock;
@SpringBootApplication
@EnableAsync
@EnableScheduling
@Theme("votian-modern")
@Push
public class Application implements AppShellConfigurator {
@Bean
public Clock clock() {
return Clock.systemDefaultZone(); // You can also use Clock.systemUTC()
}
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

View File

@@ -0,0 +1,124 @@
package de.assecutor.votianlt.ai.config;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.util.Base64;
/**
* Configuration for LLM integration via LM Studio. The LM Studio instance
* exposes an OpenAI-compatible API at {@code /v1/chat/completions}.
*/
@Configuration
@Slf4j
public class LlmConfig {
@Value("${app.ai.lmstudio.base-url}")
private String lmstudioBaseUrl;
@Value("${app.ai.lmstudio.model}")
private String lmstudioModel;
@Value("${app.ai.lmstudio.htaccess-username}")
private String lmstudioHtaccessUsername;
@Value("${app.ai.lmstudio.htaccess-password}")
private String lmstudioHtaccessPassword;
@PostConstruct
public void logConfig() {
log.info("=== LLM Configuration ===");
log.info("Provider: lmstudio");
log.info("Base URL: {}", lmstudioBaseUrl);
log.info("Model: {}", lmstudioModel);
log.info("HTACCESS auth: {}", hasHtaccessCredentials() ? "configured" : "not configured");
testConnection(lmstudioBaseUrl, lmstudioModel);
}
private void testConnection(String baseUrl, String model) {
log.info("Testing LLM connection to: {}", baseUrl);
// Test 1: Models endpoint
testEndpoint(baseUrl + "/v1/models", "GET", null);
// Test 2: Chat completions (no streaming)
String testPayload = "{\"model\":\"" + model
+ "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}";
testEndpoint(baseUrl + "/v1/chat/completions", "POST", testPayload);
}
private void testEndpoint(String endpoint, String method, String payload) {
try {
log.info("Testing endpoint: {} {}", method, endpoint);
URL url = URI.create(endpoint).toURL();
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod(method);
connection.setConnectTimeout(5000);
connection.setReadTimeout(10000);
if (hasHtaccessCredentials()) {
String credentials = lmstudioHtaccessUsername + ":" + lmstudioHtaccessPassword;
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes());
connection.setRequestProperty("Authorization", "Basic " + encoded);
}
if (payload != null) {
connection.setDoOutput(true);
connection.setRequestProperty("Content-Type", "application/json");
try (var os = connection.getOutputStream()) {
os.write(payload.getBytes());
}
}
int responseCode = connection.getResponseCode();
String responseMessage = connection.getResponseMessage();
if (responseCode >= 200 && responseCode < 300) {
log.info(" -> SUCCESS (HTTP {} {})", responseCode, responseMessage);
} else {
String errorBody = "";
try (var is = connection.getErrorStream()) {
if (is != null) {
errorBody = new String(is.readAllBytes());
}
}
log.warn(" -> HTTP {} {} - {}", responseCode, responseMessage, errorBody);
}
connection.disconnect();
} catch (java.net.ConnectException e) {
log.error(" -> FAILED - Connection refused: {}", e.getMessage());
} catch (java.net.SocketTimeoutException e) {
log.error(" -> FAILED - Timeout: {}", e.getMessage());
} catch (java.net.UnknownHostException e) {
log.error(" -> FAILED - Unknown host: {}", e.getMessage());
} catch (Exception e) {
log.error(" -> FAILED: {} - {}", e.getClass().getSimpleName(), e.getMessage());
}
}
public String getBaseUrl() {
return lmstudioBaseUrl;
}
public String getModel() {
return lmstudioModel;
}
public boolean hasHtaccessCredentials() {
return lmstudioHtaccessUsername != null && !lmstudioHtaccessUsername.isBlank()
&& lmstudioHtaccessPassword != null && !lmstudioHtaccessPassword.isBlank();
}
public String getLmstudioHtaccessUsername() {
return lmstudioHtaccessUsername;
}
public String getLmstudioHtaccessPassword() {
return lmstudioHtaccessPassword;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
package de.assecutor.votianlt.ai.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Direct REST client for LM Studio LLM API. Communicates via the
* OpenAI-compatible /v1/chat/completions endpoint.
*/
@Component
@Slf4j
public class LlmRestClient {
private static final Pattern THINK_BLOCK_PATTERN = Pattern.compile("(?is)<think>.*?</think>");
private static final Pattern THINK_TAG_PATTERN = Pattern.compile("(?is)</?think>");
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final String model;
public LlmRestClient(@Value("${app.ai.lmstudio.base-url}") String lmstudioBaseUrl,
@Value("${app.ai.lmstudio.model}") String lmstudioModel,
@Value("${app.ai.lmstudio.htaccess-username}") String lmstudioHtaccessUsername,
@Value("${app.ai.lmstudio.htaccess-password}") String lmstudioHtaccessPassword, ObjectMapper objectMapper) {
this.model = lmstudioModel;
this.objectMapper = objectMapper;
WebClient.Builder builder = WebClient.builder();
builder.baseUrl(lmstudioBaseUrl + "/v1/chat/completions");
if (lmstudioHtaccessUsername != null && !lmstudioHtaccessUsername.isBlank() && lmstudioHtaccessPassword != null
&& !lmstudioHtaccessPassword.isBlank()) {
String credentials = lmstudioHtaccessUsername + ":" + lmstudioHtaccessPassword;
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
builder.defaultHeader(HttpHeaders.AUTHORIZATION, "Basic " + encoded);
log.info("LlmRestClient initialized (with HTACCESS auth) - URL: {}/v1/chat/completions, Model: {}",
lmstudioBaseUrl, lmstudioModel);
} else {
log.info("LlmRestClient initialized - URL: {}/v1/chat/completions, Model: {}", lmstudioBaseUrl,
lmstudioModel);
}
this.webClient = builder.build();
}
/**
* Send a chat completion request.
*
* @param systemPrompt
* System prompt for context
* @param userMessage
* User message/question
* @return LLM response text, or null on error
*/
public String chat(String systemPrompt, String userMessage) {
return chat(systemPrompt, userMessage, 0.7, 2000);
}
/**
* Send a chat completion request with custom parameters.
*
* @param systemPrompt
* System prompt for context
* @param userMessage
* User message/question
* @param temperature
* Temperature for response randomness (0.0-1.0)
* @param maxTokens
* Maximum tokens in response
* @return LLM response text, or null on error
*/
public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) {
try {
Map<String, Object> request = Map.of("model", model, "messages",
List.of(Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""),
Map.of("role", "user", "content", userMessage)),
"temperature", temperature, "max_tokens", maxTokens, "stream", false);
log.info("Sending request to LLM (model: {}, prompt length: {} chars)...", model, userMessage.length());
long startTime = System.currentTimeMillis();
String response = webClient.post().contentType(MediaType.APPLICATION_JSON).bodyValue(request).retrieve()
.bodyToMono(String.class).timeout(Duration.ofSeconds(120)).block();
long duration = System.currentTimeMillis() - startTime;
log.info("LLM response received in {}ms", duration);
log.debug("LLM response payload received ({} chars)", response != null ? response.length() : 0);
return extractContent(response);
} catch (Exception e) {
log.error("Error calling LLM API: {} - {}", e.getClass().getSimpleName(), e.getMessage());
if (log.isDebugEnabled()) {
log.debug("Full stack trace:", e);
}
return null;
}
}
/**
* Simple chat without system prompt.
*/
public String chat(String userMessage) {
return chat(null, userMessage);
}
public String getModel() {
return model;
}
private String extractContent(String response) {
if (response == null || response.isBlank()) {
log.warn("LLM returned null or blank response");
return null;
}
try {
JsonNode root = objectMapper.readTree(response);
JsonNode choices = root.path("choices");
if (choices.isArray() && !choices.isEmpty()) {
String content = choices.get(0).path("message").path("content").asText();
if (content == null || content.isBlank()) {
log.warn("LLM response content is empty");
return null;
}
String sanitizedContent = sanitizeAssistantContent(content);
if (sanitizedContent.isBlank()) {
log.warn("LLM response content is empty after sanitization");
return null;
}
return sanitizedContent;
}
log.warn("Unexpected response structure (no choices): {}", response);
return null;
} catch (Exception e) {
log.error("Error parsing LLM response: {}", e.getMessage());
return null;
}
}
private String sanitizeAssistantContent(String content) {
String sanitized = THINK_BLOCK_PATTERN.matcher(content).replaceAll(" ");
sanitized = THINK_TAG_PATTERN.matcher(sanitized).replaceAll(" ");
sanitized = sanitized.replace("\r", "");
sanitized = sanitized.replaceAll("[ \\t]+", " ");
sanitized = sanitized.replaceAll("\\n{3,}", "\n\n");
return sanitized.trim();
}
}

View File

@@ -0,0 +1,69 @@
package de.assecutor.votianlt.config;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.service.DemoModeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.Set;
@Component
public class DataInitializer implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final DemoModeService demoModeService;
public DataInitializer(UserRepository userRepository, PasswordEncoder passwordEncoder,
DemoModeService demoModeService) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.demoModeService = demoModeService;
}
@Override
public void run(String... args) throws Exception {
initializeTestUsers();
demoModeService.ensureDemoUser();
}
private void initializeTestUsers() {
log.info("Initializing test users...");
// Admin User
if (!userRepository.existsByEmail("admin@votianlt.de")) {
User adminUser = new User();
adminUser.setEmail("admin@votianlt.de");
adminUser.setPassword(passwordEncoder.encode("admin123"));
adminUser.setName("Administrator");
adminUser.setFirstname("Admin");
adminUser.setIsActivated((byte) 1);
adminUser.setIsEmailConfirmed((byte) 1);
adminUser.setCreatedAt(LocalDateTime.now());
adminUser.setUpdatedAt(LocalDateTime.now());
adminUser.setRoles(Set.of("USER", "ADMIN"));
adminUser.setTwoFactorEnabled(false); // 2FA deaktiviert für Admin
userRepository.save(adminUser);
log.info("Created admin user: admin@votianlt.de / admin123 (2FA enabled)");
} else {
// Stelle sicher, dass bestehender Admin 2FA deaktiviert hat
userRepository.findByEmail("admin@votianlt.de").ifPresent(adminUser -> {
if (adminUser.isTwoFactorEnabled()) {
adminUser.setTwoFactorEnabled(false);
userRepository.save(adminUser);
log.info("Updated admin user: 2FA disabled");
}
});
}
log.info("Test users initialization completed.");
}
}

View File

@@ -0,0 +1,28 @@
package de.assecutor.votianlt.config;
import com.vaadin.flow.server.VaadinServiceInitListener;
import de.assecutor.votianlt.service.DemoModeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class DemoSessionCleanupConfig {
private static final Logger log = LoggerFactory.getLogger(DemoSessionCleanupConfig.class);
@Bean
public VaadinServiceInitListener demoSessionCleanupListener(DemoModeService demoModeService) {
return event -> event.getSource().addSessionDestroyListener(sessionDestroyEvent -> {
try {
var wrappedSession = sessionDestroyEvent.getSession().getSession();
if (wrappedSession != null) {
demoModeService.cleanupAndReleaseIfOwned(wrappedSession.getId());
}
} catch (Exception ex) {
log.warn("Demo session destroy cleanup failed: {}", ex.getMessage(), ex);
}
});
}
}

View File

@@ -0,0 +1,30 @@
package de.assecutor.votianlt.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/**
* Jackson configuration for consistent JSON serialization across the
* application. Ensures all date/time fields are serialized as ISO 8601 strings.
*/
@Configuration
public class JacksonConfig {
/**
* Creates a configured ObjectMapper bean that serializes dates as ISO 8601
* strings. This bean is used throughout the application for JSON serialization.
*/
@Bean
@Primary
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
// Serialize dates as ISO 8601 strings instead of timestamps/arrays
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return objectMapper;
}
}

View File

@@ -0,0 +1,172 @@
package de.assecutor.votianlt.config;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.server.ServiceInitEvent;
import com.vaadin.flow.server.VaadinServiceInitListener;
import de.assecutor.votianlt.model.Language;
import de.assecutor.votianlt.security.CustomUserPrincipal;
import jakarta.servlet.http.Cookie;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.util.Locale;
/**
* Sets the user's preferred locale on the UI BEFORE any layout or view is
* constructed. Registered via {@code UIInitListener} →
* {@code BeforeEnterListener}, which fires prior to the router creating the
* layout component tree.
*
* For authenticated users: Uses the language preference from the user profile.
* For anonymous users: Uses the language from the 'votianlt.language' cookie or
* falls back to the browser's preferred locale.
*/
@Component
@Slf4j
public class LocaleVaadinInitListener implements VaadinServiceInitListener {
private static final String LANGUAGE_COOKIE_NAME = "votianlt.language";
@Override
public void serviceInit(ServiceInitEvent event) {
event.getSource().addUIInitListener(uiInitEvent -> {
UI ui = uiInitEvent.getUI();
ui.addBeforeEnterListener(beforeEnterEvent -> applyLocale(ui));
});
}
private void applyLocale(UI ui) {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) {
// Authenticated user: use profile language
applyLocaleFromAuthenticatedUser(ui, auth);
} else {
// Anonymous user: use cookie or browser locale
applyLocaleFromCookieOrBrowser(ui);
}
} catch (Exception e) {
log.debug("Could not apply locale: {}", e.getMessage());
}
}
private void applyLocaleFromAuthenticatedUser(UI ui, Authentication auth) {
if (!(auth.getPrincipal() instanceof CustomUserPrincipal cup)) {
return;
}
Language language = cup.getUser().getLanguage();
if (language == null) {
return;
}
Locale targetLocale = getLocaleFromLanguage(language);
if (!targetLocale.equals(ui.getLocale())) {
ui.setLocale(targetLocale);
log.debug("Locale set to {} for authenticated user {}", targetLocale, cup.getUsername());
}
}
private void applyLocaleFromCookieOrBrowser(UI ui) {
Locale targetLocale = null;
// Try to get locale from cookie first
String cookieLanguage = getLanguageFromCookie(ui);
if (cookieLanguage != null) {
targetLocale = getLocaleFromLanguageCode(cookieLanguage);
log.debug("Using locale {} from cookie for anonymous user", targetLocale);
}
// If no cookie, use browser's preferred locale if supported
if (targetLocale == null) {
targetLocale = getSupportedLocaleFromBrowser(ui);
if (targetLocale != null) {
log.debug("Using browser locale {} for anonymous user", targetLocale);
}
}
// Apply the locale if different from current
if (targetLocale != null && !targetLocale.equals(ui.getLocale())) {
ui.setLocale(targetLocale);
log.debug("Locale set to {} for anonymous user", targetLocale);
}
}
private String getLanguageFromCookie(UI ui) {
try {
var request = com.vaadin.flow.server.VaadinRequest.getCurrent();
if (request == null) {
return null;
}
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
for (Cookie cookie : cookies) {
if (LANGUAGE_COOKIE_NAME.equals(cookie.getName())) {
return cookie.getValue();
}
}
} catch (Exception e) {
log.debug("Could not read language cookie: {}", e.getMessage());
}
return null;
}
private Locale getSupportedLocaleFromBrowser(UI ui) {
// Get the browser's preferred locales
var locale = ui.getSession().getBrowser().getLocale();
if (locale == null) {
return null;
}
// Check if the browser locale is supported
String language = locale.getLanguage().toLowerCase();
return switch (language) {
case "de" -> Locale.GERMAN;
case "en" -> Locale.ENGLISH;
case "fr" -> Locale.FRENCH;
case "es" -> Locale.of("es", "ES");
default -> null; // Return null to use Vaadin's default
};
}
private Locale getLocaleFromLanguage(Language language) {
return switch (language) {
case DE -> Locale.GERMAN;
case EN -> Locale.ENGLISH;
case FR -> Locale.FRENCH;
case ES -> Locale.of("es", "ES");
case TR -> Locale.of("tr", "TR");
case PL -> Locale.of("pl", "PL");
case RU -> Locale.of("ru", "RU");
case EE -> Locale.of("et", "EE");
case LV -> Locale.of("lv", "LV");
case LT -> Locale.of("lt", "LT");
};
}
private Locale getLocaleFromLanguageCode(String languageCode) {
if (languageCode == null) {
return null;
}
return switch (languageCode.toUpperCase()) {
case "DE" -> Locale.GERMAN;
case "EN" -> Locale.ENGLISH;
case "FR" -> Locale.FRENCH;
case "ES" -> Locale.of("es", "ES");
case "TR" -> Locale.of("tr", "TR");
case "PL" -> Locale.of("pl", "PL");
case "RU" -> Locale.of("ru", "RU");
case "EE" -> Locale.of("et", "EE");
case "LV" -> Locale.of("lv", "LV");
case "LT" -> Locale.of("lt", "LT");
default -> null;
};
}
}

View File

@@ -0,0 +1,189 @@
package de.assecutor.votianlt.config;
import de.assecutor.votianlt.model.task.*;
import de.assecutor.votianlt.model.task.CommentTask;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.ReadingConverter;
import org.springframework.data.mongodb.core.convert.MongoCustomConversions;
import org.bson.Document;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import org.bson.types.ObjectId;
@Configuration
public class MongoConfig {
@Bean
public MongoCustomConversions customConversions() {
List<Converter<?, ?>> converters = new ArrayList<>();
converters.add(new DocumentToBaseTaskConverter());
return new MongoCustomConversions(converters);
}
@ReadingConverter
@Slf4j
public static class DocumentToBaseTaskConverter implements Converter<Document, BaseTask> {
@Override
public BaseTask convert(Document source) {
// Debug logging to see what's in the document
log.debug("Converting MongoDB document to BaseTask. Document keys: {}", source.keySet());
log.debug("Full document content: {}", source.toJson());
// Use _class field for type discrimination (MongoDB standard)
String className = source.getString("_class");
if (className == null) {
// Fallback to taskType field if _class is not present
String taskType = source.getString("taskType");
if (taskType == null) {
taskType = source.getString("task_type");
}
// Map taskType to class name
className = mapTaskTypeToClassName(taskType);
}
log.debug("Extracted className: '{}' from document", className);
BaseTask task;
switch (className) {
case "de.assecutor.votianlt.model.task.ConfirmationTask":
case "ConfirmationTask":
log.debug("Creating ConfirmationTask");
task = new ConfirmationTask();
if (source.containsKey("button_text")) {
((ConfirmationTask) task).setButtonText(source.getString("button_text"));
}
break;
case "de.assecutor.votianlt.model.task.SignatureTask":
case "SignatureTask":
log.debug("Creating SignatureTask");
task = new SignatureTask();
break;
case "de.assecutor.votianlt.model.task.PhotoTask":
case "PhotoTask":
log.debug("Creating PhotoTask");
task = new PhotoTask();
if (source.containsKey("min_photo_count")) {
((PhotoTask) task).setMinPhotoCount(source.getInteger("min_photo_count"));
}
if (source.containsKey("max_photo_count")) {
((PhotoTask) task).setMaxPhotoCount(source.getInteger("max_photo_count"));
}
break;
case "de.assecutor.votianlt.model.task.TodoListTask":
case "TodoListTask":
log.debug("Creating TodoListTask");
task = new TodoListTask();
if (source.containsKey("todo_items")) {
// Suppressing unchecked cast warning as MongoDB document structure is validated
@SuppressWarnings("unchecked")
List<String> todoItems = (List<String>) source.get("todo_items");
((TodoListTask) task).setTodoItems(todoItems);
}
break;
case "de.assecutor.votianlt.model.task.BarcodeTask":
case "BarcodeTask":
log.debug("Creating BarcodeTask");
task = new BarcodeTask();
if (source.containsKey("min_barcode_count")) {
((BarcodeTask) task).setMinBarcodeCount(source.getInteger("min_barcode_count"));
}
if (source.containsKey("max_barcode_count")) {
((BarcodeTask) task).setMaxBarcodeCount(source.getInteger("max_barcode_count"));
}
break;
case "de.assecutor.votianlt.model.task.CommentTask":
case "CommentTask":
log.debug("Creating CommentTask");
task = new CommentTask();
if (source.containsKey("comment_text")) {
((CommentTask) task).setCommentText(source.getString("comment_text"));
}
if (source.containsKey("required")) {
((CommentTask) task).setRequired(source.getBoolean("required", false));
}
break;
default:
log.warn("Unknown className '{}', falling back to ConfirmationTask", className);
task = new ConfirmationTask(); // fallback
break;
}
// Set common fields
if (source.containsKey("_id")) {
task.setId(source.getObjectId("_id"));
}
task.setStationId(readObjectId(source, "station_id"));
task.setJobId(readObjectId(source, "job_id"));
if (source.containsKey("station_order")) {
task.setStationOrder(source.getInteger("station_order"));
}
if (source.containsKey("task_order")) {
task.setTaskOrder(source.getInteger("task_order", 0));
}
if (source.containsKey("description")) {
task.setDescription(source.getString("description"));
}
if (source.containsKey("optional")) {
task.setOptional(source.getBoolean("optional", false));
}
if (source.containsKey("completed")) {
task.setCompleted(source.getBoolean("completed", false));
}
if (source.containsKey("completed_at") && source.get("completed_at") != null) {
Object completedAtObj = source.get("completed_at");
if (completedAtObj instanceof String) {
task.setCompletedAt(
LocalDateTime.parse((String) completedAtObj, DateTimeFormatter.ISO_LOCAL_DATE_TIME));
} else if (completedAtObj instanceof java.util.Date) {
task.setCompletedAt(((java.util.Date) completedAtObj).toInstant()
.atZone(java.time.ZoneId.systemDefault()).toLocalDateTime());
}
}
if (source.containsKey("completed_by")) {
task.setCompletedBy(source.getString("completed_by"));
}
return task;
}
private ObjectId readObjectId(Document source, String key) {
Object value = source.get(key);
if (value instanceof ObjectId objectId) {
return objectId;
}
if (value instanceof String stringValue && ObjectId.isValid(stringValue)) {
return new ObjectId(stringValue);
}
return null;
}
private String mapTaskTypeToClassName(String taskType) {
if (taskType == null) {
return "de.assecutor.votianlt.model.task.ConfirmationTask";
}
switch (taskType) {
case "CONFIRMATION":
return "de.assecutor.votianlt.model.task.ConfirmationTask";
case "SIGNATURE":
return "de.assecutor.votianlt.model.task.SignatureTask";
case "PHOTO":
return "de.assecutor.votianlt.model.task.PhotoTask";
case "TODOLIST":
return "de.assecutor.votianlt.model.task.TodoListTask";
case "BARCODE":
return "de.assecutor.votianlt.model.task.BarcodeTask";
case "COMMENT":
return "de.assecutor.votianlt.model.task.CommentTask";
default:
return "de.assecutor.votianlt.model.task.ConfirmationTask";
}
}
}
}

View File

@@ -0,0 +1,19 @@
package de.assecutor.votianlt.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* Separate configuration for PasswordEncoder to avoid circular dependencies
* with VaadinWebSecurity configuration.
*/
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,109 @@
package de.assecutor.votianlt.config;
import com.vaadin.flow.i18n.I18NProvider;
import de.assecutor.votianlt.model.Language;
import org.springframework.stereotype.Component;
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.ResourceBundle.Control;
@Component
public class TranslationProvider implements I18NProvider {
public static final String BUNDLE_PREFIX = "messages";
private static final Locale DEFAULT_LOCALE = Locale.GERMAN;
// Custom Control to map language codes to file names
private static final Control BUNDLE_CONTROL = new Control() {
@Override
public List<Locale> getCandidateLocales(String baseName, Locale locale) {
// Map Estonian "et" to "ee" file, Latvian "lv" to "lv", Lithuanian "lt" to "lt"
String language = locale.getLanguage();
// Create a locale that matches our file naming convention
Locale mappedLocale = switch (language) {
case "et" -> Locale.of("ee"); // Estonian -> messages_ee.properties
case "lv" -> Locale.of("lv"); // Latvian -> messages_lv.properties
case "lt" -> Locale.of("lt"); // Lithuanian -> messages_lt.properties
case "ru" -> Locale.of("ru"); // Russian -> messages_ru.properties
case "pl" -> Locale.of("pl"); // Polish -> messages_pl.properties
case "tr" -> Locale.of("tr"); // Turkish -> messages_tr.properties
case "es" -> Locale.of("es"); // Spanish -> messages_es.properties
case "fr" -> Locale.of("fr"); // French -> messages_fr.properties
case "en" -> Locale.of("en"); // English -> messages_en.properties
case "de" -> Locale.of("de"); // German -> messages_de.properties
default -> locale;
};
return super.getCandidateLocales(baseName, mappedLocale);
}
};
@Override
public List<Locale> getProvidedLocales() {
return Collections.unmodifiableList(Arrays.asList(Locale.GERMAN, Locale.ENGLISH, Locale.FRENCH,
Locale.of("es", "ES"), Locale.of("tr", "TR"), Locale.of("pl", "PL"), Locale.of("ru", "RU"),
Locale.of("et", "EE"), Locale.of("lv", "LV"), Locale.of("lt", "LT")));
}
@Override
public String getTranslation(String key, Locale locale, Object... params) {
if (key == null) {
return "";
}
try {
String value = findTranslation(key, locale);
if (value == null) {
return key;
}
if (params.length > 0) {
value = MessageFormat.format(value, params);
}
return value;
} catch (MissingResourceException e) {
return key;
}
}
private String findTranslation(String key, Locale locale) {
Locale effectiveLocale = locale != null ? locale : DEFAULT_LOCALE;
ResourceBundle localizedBundle = ResourceBundle.getBundle(BUNDLE_PREFIX, effectiveLocale, BUNDLE_CONTROL);
if (localizedBundle.containsKey(key)) {
return localizedBundle.getString(key);
}
if (!DEFAULT_LOCALE.getLanguage().equals(effectiveLocale.getLanguage())) {
ResourceBundle germanBundle = ResourceBundle.getBundle(BUNDLE_PREFIX, DEFAULT_LOCALE, BUNDLE_CONTROL);
if (germanBundle.containsKey(key)) {
return germanBundle.getString(key);
}
}
return null;
}
public String getTranslation(String key, Language language) {
Locale locale = switch (language) {
case DE -> Locale.GERMAN;
case EN -> Locale.ENGLISH;
case FR -> Locale.FRENCH;
case ES -> Locale.of("es", "ES");
case TR -> Locale.of("tr", "TR");
case PL -> Locale.of("pl", "PL");
case RU -> Locale.of("ru", "RU");
case EE -> Locale.of("et", "EE");
case LV -> Locale.of("lv", "LV");
case LT -> Locale.of("lt", "LT");
};
return getTranslation(key, locale);
}
}

View File

@@ -0,0 +1,60 @@
package de.assecutor.votianlt.controller;
import de.assecutor.votianlt.model.LocationPosition;
import de.assecutor.votianlt.service.LocationService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
/**
* REST-Controller für Location-bezogene API-Endpunkte.
*/
@RestController
@RequestMapping("/api/location")
@RequiredArgsConstructor
@Slf4j
public class LocationApiController {
private final LocationService locationService;
/**
* Gibt die aktuelle Position eines App-Nutzers zurück.
*
* @param appUserId
* die ID des App-Nutzers
* @return die aktuelle Position oder 404 wenn keine vorhanden
*/
@GetMapping("/{appUserId}")
public ResponseEntity<LocationResponse> getCurrentPosition(@PathVariable String appUserId) {
LocationPosition position = locationService.getLatestPosition(appUserId);
if (position == null || position.getLatitude() == null || position.getLongitude() == null) {
return ResponseEntity.notFound().build();
}
LocationResponse response = new LocationResponse();
response.setLatitude(position.getLatitude());
response.setLongitude(position.getLongitude());
response.setAccuracy(position.getAccuracy());
response.setSpeed(position.getSpeed());
response.setTimestamp(position.getTimestamp());
return ResponseEntity.ok(response);
}
@Data
public static class LocationResponse {
private Double latitude;
private Double longitude;
private Double accuracy;
private Double speed;
private Instant timestamp;
}
}

View File

@@ -0,0 +1,237 @@
package de.assecutor.votianlt.controller;
import de.assecutor.votianlt.model.Message;
import de.assecutor.votianlt.model.MessageContentType;
import de.assecutor.votianlt.model.MessageOrigin;
import de.assecutor.votianlt.service.MessageService;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* REST API controller for message operations. Provides endpoints for sending
* messages, retrieving messages, and marking messages as read.
*/
@RestController
@RequestMapping("/api/messages")
@Slf4j
public class MessageApiController {
private final MessageService messageService;
public MessageApiController(MessageService messageService) {
this.messageService = messageService;
}
/**
* Send a general message to a client POST /api/messages/send Body: { "content":
* "message text", "receiver": "appUserId", "contentType": "TEXT|IMAGE" }
*/
@PostMapping("/send")
public ResponseEntity<Message> sendGeneralMessage(@RequestBody Map<String, String> request) {
try {
String content = request.get("content");
String receiver = request.get("receiver");
MessageContentType contentType = resolveContentType(request.get("contentType"));
if (content == null || content.isBlank() || receiver == null || receiver.isBlank()) {
log.warn("Invalid message request: missing required fields");
return ResponseEntity.badRequest().build();
}
Message message = messageService.sendGeneralMessageToClient(content, receiver, contentType);
log.info("General message sent to AppUser '{}'", receiver);
return ResponseEntity.ok(message);
} catch (IllegalArgumentException e) {
log.warn("Invalid general message request: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error sending general message: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Send a job-related message to a client POST /api/messages/send-job-message
* Body: { "content": "message text", "receiver": "appUserId", "jobId": "job
* id", "jobNumber": "job number", "contentType": "TEXT|IMAGE" }
*/
@PostMapping("/send-job-message")
public ResponseEntity<Message> sendJobMessage(@RequestBody Map<String, String> request) {
try {
String content = request.get("content");
String receiver = request.get("receiver");
String jobIdStr = request.get("jobId");
String jobNumber = request.get("jobNumber");
MessageContentType contentType = resolveContentType(request.get("contentType"));
if (content == null || content.isBlank() || receiver == null || receiver.isBlank() || jobIdStr == null
|| jobIdStr.isBlank()) {
log.warn("Invalid job message request: missing required fields");
return ResponseEntity.badRequest().build();
}
ObjectId jobId = new ObjectId(jobIdStr);
Message message = messageService.sendJobMessageToClient(content, receiver, contentType, jobId, jobNumber);
log.info("Job-related message sent to AppUser '{}' for job {}", receiver, jobNumber);
return ResponseEntity.ok(message);
} catch (IllegalArgumentException e) {
log.warn("Invalid job message request: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error sending job message: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Get all messages for a specific receiver GET
* /api/messages/receiver/{username}
*/
@GetMapping("/receiver/{username}")
public ResponseEntity<List<Message>> getMessagesForReceiver(@PathVariable String username) {
try {
List<Message> messages = messageService.getMessagesForReceiver(username);
return ResponseEntity.ok(messages);
} catch (Exception e) {
log.error("Error retrieving messages for receiver {}: {}", username, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Get all unread messages for a specific receiver GET
* /api/messages/receiver/{username}/unread
*/
@GetMapping("/receiver/{username}/unread")
public ResponseEntity<List<Message>> getUnreadMessagesForReceiver(@PathVariable String username) {
try {
List<Message> messages = messageService.getUnreadMessagesForReceiver(username);
return ResponseEntity.ok(messages);
} catch (Exception e) {
log.error("Error retrieving unread messages for receiver {}: {}", username, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Get unread message count for a specific receiver GET
* /api/messages/receiver/{username}/unread-count
*/
@GetMapping("/receiver/{username}/unread-count")
public ResponseEntity<Map<String, Long>> getUnreadMessageCount(@PathVariable String username) {
try {
long count = messageService.getUnreadMessageCount(username);
return ResponseEntity.ok(Map.of("count", count));
} catch (Exception e) {
log.error("Error retrieving unread message count for receiver {}: {}", username, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Get all messages related to a specific job GET /api/messages/job/{jobId}
*/
@GetMapping("/job/{jobId}")
public ResponseEntity<List<Message>> getMessagesForJob(@PathVariable String jobId) {
try {
ObjectId objectId = new ObjectId(jobId);
List<Message> messages = messageService.getMessagesForJob(objectId);
return ResponseEntity.ok(messages);
} catch (IllegalArgumentException e) {
log.error("Invalid jobId format: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error retrieving messages for job {}: {}", jobId, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Get all messages (for admin/overview) GET /api/messages/all
*/
@GetMapping("/all")
public ResponseEntity<List<Message>> getAllMessages() {
try {
List<Message> messages = messageService.getAllMessages();
return ResponseEntity.ok(messages);
} catch (Exception e) {
log.error("Error retrieving all messages: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Get messages by origin (incoming/outgoing/server) GET
* /api/messages/origin/{origin}
*/
@GetMapping("/origin/{origin}")
public ResponseEntity<List<Message>> getMessagesByOrigin(@PathVariable String origin) {
try {
MessageOrigin messageOrigin = MessageOrigin.valueOf(origin.toUpperCase());
List<Message> messages = messageService.getMessagesByOrigin(messageOrigin);
return ResponseEntity.ok(messages);
} catch (IllegalArgumentException e) {
log.error("Invalid origin: {}", origin);
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error retrieving messages by origin {}: {}", origin, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Mark a message as read PUT /api/messages/{messageId}/mark-read
*/
@PutMapping("/{messageId}/mark-read")
public ResponseEntity<Void> markMessageAsRead(@PathVariable String messageId) {
try {
ObjectId objectId = new ObjectId(messageId);
messageService.markAsRead(objectId);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
log.error("Invalid messageId format: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error marking message as read {}: {}", messageId, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Delete a message DELETE /api/messages/{messageId}
*/
@DeleteMapping("/{messageId}")
public ResponseEntity<Void> deleteMessage(@PathVariable String messageId) {
try {
ObjectId objectId = new ObjectId(messageId);
messageService.deleteMessage(objectId);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
log.error("Invalid messageId format: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Error deleting message {}: {}", messageId, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
private MessageContentType resolveContentType(String rawValue) {
if (rawValue == null || rawValue.isBlank()) {
return MessageContentType.TEXT;
}
try {
return MessageContentType.valueOf(rawValue.trim().toUpperCase());
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Unsupported contentType: " + rawValue, ex);
}
}
}

View File

@@ -0,0 +1,455 @@
package de.assecutor.votianlt.controller;
import de.assecutor.votianlt.dto.AppLoginRequest;
import de.assecutor.votianlt.dto.AppLoginResponse;
import de.assecutor.votianlt.dto.ChatMessageInboundPayload;
import de.assecutor.votianlt.dto.JobWithRelatedDataDTO;
import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.CargoItem;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.task.BaseTask;
import de.assecutor.votianlt.pages.service.AppUserService;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.CargoItemRepository;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.PhotoRepository;
import de.assecutor.votianlt.repository.TaskRepository;
import de.assecutor.votianlt.repository.BarcodeRepository;
import de.assecutor.votianlt.repository.SignatureRepository;
import de.assecutor.votianlt.repository.CommentRepository;
import de.assecutor.votianlt.model.Photo;
import de.assecutor.votianlt.model.Barcode;
import de.assecutor.votianlt.model.Signature;
import de.assecutor.votianlt.model.Comment;
import de.assecutor.votianlt.service.JobHistoryService;
import de.assecutor.votianlt.service.JobUpdateBroadcaster;
import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.service.MessageService;
import de.assecutor.votianlt.service.TaskAssignmentService;
import de.assecutor.votianlt.model.JobStatus;
import lombok.extern.slf4j.Slf4j;
import de.assecutor.votianlt.messaging.MessagingPublisher;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.bson.types.ObjectId;
/**
* Message controller for handling real-time communication with apps. Provides
* endpoints for sending and receiving messages via WebSocket.
*/
@Component
@Slf4j
public class MessageController {
private final MessagingPublisher messagingPublisher;
private final AppUserRepository appUserRepository;
private final AppUserService appUserService;
private final JobRepository jobRepository;
private final CargoItemRepository cargoItemRepository;
private final TaskRepository taskRepository;
private final PhotoRepository photoRepository;
private final BarcodeRepository barcodeRepository;
private final SignatureRepository signatureRepository;
private final CommentRepository commentRepository;
private final JobHistoryService jobHistoryService;
private final JobUpdateBroadcaster jobUpdateBroadcaster;
private final EmailService emailService;
private final MessageService messageService;
private final TaskAssignmentService taskAssignmentService;
public MessageController(MessagingPublisher messagingPublisher, AppUserRepository appUserRepository,
AppUserService appUserService, JobRepository jobRepository, CargoItemRepository cargoItemRepository,
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
SignatureRepository signatureRepository, CommentRepository commentRepository,
JobHistoryService jobHistoryService, JobUpdateBroadcaster jobUpdateBroadcaster, EmailService emailService,
MessageService messageService, TaskAssignmentService taskAssignmentService) {
this.messagingPublisher = messagingPublisher;
this.appUserRepository = appUserRepository;
this.appUserService = appUserService;
this.jobRepository = jobRepository;
this.cargoItemRepository = cargoItemRepository;
this.taskRepository = taskRepository;
this.photoRepository = photoRepository;
this.barcodeRepository = barcodeRepository;
this.signatureRepository = signatureRepository;
this.commentRepository = commentRepository;
this.jobHistoryService = jobHistoryService;
this.jobUpdateBroadcaster = jobUpdateBroadcaster;
this.emailService = emailService;
this.messageService = messageService;
this.taskAssignmentService = taskAssignmentService;
}
/**
* Authentication endpoint for mobile app users via WebSocket. Client sends to
* /server/login with payload { email, password }. Returns the result to the
* caller (MessagingConfig) which handles session registration and response
* sending.
*/
public AppLoginResponse handleAppLogin(AppLoginRequest request) {
if (request == null || request.getEmail() == null || request.getPassword() == null
|| request.getEmail().isBlank() || request.getPassword().isBlank()) {
return new AppLoginResponse(false, "E-Mail und Passwort sind erforderlich", null);
}
AppUser user = appUserRepository.findByEmail(request.getEmail());
if (user == null) {
return new AppLoginResponse(false, "Benutzer nicht gefunden", null);
}
boolean ok = appUserService.verifyPassword(request.getPassword(), user.getPassword());
if (!ok) {
return new AppLoginResponse(false, "Ungültige Anmeldedaten", null);
}
return new AppLoginResponse(true, "Anmeldung erfolgreich", user.getIdAsString());
}
/**
* Retrieve jobs assigned to a specific app user with related cargo items and
* tasks. The appUserId is determined from the authenticated WebSocket session.
* Response is sent back on /client/jobs.
*/
public void handleGetAssignedJobs(String appUserId) {
if (appUserId == null || appUserId.isBlank()) {
log.warn("[JOBS] appUserId is null or blank, cannot retrieve jobs");
return;
}
log.info("[JOBS] Retrieving assigned jobs for appUserId: {}", appUserId);
List<Job> assignedJobs = jobRepository.findByAppUser(appUserId);
log.info("[JOBS] Found {} jobs for appUserId: {}", assignedJobs.size(), appUserId);
List<JobWithRelatedDataDTO> jobsWithRelatedData = assignedJobs.stream().map(job -> {
List<CargoItem> cargoItems = cargoItemRepository.findByJobId(job.getId());
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job);
return new JobWithRelatedDataDTO(job, cargoItems, tasks);
}).toList();
log.info("[JOBS] Publishing {} jobs to client {} on topic /client/jobs", jobsWithRelatedData.size(), appUserId);
messagingPublisher.publishAsJson(appUserId, "jobs", jobsWithRelatedData);
log.info("[JOBS] Jobs published successfully for client {}", appUserId);
}
/**
* Report generic task completion from apps. Client sends to /app/task/completed
* with payload { taskId, completedBy?, note? }. Broadcasts to
* /topic/task-updates and /topic/tasks/{taskId}. This endpoint accepts any task
* type (fallback for GENERIC or unknown types).
*/
public void handleTaskCompleted(Map<String, Object> payload) {
// Backward-compatible entry point: extract taskType from payload (if present)
// and delegate to the overloaded handler with explicit type.
String taskType = null;
try {
Object tt = payload != null ? payload.get("taskType") : null;
if (tt != null)
taskType = tt.toString();
} catch (Exception e) {
log.debug("Could not extract taskType from payload: {}", e.getMessage());
}
handleTaskCompleted(payload, taskType);
}
/**
* Central dispatcher for task_completed messages. Decides handling based on
* taskType. PHOTO and CONFIRMATION are routed to specialized handlers; others
* go to generic processing.
*/
public void handleTaskCompleted(Map<String, Object> payload, String taskType) {
String key = taskType == null ? "" : taskType.trim().toUpperCase();
switch (key) {
case "PHOTO" -> processPhotoTaskCompletion(payload);
case "CONFIRMATION" -> processConfirmationTaskCompletion(payload);
case "SIGNATURE" -> processSignatureTaskCompletion(payload);
case "TODOLIST" -> processTodoListTaskCompletion(payload);
case "BARCODE" -> processBarcodeTaskCompletion(payload);
case "COMMENT" -> processCommentTaskCompletion(payload);
default -> log.error("[TASK] Unknown taskType: {}", taskType);
}
}
private void processConfirmationTaskCompletion(Map<String, Object> payload) {
Object taskId = payload.get("taskId");
completeTaskWithHistory(taskId, "Bestätigung durchgeführt");
}
private void processTodoListTaskCompletion(Map<String, Object> payload) {
Object taskId = payload.get("taskId");
completeTaskWithHistory(taskId, "Alle To-Do-Elemente abgehakt");
}
private void processBarcodeTaskCompletion(Map<String, Object> payload) {
Object taskId = payload.get("taskId");
try {
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
if (opt.isEmpty()) {
return;
}
BaseTask task = opt.get();
String extraDataSummary = null;
Object extra = payload.get("extraData");
if (extra instanceof Map<?, ?> extraData) {
Object barcodesObj = extraData.get("barcodes");
if (barcodesObj instanceof List<?> barcodesList) {
@SuppressWarnings("unchecked")
List<String> barcodes = (List<String>) barcodesList;
if (!barcodes.isEmpty()) {
for (String barcodeString : barcodes) {
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
Barcode barcodeEntry = new Barcode(new ObjectId(taskId.toString()), barcodeString,
completedBy);
barcodeRepository.save(barcodeEntry);
}
extraDataSummary = barcodes.size() + " Barcode(s) gescannt: "
+ String.join(", ", barcodes.subList(0, Math.min(3, barcodes.size())))
+ (barcodes.size() > 3 ? "..." : "");
} else {
extraDataSummary = "Keine Barcodes gescannt";
}
} else {
extraDataSummary = "Barcode-Daten fehlerhaft";
}
} else {
extraDataSummary = "Keine Extra-Daten";
}
completeTaskWithHistory(taskId, extraDataSummary);
} catch (Exception ex) {
log.error("[TASK] Barcode completion error: {}", ex.getMessage());
}
}
private void processSignatureTaskCompletion(Map<String, Object> payload) {
Object taskId = payload.get("taskId");
try {
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
if (opt.isEmpty()) {
return;
}
BaseTask task = opt.get();
String extraDataSummary = null;
Object extra = payload.get("extraData");
if (extra instanceof Map<?, ?> extraData) {
Object signatureSvgObj = extraData.get("signatureSvg");
if (signatureSvgObj instanceof String signatureSvg) {
if (!signatureSvg.isBlank()) {
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
Signature signatureEntry = new Signature(new ObjectId(taskId.toString()), signatureSvg,
completedBy);
signatureRepository.save(signatureEntry);
extraDataSummary = "Unterschrift erfasst (SVG, " + signatureSvg.length() + " Zeichen)";
} else {
extraDataSummary = "Leere Unterschrift";
}
} else {
extraDataSummary = "Unterschrift-Daten fehlerhaft";
}
} else {
extraDataSummary = "Keine Extra-Daten";
}
completeTaskWithHistory(taskId, extraDataSummary);
} catch (Exception ex) {
log.error("[TASK] Signature completion error: {}", ex.getMessage());
}
}
private void processPhotoTaskCompletion(Map<String, Object> payload) {
Object taskId = payload.get("taskId");
try {
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
if (opt.isEmpty()) {
return;
}
BaseTask task = opt.get();
String extraDataSummary = null;
Object extra = payload.get("extraData");
if (extra instanceof Map<?, ?> extraData) {
Object photosObj = extraData.get("photos");
if (photosObj instanceof List<?> photosList) {
@SuppressWarnings("unchecked")
List<String> photos = (List<String>) photosList;
if (!photos.isEmpty()) {
for (String photoString : photos) {
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString, completedBy);
photoRepository.save(photoEntry);
}
extraDataSummary = photos.size() + " Foto(s) aufgenommen";
} else {
extraDataSummary = "Keine Fotos aufgenommen";
}
} else {
extraDataSummary = "Foto-Daten fehlerhaft";
}
} else {
extraDataSummary = "Keine Extra-Daten";
}
completeTaskWithHistory(taskId, extraDataSummary);
} catch (Exception ex) {
log.error("[TASK] Photo completion error: {}", ex.getMessage());
}
}
private void processCommentTaskCompletion(Map<String, Object> payload) {
Object taskId = payload.get("taskId");
try {
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
if (opt.isEmpty()) {
return;
}
BaseTask task = opt.get();
String extraDataSummary = null;
Object extra = payload.get("extraData");
if (extra instanceof Map<?, ?> extraData) {
Object commentTextObj = extraData.get("commentText");
if (commentTextObj instanceof String commentText && !commentText.isBlank()) {
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
Comment commentEntry = new Comment(new ObjectId(taskId.toString()), commentText, completedBy);
commentRepository.save(commentEntry);
extraDataSummary = "Kommentar: " + commentText;
} else {
extraDataSummary = "Kommentar abgegeben (leer)";
}
} else {
extraDataSummary = "Kommentar abgegeben";
}
completeTaskWithHistory(taskId, extraDataSummary);
} catch (Exception ex) {
log.error("[TASK] Comment completion error: {}", ex.getMessage());
}
}
private void completeTaskWithHistory(Object tid, String extraDataSummary) {
String taskIdStr = tid.toString();
try {
ObjectId taskId = new ObjectId(taskIdStr);
var opt = taskRepository.findById(taskId);
if (opt.isEmpty()) {
return;
}
BaseTask task = opt.get();
task.setCompleted(true);
task.setCompletedAt(LocalDateTime.now());
taskRepository.save(task);
Optional<Job> jobOpt = taskAssignmentService.findJobForTask(task);
if (jobOpt.isEmpty()) {
log.warn("[TASK] Could not resolve job for task {}", taskIdStr);
return;
}
ObjectId jobId = jobOpt.get().getId();
// Log detailed task completion in job history
try {
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
String taskDisplayName = task.getDisplayName() != null ? task.getDisplayName() : taskType;
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, completedBy, taskDisplayName,
extraDataSummary);
} catch (Exception e) {
// Ignore history logging errors
}
// Send email notification for task completion
try {
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy);
checkAndHandleJobCompletion(jobId, completedBy);
} catch (Exception e) {
// Ignore email notification errors
}
jobUpdateBroadcaster.broadcast(jobId);
} catch (Exception ex) {
log.error("[TASK] Completion error: {}", ex.getMessage());
}
}
private void checkAndHandleJobCompletion(ObjectId jobId, String completedBy) {
try {
var allTasks = taskAssignmentService.findTasksForJob(jobId);
if (allTasks.isEmpty()) {
return;
}
var mandatoryTasks = allTasks.stream().filter(task -> !task.isOptional()).toList();
if (mandatoryTasks.isEmpty()) {
return;
}
boolean allCompleted = mandatoryTasks.stream().allMatch(task -> task.isCompleted());
if (allCompleted) {
updateJobStatusToCompleted(jobId);
try {
emailService.sendJobCompletionNotification(jobId, completedBy);
} catch (Exception e) {
// Ignore email notification errors
}
}
} catch (Exception e) {
// Ignore job completion check errors
}
}
private void updateJobStatusToCompleted(ObjectId jobId) {
try {
Optional<Job> jobOpt = jobRepository.findById(jobId);
if (jobOpt.isEmpty()) {
return;
}
Job job = jobOpt.get();
if (job.getStatus() != JobStatus.COMPLETED) {
job.setStatus(JobStatus.COMPLETED);
job.setUpdatedAt(LocalDateTime.now());
jobRepository.save(job);
}
} catch (Exception e) {
// Ignore job status update errors
}
}
/**
* Handle incoming message from a client via WebSocket. Client sends to
* /server/message with payload: { "content": "message payload", "contentType":
* "TEXT|IMAGE", "jobId": "optional job id", "jobNumber": "optional job number"
* }
*
* The appUserId is determined from the authenticated WebSocket session.
*/
public void handleIncomingMessage(String appUserId, Map<String, Object> payload) {
try {
if (appUserId == null || appUserId.isBlank()) {
return;
}
payload.put("receiver", appUserId);
ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload);
messageService.receiveMessageFromClient(inboundPayload);
} catch (Exception e) {
// Ignore message handling errors
}
}
}

View File

@@ -0,0 +1,15 @@
package de.assecutor.votianlt.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class AppLoginRequest {
private String email;
private String password;
}

View File

@@ -0,0 +1,18 @@
package de.assecutor.votianlt.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AppLoginResponse {
private boolean success;
private String message;
/**
* Only populated on success, for internal server-side routing. Not sent to
* client.
*/
private String appUserId;
}

View File

@@ -0,0 +1,76 @@
package de.assecutor.votianlt.dto;
import de.assecutor.votianlt.model.MessageContentType;
import java.util.Map;
import org.bson.types.ObjectId;
/**
* Normalized payload for chat messages sent by mobile clients via WebSocket.
* receiver = AppUser ID (clientId) extracted from topic
*/
public record ChatMessageInboundPayload(String receiver, String content, MessageContentType contentType, ObjectId jobId,
String jobNumber) {
public ChatMessageInboundPayload {
contentType = contentType != null ? contentType : MessageContentType.TEXT;
}
public static ChatMessageInboundPayload fromPayload(Map<String, Object> payload) {
if (payload == null) {
throw new IllegalArgumentException("payload must not be null");
}
String receiver = extractRequiredString(payload, "receiver");
String content = extractRequiredString(payload, "content");
MessageContentType contentType = extractContentType(payload.get("contentType"));
ObjectId jobId = extractObjectId(payload.get("jobId"), "jobId");
String jobNumber = extractOptionalString(payload.get("jobNumber"));
return new ChatMessageInboundPayload(receiver, content, contentType, jobId, jobNumber);
}
public boolean hasJobContext() {
return jobId != null;
}
private static String extractRequiredString(Map<String, Object> payload, String key) {
Object value = payload.get(key);
String asString = value != null ? value.toString().trim() : null;
if (asString == null || asString.isEmpty()) {
throw new IllegalArgumentException("Missing required field '%s'".formatted(key));
}
return asString;
}
private static String extractOptionalString(Object value) {
if (value == null) {
return null;
}
String asString = value.toString().trim();
return asString.isEmpty() ? null : asString;
}
private static ObjectId extractObjectId(Object value, String fieldName) {
String candidate = extractOptionalString(value);
if (candidate == null) {
return null;
}
try {
return new ObjectId(candidate);
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Field '%s' must be a valid MongoDB ObjectId".formatted(fieldName), ex);
}
}
private static MessageContentType extractContentType(Object value) {
String candidate = extractOptionalString(value);
if (candidate == null) {
return MessageContentType.TEXT;
}
try {
return MessageContentType.valueOf(candidate.trim().toUpperCase());
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Unsupported contentType '%s'".formatted(candidate), ex);
}
}
}

View File

@@ -0,0 +1,22 @@
package de.assecutor.votianlt.dto;
import de.assecutor.votianlt.model.Message;
import de.assecutor.votianlt.model.MessageContentType;
import de.assecutor.votianlt.model.MessageOrigin;
import de.assecutor.votianlt.model.MessageType;
import java.time.LocalDateTime;
/**
* Outbound chat message payload published to subscribers. The receiver is
* implicit from the WebSocket session (/client/message)
*/
public record ChatMessageOutboundPayload(String messageId, String content, MessageContentType contentType,
MessageOrigin origin, MessageType messageType, LocalDateTime createdAt, String jobId, String jobNumber,
boolean read) {
public static ChatMessageOutboundPayload fromMessage(Message message) {
return new ChatMessageOutboundPayload(message.getIdAsString(), message.getContent(), message.getContentType(),
message.getOrigin(), message.getMessageType(), message.getCreatedAt(), message.getJobIdAsString(),
message.getJobNumber(), message.isRead());
}
}

View File

@@ -0,0 +1,24 @@
package de.assecutor.votianlt.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* DTO for summarizing message conversations by client
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ClientMessageSummary {
private String clientId;
private String clientName;
private String clientEmail;
private int totalMessages;
private int unreadCount;
private LocalDateTime lastMessageDate;
private String lastMessagePreview;
}

View File

@@ -0,0 +1,23 @@
package de.assecutor.votianlt.dto;
import de.assecutor.votianlt.model.CargoItem;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.task.BaseTask;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* DTO for returning job data with related cargo items and tasks. This combines
* Job entity with its associated CargoItems and TaskEntries.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JobWithRelatedDataDTO {
private Job job;
private List<CargoItem> cargoItems;
private List<BaseTask> tasks;
}

View File

@@ -0,0 +1,14 @@
package de.assecutor.votianlt.event;
import org.springframework.context.ApplicationEvent;
/**
* Event published when message read status changes (e.g., messages marked as
* read) This allows UI components like the sidebar badge to update accordingly
*/
public class MessageReadStatusChangedEvent extends ApplicationEvent {
public MessageReadStatusChangedEvent(Object source) {
super(source);
}
}

View File

@@ -0,0 +1,21 @@
package de.assecutor.votianlt.event;
import de.assecutor.votianlt.model.Message;
import org.springframework.context.ApplicationEvent;
/**
* Event published when a new message is received from a client
*/
public class MessageReceivedEvent extends ApplicationEvent {
private final Message message;
public MessageReceivedEvent(Object source, Message message) {
super(source);
this.message = message;
}
public Message getMessage() {
return message;
}
}

View File

@@ -0,0 +1,37 @@
package de.assecutor.votianlt.mcp.config;
import de.assecutor.votianlt.mcp.tools.JobQueryTool;
import de.assecutor.votianlt.mcp.tools.JobStatisticsTool;
import de.assecutor.votianlt.mcp.tools.TaskCompletionTool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Configuration for the MCP (Model Context Protocol) server. Registers all MCP
* tools for job statistics and queries.
*/
@Configuration
@Slf4j
public class McpServerConfig {
@Bean
public ToolCallbackProvider jobStatisticsToolProvider(JobStatisticsTool jobStatisticsTool) {
log.info("Registering JobStatisticsTool for MCP server");
return MethodToolCallbackProvider.builder().toolObjects(jobStatisticsTool).build();
}
@Bean
public ToolCallbackProvider jobQueryToolProvider(JobQueryTool jobQueryTool) {
log.info("Registering JobQueryTool for MCP server");
return MethodToolCallbackProvider.builder().toolObjects(jobQueryTool).build();
}
@Bean
public ToolCallbackProvider taskCompletionToolProvider(TaskCompletionTool taskCompletionTool) {
log.info("Registering TaskCompletionTool for MCP server");
return MethodToolCallbackProvider.builder().toolObjects(taskCompletionTool).build();
}
}

View File

@@ -0,0 +1,22 @@
package de.assecutor.votianlt.mcp.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* DTO for customer revenue results.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CustomerRevenueResult {
private String customer;
private BigDecimal revenue;
private long jobCount;
}

View File

@@ -0,0 +1,34 @@
package de.assecutor.votianlt.mcp.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* DTO for job query results returned by MCP tools.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobQueryResult {
private String jobId;
private String jobNumber;
private String status;
private String statusDisplayName;
private String customer;
private String pickupCity;
private String deliveryCity;
private LocalDate pickupDate;
private LocalDate deliveryDate;
private BigDecimal price;
private LocalDateTime createdAt;
private String assignedAppUser;
private boolean digitalProcessing;
}

View File

@@ -0,0 +1,29 @@
package de.assecutor.votianlt.mcp.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
/**
* DTO for job statistics results returned by MCP tools.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobStatisticsResult {
private Map<String, Long> countsByStatus;
private long totalJobs;
private long completedJobs;
private long cancelledJobs;
private long inProgressJobs;
private double completionRate;
private BigDecimal totalRevenue;
private LocalDateTime queryTimestamp;
}

View File

@@ -0,0 +1,21 @@
package de.assecutor.votianlt.mcp.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for task completion statistics.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TaskCompletionResult {
private long totalTasks;
private long completedTasks;
private long pendingTasks;
private double completionRate;
}

View File

@@ -0,0 +1,109 @@
package de.assecutor.votianlt.mcp.tools;
import de.assecutor.votianlt.mcp.dto.JobQueryResult;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.service.JobStatisticsService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
* MCP Tool for querying jobs with various filters.
*/
@Component
@Slf4j
public class JobQueryTool {
private final JobStatisticsService statisticsService;
public JobQueryTool(JobStatisticsService statisticsService) {
this.statisticsService = statisticsService;
}
@Tool(description = "Query jobs with optional filters. Returns a list of jobs matching the criteria.")
public List<JobQueryResult> queryJobs(
@ToolParam(description = "Optional: Job status filter (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)") String status,
@ToolParam(description = "Optional: Customer name filter") String customer,
@ToolParam(description = "Optional: Pickup city filter") String pickupCity,
@ToolParam(description = "Optional: Delivery city filter") String deliveryCity,
@ToolParam(description = "Maximum results to return (default 50)") Integer limit) {
log.info("MCP Tool: Querying jobs with filters - status: {}, customer: {}, pickupCity: {}, deliveryCity: {}",
status, customer, pickupCity, deliveryCity);
int actualLimit = limit != null ? limit : 50;
List<Job> jobs;
if (status != null && !status.isBlank()) {
JobStatus jobStatus = JobStatus.valueOf(status.toUpperCase());
jobs = statisticsService.getJobsByStatus(jobStatus);
} else if (customer != null && !customer.isBlank()) {
jobs = statisticsService.getJobsByCustomer(customer);
} else if (pickupCity != null && !pickupCity.isBlank()) {
jobs = statisticsService.getJobsByPickupCity(pickupCity);
} else if (deliveryCity != null && !deliveryCity.isBlank()) {
jobs = statisticsService.getJobsByDeliveryCity(deliveryCity);
} else {
jobs = statisticsService.getLatestJobs(actualLimit);
}
return jobs.stream().limit(actualLimit).map(this::toQueryResult).toList();
}
@Tool(description = "Get detailed information about a specific job by its job number")
public JobQueryResult getJobByNumber(
@ToolParam(description = "The job number to look up (e.g., JOB-2024-0001)") String jobNumber) {
log.info("MCP Tool: Getting job by number: {}", jobNumber);
Job job = statisticsService.getJobByNumber(jobNumber);
if (job == null) {
return null;
}
return toQueryResult(job);
}
@Tool(description = "Get jobs assigned to a specific mobile app user")
public List<JobQueryResult> getJobsByAppUser(@ToolParam(description = "App user identifier") String appUser) {
log.info("MCP Tool: Getting jobs for app user: {}", appUser);
return statisticsService.getJobsByAppUser(appUser).stream().map(this::toQueryResult).toList();
}
@Tool(description = "Get the most recent jobs, sorted by creation date descending")
public List<JobQueryResult> getLatestJobs(
@ToolParam(description = "Number of jobs to return (default 10)") Integer limit) {
log.info("MCP Tool: Getting latest jobs, limit: {}", limit);
int actualLimit = limit != null ? limit : 10;
return statisticsService.getLatestJobs(actualLimit).stream().map(this::toQueryResult).toList();
}
@Tool(description = "Get jobs created within a specific date range")
public List<JobQueryResult> getJobsByDateRange(
@ToolParam(description = "Start date in ISO format (e.g., 2024-01-01T00:00:00)") String startDate,
@ToolParam(description = "End date in ISO format (e.g., 2024-12-31T23:59:59)") String endDate,
@ToolParam(description = "Maximum results to return (default 100)") Integer limit) {
log.info("MCP Tool: Getting jobs for date range: {} to {}", startDate, endDate);
LocalDateTime start = LocalDateTime.parse(startDate);
LocalDateTime end = LocalDateTime.parse(endDate);
int actualLimit = limit != null ? limit : 100;
return statisticsService.getJobsByDateRange(start, end).stream().limit(actualLimit).map(this::toQueryResult)
.toList();
}
private JobQueryResult toQueryResult(Job job) {
return JobQueryResult.builder().jobId(job.getIdAsString()).jobNumber(job.getJobNumber())
.status(job.getStatus() != null ? job.getStatus().name() : null)
.statusDisplayName(job.getStatus() != null ? job.getStatus().getDisplayName() : null)
.customer(job.getCustomerSelection()).pickupCity(job.getPickupCity())
.deliveryCity(job.getDeliveryCity()).pickupDate(job.getPickupDate()).deliveryDate(job.getDeliveryDate())
.price(job.getPrice()).createdAt(job.getCreatedAt()).assignedAppUser(job.getAppUser())
.digitalProcessing(job.isDigitalProcessing()).build();
}
}

View File

@@ -0,0 +1,110 @@
package de.assecutor.votianlt.mcp.tools;
import de.assecutor.votianlt.mcp.dto.CustomerRevenueResult;
import de.assecutor.votianlt.mcp.dto.JobStatisticsResult;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.service.JobStatisticsService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.Month;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* MCP Tool for job statistics queries. Provides various statistics and
* aggregations about jobs.
*/
@Component
@Slf4j
public class JobStatisticsTool {
private final JobStatisticsService statisticsService;
public JobStatisticsTool(JobStatisticsService statisticsService) {
this.statisticsService = statisticsService;
}
@Tool(description = "Get comprehensive job statistics including counts by status, completion rates, and revenue metrics")
public JobStatisticsResult getJobStatistics() {
log.info("MCP Tool: Getting job statistics");
Map<JobStatus, Long> countsByStatus = statisticsService.getJobCountsByStatus();
Map<String, Long> statusCounts = countsByStatus.entrySet().stream()
.collect(Collectors.toMap(e -> e.getKey().name(), Map.Entry::getValue));
long completed = countsByStatus.getOrDefault(JobStatus.COMPLETED, 0L);
long cancelled = countsByStatus.getOrDefault(JobStatus.CANCELLED, 0L);
long inProgress = countsByStatus.getOrDefault(JobStatus.IN_PROGRESS, 0L);
return JobStatisticsResult.builder().countsByStatus(statusCounts)
.totalJobs(statisticsService.getTotalJobCount()).completedJobs(completed).cancelledJobs(cancelled)
.inProgressJobs(inProgress).completionRate(statisticsService.getCompletionRate())
.totalRevenue(statisticsService.getTotalRevenue()).queryTimestamp(LocalDateTime.now()).build();
}
@Tool(description = "Get job counts grouped by status (CREATED, IN_PROGRESS, PICKUP_SCHEDULED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED)")
public Map<String, Long> getJobCountsByStatus() {
log.info("MCP Tool: Getting job counts by status");
Map<JobStatus, Long> counts = statisticsService.getJobCountsByStatus();
return counts.entrySet().stream().collect(Collectors
.toMap(e -> e.getKey().name() + " (" + e.getKey().getDisplayName() + ")", Map.Entry::getValue));
}
@Tool(description = "Get the completion rate as a percentage (completed jobs / total jobs * 100)")
public String getCompletionRate() {
log.info("MCP Tool: Getting completion rate");
double rate = statisticsService.getCompletionRate();
return String.format("%.2f%%", rate);
}
@Tool(description = "Get revenue statistics grouped by customer, sorted by revenue descending")
public List<CustomerRevenueResult> getRevenueByCustomer(
@ToolParam(description = "Maximum number of customers to return (default 10)") Integer limit) {
log.info("MCP Tool: Getting revenue by customer, limit: {}", limit);
int actualLimit = limit != null ? limit : 10;
return statisticsService.getTopCustomersByRevenue(actualLimit).stream().map(entry -> {
String customer = entry.getKey();
long jobCount = statisticsService.getJobsByCustomer(customer).size();
return CustomerRevenueResult.builder().customer(customer).revenue(entry.getValue()).jobCount(jobCount)
.build();
}).toList();
}
@Tool(description = "Get monthly job trend data for a specific year showing job counts per month")
public Map<String, Long> getMonthlyJobTrend(
@ToolParam(description = "Year for the trend data (e.g., 2024)") int year) {
log.info("MCP Tool: Getting monthly job trend for year: {}", year);
Map<Month, Long> monthlyData = statisticsService.getMonthlyJobCounts(year);
return monthlyData.entrySet().stream()
.collect(Collectors.toMap(e -> e.getKey().toString(), Map.Entry::getValue));
}
@Tool(description = "Get total revenue from all jobs")
public String getTotalRevenue() {
log.info("MCP Tool: Getting total revenue");
BigDecimal revenue = statisticsService.getTotalRevenue();
return String.format("%.2f EUR", revenue);
}
@Tool(description = "Get job count for a specific date range")
public long getJobCountByDateRange(
@ToolParam(description = "Start date in ISO format (e.g., 2024-01-01T00:00:00)") String startDate,
@ToolParam(description = "End date in ISO format (e.g., 2024-12-31T23:59:59)") String endDate) {
log.info("MCP Tool: Getting job count for date range: {} to {}", startDate, endDate);
LocalDateTime start = LocalDateTime.parse(startDate);
LocalDateTime end = LocalDateTime.parse(endDate);
return statisticsService.getJobCountByDateRange(start, end);
}
}

View File

@@ -0,0 +1,53 @@
package de.assecutor.votianlt.mcp.tools;
import de.assecutor.votianlt.mcp.dto.TaskCompletionResult;
import de.assecutor.votianlt.service.JobStatisticsService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* MCP Tool for task completion statistics and data.
*/
@Component
@Slf4j
public class TaskCompletionTool {
private final JobStatisticsService statisticsService;
public TaskCompletionTool(JobStatisticsService statisticsService) {
this.statisticsService = statisticsService;
}
@Tool(description = "Get overall task completion statistics including total, completed, pending tasks and completion rate")
public TaskCompletionResult getTaskCompletionStats() {
log.info("MCP Tool: Getting task completion statistics");
Map<String, Long> stats = statisticsService.getTaskCompletionStats();
long total = stats.getOrDefault("total", 0L);
long completed = stats.getOrDefault("completed", 0L);
long pending = stats.getOrDefault("pending", 0L);
double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0;
return TaskCompletionResult.builder().totalTasks(total).completedTasks(completed).pendingTasks(pending)
.completionRate(completionRate).build();
}
@Tool(description = "Get a summary of task completion as a formatted string")
public String getTaskCompletionSummary() {
log.info("MCP Tool: Getting task completion summary");
Map<String, Long> stats = statisticsService.getTaskCompletionStats();
long total = stats.getOrDefault("total", 0L);
long completed = stats.getOrDefault("completed", 0L);
long pending = stats.getOrDefault("pending", 0L);
double completionRate = total > 0 ? (double) completed / total * 100.0 : 0.0;
return String.format("Task Statistics: %d total tasks, %d completed (%.1f%%), %d pending", total, completed,
completionRate, pending);
}
}

View File

@@ -0,0 +1,168 @@
package de.assecutor.votianlt.messaging;
import de.assecutor.votianlt.controller.MessageController;
import de.assecutor.votianlt.dto.AppLoginRequest;
import de.assecutor.votianlt.dto.AppLoginResponse;
import de.assecutor.votianlt.service.ClientConnectionService;
import de.assecutor.votianlt.service.LocationService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* Configuration for the messaging system. Sets up message routing after
* application startup.
*/
@Configuration
@Slf4j
public class MessagingConfig {
private final WebSocketService webSocketService;
private final ObjectMapper objectMapper;
public MessagingConfig(WebSocketService webSocketService, ObjectMapper objectMapper) {
this.webSocketService = webSocketService;
this.objectMapper = objectMapper;
}
/**
* Set up message routing after application startup.
*/
@EventListener(ApplicationReadyEvent.class)
public void setupMessaging(ApplicationReadyEvent event) {
try {
MessageController messageController = event.getApplicationContext().getBean(MessageController.class);
ClientConnectionService clientConnectionService = event.getApplicationContext()
.getBean(ClientConnectionService.class);
LocationService locationService = event.getApplicationContext().getBean(LocationService.class);
setupSubscriptions(messageController, clientConnectionService, locationService);
log.info("[Messaging] Message routing configured");
} catch (Exception e) {
log.error("[Messaging] Failed to initialize: {}", e.getMessage());
throw new RuntimeException("Failed to initialize messaging", e);
}
}
/**
* Setup message subscriptions on the WebSocket service.
*/
private void setupSubscriptions(MessageController messageController,
ClientConnectionService clientConnectionService, LocationService locationService) {
// Login handler: authenticate and register session
webSocketService.registerMessageHandler("login", (wsSessionId, payload) -> {
handleLoginMessage(wsSessionId, payload, messageController, clientConnectionService);
});
// Task completion handler
webSocketService.registerMessageHandler("task_completed", (appUserId, payload) -> {
handlePayload(payload, payloadMap -> {
String taskType = payloadMap.get("taskType") != null ? payloadMap.get("taskType").toString() : null;
messageController.handleTaskCompleted(payloadMap, taskType);
});
});
// Chat message handler
webSocketService.registerMessageHandler("message", (appUserId, payload) -> {
handlePayload(payload, payloadMap -> {
messageController.handleIncomingMessage(appUserId, payloadMap);
});
});
// Buffer flushed handler - client is ready to receive pending messages
webSocketService.registerMessageHandler("buffer_flushed", (appUserId, payload) -> {
handlePayload(payload, payloadMap -> {
int messageCount = extractMessageCount(payloadMap);
clientConnectionService.onBufferFlushed(appUserId, messageCount);
});
});
// Location handler - client sends position updates
webSocketService.registerMessageHandler("location", (appUserId, payload) -> {
handlePayload(payload, payloadMap -> {
locationService.savePosition(appUserId, payloadMap);
});
});
}
/**
* Handle login message. The wsSessionId identifies the pending WebSocket
* session. On success, registers the session under the appUserId and sends an
* auth response. On failure, sends an error response to the pending session.
*/
@SuppressWarnings("unchecked")
private void handleLoginMessage(String wsSessionId, byte[] payload, MessageController messageController,
ClientConnectionService clientConnectionService) {
try {
String json = new String(payload, StandardCharsets.UTF_8);
Map<String, Object> payloadMap = objectMapper.readValue(json, Map.class);
AppLoginRequest loginRequest = objectMapper.convertValue(payloadMap, AppLoginRequest.class);
AppLoginResponse response = messageController.handleAppLogin(loginRequest);
if (response.isSuccess()) {
String appUserId = response.getAppUserId();
log.info("[Messaging] Login successful for appUserId: {}", appUserId);
webSocketService.registerAuthenticatedSession(wsSessionId, appUserId);
// Send success response to the now-authenticated session
// locationTrackingEnabled: true = client should send position updates
// appUserId: wird an den Client gesendet für Referenz
Map<String, Object> authResponse = Map.of("success", true, "message", response.getMessage(),
"locationTrackingEnabled", true, "appUserId", appUserId);
byte[] responseBytes = objectMapper.writeValueAsBytes(authResponse);
log.info("[Messaging] Sending auth response to appUserId: {}", appUserId);
webSocketService.sendToClient(appUserId, "auth", responseBytes);
// Register client - pending messages and jobs will be sent after
// client confirms buffer_flushed
clientConnectionService.registerClient(appUserId);
} else {
log.warn("[Messaging] Login failed: {}", response.getMessage());
// Send failure response to the pending session
Map<String, Object> authResponse = Map.of("success", false, "message", response.getMessage());
byte[] responseBytes = objectMapper.writeValueAsBytes(authResponse);
webSocketService.sendToSessionById(wsSessionId, "/client/auth", responseBytes);
}
} catch (Exception e) {
log.error("[Messaging] Login handling error: {}", e.getMessage(), e);
}
}
/**
* Extract message count from buffer_flushed payload.
*/
private int extractMessageCount(Map<String, Object> payloadMap) {
try {
Object countObj = payloadMap.get("messageCount");
if (countObj instanceof Number) {
return ((Number) countObj).intValue();
}
return 0;
} catch (Exception e) {
return 0;
}
}
/**
* Parse payload bytes to a Map and pass to the consumer.
*/
@SuppressWarnings("unchecked")
private void handlePayload(byte[] payload, java.util.function.Consumer<Map<String, Object>> handler) {
try {
String json = new String(payload, StandardCharsets.UTF_8);
Map<String, Object> payloadMap = objectMapper.readValue(json, Map.class);
handler.accept(payloadMap);
} catch (Exception e) {
log.error("[Messaging] Error parsing payload: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,54 @@
package de.assecutor.votianlt.messaging;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
/**
* Publishing helper to send JSON payloads to clients via WebSocket.
*/
public interface MessagingPublisher {
void publishAsJson(String clientId, String messageType, Object payload);
}
@Component
@Slf4j
class MessagingPublisherImpl implements MessagingPublisher {
private final WebSocketService webSocketService;
private final ObjectMapper objectMapper;
public MessagingPublisherImpl(WebSocketService webSocketService, ObjectMapper objectMapper) {
this.webSocketService = webSocketService;
this.objectMapper = objectMapper;
}
@Override
public void publishAsJson(String clientId, String messageType, Object payload) {
try {
// Prüfen ob Client verbunden ist
boolean isConnected = webSocketService.isClientConnected(clientId);
log.debug("[Messaging] Publishing to {}/{} - connected: {}", clientId, messageType, isConnected);
if (!isConnected) {
log.warn("[Messaging] Client {} is not connected, cannot send {}", clientId, messageType);
return;
}
String json = objectMapper.writeValueAsString(payload);
byte[] data = json.getBytes(StandardCharsets.UTF_8);
webSocketService.sendToClient(clientId, messageType, data).thenRun(() -> {
log.debug("[Messaging] Successfully sent {}/{} to client {}", messageType, clientId);
}).exceptionally(ex -> {
log.error("[Messaging] Failed to deliver to {}/{}: {}", clientId, messageType, ex.getMessage(), ex);
return null;
});
} catch (Exception e) {
log.error("[Messaging] Failed to publish to {}/{}: {}", clientId, messageType, e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,35 @@
package de.assecutor.votianlt.messaging;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
/**
* WebSocket configuration that registers the WebSocketService as a handler on
* the configured endpoint.
*/
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final WebSocketService webSocketService;
@Value("${app.messaging.websocket.path:/ws/messaging}")
private String wsPath;
@Value("${app.messaging.websocket.allowed-origins:*}")
private String allowedOrigins;
public WebSocketConfig(WebSocketService webSocketService) {
this.webSocketService = webSocketService;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketService, wsPath).setAllowedOrigins(allowedOrigins.split(","))
.addInterceptors(new HttpSessionHandshakeInterceptor());
}
}

View File

@@ -0,0 +1,394 @@
package de.assecutor.votianlt.messaging;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.*;
/**
* WebSocket service for direct bidirectional communication with mobile clients.
*
* Wire Protocol: Each WebSocket message is a JSON document with a "topic" and
* "payload" field:
*
* <pre>
* {
* "topic": "/server/login",
* "payload": { ... }
* }
* </pre>
*
* Topic Structure:
* <ul>
* <li>Server to Client: /client/{messageType}</li>
* <li>Client to Server: /server/{messageType}</li>
* <li>Login (special): /server/login (unauthenticated)</li>
* </ul>
*/
@Component
@Slf4j
public class WebSocketService extends TextWebSocketHandler {
@FunctionalInterface
public interface MessageHandler {
void onMessageReceived(String clientId, byte[] payload);
}
private static final String TOPIC_TO_CLIENT = "/client/%s";
private static final long PENDING_SESSION_TIMEOUT_MS = 30_000;
private final ObjectMapper objectMapper;
// appUserId -> WebSocketSession
private final ConcurrentHashMap<String, WebSocketSession> clientSessions = new ConcurrentHashMap<>();
// sessionId -> appUserId (reverse lookup for cleanup on disconnect)
private final ConcurrentHashMap<String, String> sessionToClient = new ConcurrentHashMap<>();
// sessionId -> PendingSession (connected but not yet logged in)
private final ConcurrentHashMap<String, PendingSession> pendingSessions = new ConcurrentHashMap<>();
private final Map<String, MessageHandler> messageHandlers = new ConcurrentHashMap<>();
private volatile boolean initialized = false;
private ScheduledExecutorService pendingSessionCleanup;
public WebSocketService(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
// ==========================================
// Lifecycle
// ==========================================
@PostConstruct
public void init() {
pendingSessionCleanup = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "ws-pending-cleanup");
t.setDaemon(true);
return t;
});
pendingSessionCleanup.scheduleAtFixedRate(this::cleanupPendingSessions, 30, 30, TimeUnit.SECONDS);
initialized = true;
log.info("[WebSocket] Service initialized on endpoint /ws/messaging");
}
@PreDestroy
public void shutdown() {
if (pendingSessionCleanup != null) {
pendingSessionCleanup.shutdownNow();
}
for (var entry : clientSessions.entrySet()) {
try {
WebSocketSession session = entry.getValue();
if (session.isOpen()) {
session.close(CloseStatus.GOING_AWAY);
}
} catch (Exception e) {
log.warn("[WebSocket] Error closing session for client {}: {}", entry.getKey(), e.getMessage());
}
}
for (var entry : pendingSessions.entrySet()) {
try {
if (entry.getValue().session.isOpen()) {
entry.getValue().session.close(CloseStatus.GOING_AWAY);
}
} catch (Exception ignored) {
}
}
clientSessions.clear();
sessionToClient.clear();
pendingSessions.clear();
messageHandlers.clear();
initialized = false;
log.info("[WebSocket] Service shut down");
}
// ==========================================
// Public API
// ==========================================
public CompletableFuture<Void> sendToClient(String clientId, String messageType, byte[] payload) {
WebSocketSession session = clientSessions.get(clientId);
if (session == null) {
log.warn("[WebSocket] No session found for client {}", clientId);
return CompletableFuture.failedFuture(new IOException("No WebSocket session for client: " + clientId));
}
if (!session.isOpen()) {
log.warn("[WebSocket] Session for client {} is closed", clientId);
// Session aus der Map entfernen
clientSessions.remove(clientId);
sessionToClient.remove(session.getId());
return CompletableFuture.failedFuture(new IOException("WebSocket session closed for client: " + clientId));
}
try {
String topic = String.format(TOPIC_TO_CLIENT, messageType);
String payloadJson = new String(payload, StandardCharsets.UTF_8);
ObjectNode wireMessage = objectMapper.createObjectNode();
wireMessage.put("topic", topic);
wireMessage.set("payload", objectMapper.readTree(payloadJson));
String wireJson = objectMapper.writeValueAsString(wireMessage);
log.info("[WebSocket OUT] {} to client {} (session open: {})", topic, clientId, session.isOpen());
log.debug("[WebSocket OUT] {} -> {}", topic, wireJson);
sendToSession(session, wireJson);
log.debug("[WebSocket] Message sent successfully to client {}", clientId);
return CompletableFuture.completedFuture(null);
} catch (Exception e) {
log.error("[WebSocket] Failed to send to client {}: {}", clientId, e.getMessage(), e);
return CompletableFuture.failedFuture(new IOException("Failed to send WebSocket message", e));
}
}
public void registerMessageHandler(String messageType, MessageHandler handler) {
messageHandlers.put(messageType, handler);
log.debug("[WebSocket] Registered handler for messageType: {}", messageType);
}
public boolean isConnected() {
return initialized;
}
public boolean isClientConnected(String clientId) {
WebSocketSession session = clientSessions.get(clientId);
return session != null && session.isOpen();
}
public int getConnectedClientCount() {
return clientSessions.size();
}
// ==========================================
// WebSocket handler methods
// ==========================================
@Override
public void afterConnectionEstablished(WebSocketSession session) {
pendingSessions.put(session.getId(), new PendingSession(session, Instant.now()));
log.info("[WebSocket] New connection: sessionId={}, remote={}", session.getId(), session.getRemoteAddress());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
try {
String json = message.getPayload();
JsonNode wireMessage = objectMapper.readTree(json);
JsonNode topicNode = wireMessage.get("topic");
JsonNode payloadNode = wireMessage.get("payload");
if (topicNode == null || payloadNode == null) {
log.warn("[WebSocket] Invalid message format (missing topic or payload): {}", json);
return;
}
String topic = topicNode.asText();
byte[] payloadBytes = objectMapper.writeValueAsBytes(payloadNode);
log.info("[WebSocket IN] {} <- {}", topic, json);
// Login message (special: unauthenticated)
if ("/server/login".equals(topic)) {
handleLoginMessage(session, payloadBytes);
return;
}
// Regular client message: /server/{messageType}
if (topic.startsWith("/server/")) {
// Verify session is authenticated
String appUserId = sessionToClient.get(session.getId());
if (appUserId == null) {
log.warn("[WebSocket] Unauthenticated session {} tried to send: {}", session.getId(), topic);
return;
}
handleClientMessage(topic, appUserId, payloadBytes);
}
} catch (Exception e) {
log.error("[WebSocket] Error handling message: {}", e.getMessage(), e);
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
String sessionId = session.getId();
// Remove from pending sessions
pendingSessions.remove(sessionId);
// Remove from authenticated sessions
String clientId = sessionToClient.remove(sessionId);
if (clientId != null) {
clientSessions.remove(clientId, session);
log.info("[WebSocket] Client disconnected: clientId={}, reason={}", clientId, status);
} else {
log.info("[WebSocket] Unauthenticated session closed: sessionId={}", sessionId);
}
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
log.error("[WebSocket] Transport error for session {}: {}", session.getId(), exception.getMessage());
}
// ==========================================
// Internal message routing
// ==========================================
private void handleLoginMessage(WebSocketSession session, byte[] payloadBytes) {
MessageHandler handler = messageHandlers.get("login");
if (handler != null) {
handler.onMessageReceived(session.getId(), payloadBytes);
}
}
/**
* Register a pending session as authenticated under the given appUserId. Called
* by MessagingConfig after successful login.
*/
public void registerAuthenticatedSession(String wsSessionId, String appUserId) {
PendingSession pending = pendingSessions.get(wsSessionId);
if (pending == null) {
log.warn("[WebSocket] No pending session for wsSessionId={}", wsSessionId);
return;
}
registerClientSession(appUserId, pending.session());
}
/**
* Send a wire-format message directly to a session by its WebSocket sessionId.
* Used for sending login responses to pending (not yet authenticated) sessions.
*/
public void sendToSessionById(String wsSessionId, String topic, byte[] payload) {
try {
// Check pending sessions first
PendingSession pending = pendingSessions.get(wsSessionId);
WebSocketSession session = pending != null ? pending.session() : null;
// Fallback: check authenticated sessions via reverse lookup
if (session == null) {
String appUserId = sessionToClient.get(wsSessionId);
if (appUserId != null) {
session = clientSessions.get(appUserId);
}
}
if (session == null || !session.isOpen()) {
log.warn("[WebSocket] Cannot send to session {}: not found or closed", wsSessionId);
return;
}
String payloadJson = new String(payload, StandardCharsets.UTF_8);
ObjectNode wireMessage = objectMapper.createObjectNode();
wireMessage.put("topic", topic);
wireMessage.set("payload", objectMapper.readTree(payloadJson));
String wireJson = objectMapper.writeValueAsString(wireMessage);
log.info("[WebSocket OUT] {} -> {}", topic, wireJson);
sendToSession(session, wireJson);
} catch (Exception e) {
log.error("[WebSocket] Error sending to session {}: {}", wsSessionId, e.getMessage());
}
}
private void handleClientMessage(String topic, String appUserId, byte[] payload) {
String[] parts = topic.split("/");
// Handle /server/{messageType} where messageType can contain slashes
if (parts.length >= 3) {
String messageType = String.join("/", Arrays.copyOfRange(parts, 2, parts.length));
MessageHandler handler = messageHandlers.get(messageType);
if (handler != null) {
handler.onMessageReceived(appUserId, payload);
} else {
log.warn("[WebSocket] No handler registered for messageType: {}", messageType);
}
}
}
// ==========================================
// Session management
// ==========================================
private void registerClientSession(String clientId, WebSocketSession session) {
// Close old session if same clientId reconnects
WebSocketSession oldSession = clientSessions.put(clientId, session);
if (oldSession != null && oldSession.isOpen() && !oldSession.getId().equals(session.getId())) {
try {
String oldSessionId = oldSession.getId();
sessionToClient.remove(oldSessionId);
oldSession.close(CloseStatus.NORMAL.withReason("Replaced by new connection"));
log.info("[WebSocket] Closed old session for clientId={} (replaced)", clientId);
} catch (IOException e) {
log.warn("[WebSocket] Error closing old session for client {}: {}", clientId, e.getMessage());
}
}
sessionToClient.put(session.getId(), clientId);
pendingSessions.remove(session.getId());
log.info("[WebSocket] Client registered: clientId={}, sessionId={}", clientId, session.getId());
}
private void cleanupPendingSessions() {
Instant cutoff = Instant.now().minusMillis(PENDING_SESSION_TIMEOUT_MS);
pendingSessions.entrySet().removeIf(entry -> {
if (entry.getValue().connectedAt.isBefore(cutoff)) {
try {
WebSocketSession session = entry.getValue().session;
if (session.isOpen()) {
session.close(CloseStatus.POLICY_VIOLATION.withReason("Login timeout"));
}
log.info("[WebSocket] Closed pending session (login timeout): sessionId={}", entry.getKey());
} catch (IOException e) {
log.warn("[WebSocket] Error closing pending session: {}", e.getMessage());
}
return true;
}
return false;
});
}
// ==========================================
// Utility methods
// ==========================================
private void sendToSession(WebSocketSession session, String message) throws IOException {
synchronized (session) {
if (session.isOpen()) {
session.sendMessage(new TextMessage(message));
}
}
}
// ==========================================
// Internal types
// ==========================================
private record PendingSession(WebSocketSession session, Instant connectedAt) {
}
}

View File

@@ -0,0 +1,53 @@
package de.assecutor.votianlt.model;
import lombok.Data;
/**
* Speichert das Ergebnis einer Adressvalidierung. Wird verwendet, um zu merken,
* ob eine Adresse bereits validiert wurde und ob sie gültig ist.
*/
@Data
public class AddressValidationResult {
private final String addressType; // "pickup" oder "delivery"
private final String street;
private final String houseNumber;
private final String zip;
private final String city;
private boolean valid;
private String formattedAddress;
private double latitude;
private double longitude;
private String validationMessage;
public AddressValidationResult(String addressType, String street, String houseNumber, String zip, String city) {
this.addressType = addressType;
this.street = street;
this.houseNumber = houseNumber;
this.zip = zip;
this.city = city;
this.valid = false;
}
/**
* Erstellt einen eindeutigen Schlüssel für diese Adresse
*/
public String getAddressKey() {
return String.format("%s|%s|%s|%s|%s", addressType, normalize(street), normalize(houseNumber), normalize(zip),
normalize(city));
}
private String normalize(String value) {
return value != null ? value.trim().toLowerCase() : "";
}
/**
* Prüft, ob diese Validierung für die angegebenen Adressdaten gilt
*/
public boolean matches(String street, String houseNumber, String zip, String city) {
return normalize(this.street).equals(normalize(street))
&& normalize(this.houseNumber).equals(normalize(houseNumber))
&& normalize(this.zip).equals(normalize(zip)) && normalize(this.city).equals(normalize(city));
}
}

View File

@@ -0,0 +1,81 @@
package de.assecutor.votianlt.model;
import com.fasterxml.jackson.annotation.JsonGetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.time.LocalDateTime;
@Data
@Document(collection = "app_user")
public class AppUser {
@Id
@JsonIgnore
private ObjectId id;
@Field("bezeichnung")
private String bezeichnung;
@Field("vorname")
private String vorname;
@Field("nachname")
private String nachname;
@Field("telefon")
private String telefon;
@Field("app_code")
private String appCode;
@Field("email")
@org.springframework.data.mongodb.core.index.Indexed(unique = true)
private String email;
@Field("password")
private String password;
// Reset-Token und Zeitstempel
@Field("password_code")
private String passwordCode;
@Field("password_timestamp")
private LocalDateTime passwordTimestamp;
@Field("geraet")
private String geraet;
@Field("owner")
private ObjectId owner;
@Field("erstellt_am")
private LocalDateTime erstelltAm;
@Field("erstellt_von")
private ObjectId erstelltVon;
@Field("aktualisiert_am")
private LocalDateTime aktualisiertAm;
@Field("aktualisiert_von")
private ObjectId aktualisiertVon;
public AppUser() {
this.erstelltAm = LocalDateTime.now();
this.aktualisiertAm = LocalDateTime.now();
}
/**
* Returns the ObjectId as string for JSON serialization. This ensures that the
* app user id is returned as a string when users are retrieved via API.
*/
@JsonGetter("id")
public String getIdAsString() {
return id != null ? id.toString() : null;
}
}

View File

@@ -0,0 +1,38 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.bson.types.ObjectId;
import java.time.LocalDateTime;
/**
* Barcode entity for storing barcode data from task completions. References the
* task ObjectId and stores barcode strings.
*/
@Data
@Document(collection = "barcodes")
public class Barcode {
@Id
private ObjectId id;
private ObjectId taskId;
private String barcode;
private LocalDateTime createdAt;
private String completedBy;
// Default constructor
public Barcode() {
this.createdAt = LocalDateTime.now();
}
// Constructor with parameters
public Barcode(ObjectId taskId, String barcode, String completedBy) {
this();
this.taskId = taskId;
this.barcode = barcode;
this.completedBy = completedBy;
}
}

View File

@@ -0,0 +1,51 @@
package de.assecutor.votianlt.model;
import com.fasterxml.jackson.annotation.JsonGetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "cargo_items")
public class CargoItem {
@Id
@JsonIgnore
private ObjectId id;
@Field("job_id")
private ObjectId jobId;
@Field("description")
private String description;
@Field("quantity")
private Integer quantity;
@Field("weight_kg")
private Double weightKg;
@Field("length_mm")
private Double lengthMm;
@Field("width_mm")
private Double widthMm;
@Field("height_mm")
private Double heightMm;
/**
* Returns the ObjectId as string for JSON serialization. This ensures that the
* cargo item id is returned as a string when items are retrieved via API.
*/
@JsonGetter("id")
public String getIdAsString() {
return id != null ? id.toString() : null;
}
}

View File

@@ -0,0 +1,38 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@Document(collection = "comments")
public class Comment {
@Id
private ObjectId id;
@Field("task_id")
private ObjectId taskId;
@Field("comment_text")
private String commentText;
@Field("completed_by")
private String completedBy;
@Field("created_at")
private LocalDateTime createdAt;
public Comment(ObjectId taskId, String commentText, String completedBy) {
this.taskId = taskId;
this.commentText = commentText;
this.completedBy = completedBy;
this.createdAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,16 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import org.bson.types.ObjectId;
@Data
public class Company {
private ObjectId id;
private String name;
private String street;
private String houseNumber;
private String addressAddition;
private String zip;
private String city;
}

View File

@@ -0,0 +1,56 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
@Data
@Document(collection = "customers")
public class Customer {
@Id
private ObjectId id;
@Field("title")
private String title;
@Field("company_name")
private String companyName;
@Field("firstname")
private String firstname;
@Field("last_name")
private String lastName;
@Field("telephone")
private String telephone;
@Field("fax")
private String fax;
@Field("mail")
private String mail;
@Field("street")
private String street;
@Field("house_number")
private String houseNumber;
@Field("address_addition")
private String addressAddition;
@Field("zip")
private String zip;
@Field("city")
private String city;
@Field("created_by")
private ObjectId createdBy;
@Field("owner")
private ObjectId owner;
}

View File

@@ -0,0 +1,84 @@
package de.assecutor.votianlt.model;
import com.fasterxml.jackson.annotation.JsonGetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import de.assecutor.votianlt.model.task.BaseTask;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.core.mapping.Field;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
/**
* Embedded delivery station within a Job. Each job can have up to 25 delivery
* stations. This is NOT a standalone MongoDB document - it is stored as part of
* the Job document.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DeliveryStation {
@Field("station_id")
@JsonIgnore
private ObjectId stationId;
@Field("station_order")
private int stationOrder;
@Field("company")
private String company;
@Field("salutation")
private String salutation;
@Field("first_name")
private String firstName;
@Field("last_name")
private String lastName;
@Field("phone")
private String phone;
@Field("street")
private String street;
@Field("house_number")
private String houseNumber;
@Field("address_addition")
private String addressAddition;
@Field("zip")
private String zip;
@Field("city")
private String city;
@Field("delivery_date")
private LocalDate deliveryDate;
@Field("delivery_time")
private LocalTime deliveryTime;
@Field("tasks")
private List<BaseTask> tasks = new ArrayList<>();
@JsonGetter("stationId")
public String getStationIdAsString() {
return stationId != null ? stationId.toHexString() : null;
}
public ObjectId ensureStationId() {
if (stationId == null) {
stationId = new ObjectId();
}
return stationId;
}
}

View File

@@ -0,0 +1,66 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
/**
* Stores invoice template data for a user. Contains the JSON representation of
* the canvas elements.
*/
@Document(collection = "invoice_templates")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class InvoiceTemplate {
@Id
private String id;
/**
* The user ID this template belongs to
*/
private String userId;
/**
* Template name (optional, for future use if multiple templates are supported)
*/
private String name;
/**
* JSON string containing the template data (canvas elements)
*/
private String templateData;
/**
* When the template was created
*/
private LocalDateTime createdAt;
/**
* When the template was last updated
*/
private LocalDateTime updatedAt;
/**
* Version for optimistic locking
*/
private Long version;
public InvoiceTemplate(String userId, String name, String templateData) {
this.userId = userId;
this.name = name;
this.templateData = templateData;
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
public void updateTemplate(String templateData) {
this.templateData = templateData;
this.updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,234 @@
package de.assecutor.votianlt.model;
import com.fasterxml.jackson.annotation.JsonGetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Data
@Document(collection = "jobs")
public class Job {
@Id
@JsonIgnore
private ObjectId id;
// Metadaten
@Field("job_number")
private String jobNumber; // Eindeutige Auftragsnummer
@Field("status")
private JobStatus status = JobStatus.CREATED; // Status des Auftrags
@Field("created_at")
private LocalDateTime createdAt;
@Field("updated_at")
private LocalDateTime updatedAt;
@Field("created_by")
private String createdBy; // Benutzer, der den Auftrag erstellt hat
@Field("is_draft")
private boolean isDraft = false; // Kennzeichnet Entwürfe
// Auftraggeber/Rechnungsempfänger
@Field("customer_selection")
private String customerSelection; // Kunde01 | KOTVor K01Nach
// Abholadresse
@Field("pickup_company")
private String pickupCompany;
@Field("pickup_salutation")
private String pickupSalutation;
@Field("pickup_first_name")
private String pickupFirstName;
@Field("pickup_last_name")
private String pickupLastName;
@Field("pickup_phone")
private String pickupPhone;
@Field("pickup_street")
private String pickupStreet;
@Field("pickup_house_number")
private String pickupHouseNumber;
@Field("pickup_address_addition")
private String pickupAddressAddition;
@Field("pickup_zip")
private String pickupZip;
@Field("pickup_city")
private String pickupCity;
// Lieferadresse
@Field("delivery_company")
private String deliveryCompany;
@Field("delivery_salutation")
private String deliverySalutation;
@Field("delivery_first_name")
private String deliveryFirstName;
@Field("delivery_last_name")
private String deliveryLastName;
@Field("delivery_phone")
private String deliveryPhone;
@Field("delivery_street")
private String deliveryStreet;
@Field("delivery_house_number")
private String deliveryHouseNumber;
@Field("delivery_address_addition")
private String deliveryAddressAddition;
@Field("delivery_zip")
private String deliveryZip;
@Field("delivery_city")
private String deliveryCity;
// Digitale Abwicklung per App
@Field("digital_processing")
private boolean digitalProcessing;
@Field("app_user")
private String appUser;
// Termine
@Field("pickup_date")
private LocalDate pickupDate;
@Field("pickup_time")
private LocalTime pickupTime;
@Field("delivery_date")
private LocalDate deliveryDate;
@Field("delivery_time")
private LocalTime deliveryTime;
// Bemerkung
@Field("remark")
private String remark;
// Preis (netto)
@Field("price")
private BigDecimal price;
// Gefahrene Kilometer für Rechnungsstellung
@Field("kilometers_driven")
private Integer kilometersDriven;
// Arbeitszeit in 15-Minuten-Einheiten für Rechnungsstellung
@Field("time_in_15min_units")
private Integer timeIn15MinUnits;
// Service-IDs für die Rechnung
@Field("service_ids")
private List<String> serviceIds;
// Ausgewählte Leistungen inkl. zugeordneter Lieferstation und Berechnungsbasis
@Field("selected_services")
private List<JobServiceSelection> selectedServices = new ArrayList<>();
// Streckeninformation für die Rechnung (in km)
@Field("route_distance_km")
private Double routeDistanceKm;
// Fahrtzeit in Sekunden für die Rechnung
@Field("route_duration_seconds")
private Integer routeDurationSeconds;
// Referenz auf die erstellte Rechnung
@Field("invoice_id")
private String invoiceId;
// Lieferstationen (bis zu 25)
@Field("delivery_stations")
private List<DeliveryStation> deliveryStations = new ArrayList<>();
/**
* Returns the ObjectId as string for JSON serialization. This ensures that the
* job id is returned as a string when jobs are retrieved via API.
*/
@JsonGetter("id")
public String getIdAsString() {
return id != null ? id.toString() : null;
}
/**
* Returns the first delivery station's city. Falls back to the flat
* deliveryCity field for backward compatibility with old jobs.
*/
public String getFirstDeliveryCity() {
if (deliveryStations != null && !deliveryStations.isEmpty()) {
return deliveryStations.get(0).getCity();
}
return deliveryCity;
}
/**
* Returns the last delivery station's city for route display.
*/
public String getLastDeliveryCity() {
if (deliveryStations != null && !deliveryStations.isEmpty()) {
return deliveryStations.get(deliveryStations.size() - 1).getCity();
}
return deliveryCity;
}
/**
* Returns all delivery cities joined with arrows for display (e.g. "Berlin →
* Dresden → München").
*/
public String getDeliveryCitiesDisplay() {
if (deliveryStations != null && !deliveryStations.isEmpty()) {
return deliveryStations.stream().map(DeliveryStation::getCity).filter(c -> c != null && !c.isBlank())
.collect(Collectors.joining(" \u2192 "));
}
return deliveryCity;
}
/**
* Populates the flat delivery fields from the first delivery station for
* backward compatibility. Call this before saving when using delivery stations.
*/
public void syncFlatDeliveryFieldsFromStations() {
if (deliveryStations != null && !deliveryStations.isEmpty()) {
DeliveryStation first = deliveryStations.get(0);
this.deliveryCompany = first.getCompany();
this.deliverySalutation = first.getSalutation();
this.deliveryFirstName = first.getFirstName();
this.deliveryLastName = first.getLastName();
this.deliveryPhone = first.getPhone();
this.deliveryStreet = first.getStreet();
this.deliveryHouseNumber = first.getHouseNumber();
this.deliveryAddressAddition = first.getAddressAddition();
this.deliveryZip = first.getZip();
this.deliveryCity = first.getCity();
this.deliveryDate = first.getDeliveryDate();
this.deliveryTime = first.getDeliveryTime();
}
}
}

View File

@@ -0,0 +1,99 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.bson.types.ObjectId;
import java.time.LocalDateTime;
/**
* Job History entity for tracking all changes made to a job. Each entry
* represents a single change or action performed on a job.
*/
@Data
@Document(collection = "job_history")
public class JobHistory {
@Id
private ObjectId id;
/**
* Reference to the job this history entry belongs to
*/
private ObjectId jobId;
/**
* Timestamp when the change occurred
*/
private LocalDateTime timestamp;
/**
* Reason for the change (e.g., "Status Update", "User Edit", "System Update")
*/
private String reason;
/**
* Description of what was changed (e.g., "Status changed from CREATED to
* IN_PROGRESS")
*/
private String description;
/**
* User who made the change (can be null for system changes)
*/
private String changedBy;
/**
* Additional details about the change (optional)
*/
private String details;
/**
* Type of change (CREATE, UPDATE, STATUS_CHANGE, DELETE, etc.)
*/
private JobHistoryType changeType;
/**
* Old value (for comparison, stored as JSON string if complex)
*/
private String oldValue;
/**
* New value (for comparison, stored as JSON string if complex)
*/
private String newValue;
// Default constructor
public JobHistory() {
this.timestamp = LocalDateTime.now();
}
// Constructor for basic history entry
public JobHistory(ObjectId jobId, String reason, String description, String changedBy) {
this();
this.jobId = jobId;
this.reason = reason;
this.description = description;
this.changedBy = changedBy;
}
// Constructor for detailed history entry
public JobHistory(ObjectId jobId, String reason, String description, String changedBy, JobHistoryType changeType,
String oldValue, String newValue) {
this(jobId, reason, description, changedBy);
this.changeType = changeType;
this.oldValue = oldValue;
this.newValue = newValue;
}
// Getter for ID as String
public String getIdAsString() {
return id != null ? id.toHexString() : null;
}
// Getter for Job ID as String
public String getJobIdAsString() {
return jobId != null ? jobId.toHexString() : null;
}
}

View File

@@ -0,0 +1,56 @@
package de.assecutor.votianlt.model;
/**
* Enumeration of different types of job history changes
*/
public enum JobHistoryType {
/**
* Job was created
*/
CREATE,
/**
* Job data was updated
*/
UPDATE,
/**
* Job status was changed
*/
STATUS_CHANGE,
/**
* Job was assigned to a user
*/
ASSIGNMENT,
/**
* Task was completed within the job
*/
TASK_COMPLETED,
/**
* Job was exported or shared
*/
EXPORT,
/**
* Job was deleted or archived
*/
DELETE,
/**
* System-generated change
*/
SYSTEM,
/**
* Comment or note was added
*/
COMMENT,
/**
* Other type of change
*/
OTHER
}

View File

@@ -0,0 +1,20 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import org.springframework.data.mongodb.core.mapping.Field;
@Data
public class JobServiceSelection {
@Field("service_id")
private String serviceId;
@Field("delivery_station_order")
private Integer deliveryStationOrder;
@Field("route_distance_km")
private Double routeDistanceKm;
@Field("route_duration_seconds")
private Integer routeDurationSeconds;
}

View File

@@ -0,0 +1,30 @@
package de.assecutor.votianlt.model;
/**
* Status-Enum für Aufträge
*/
public enum JobStatus {
CREATED("Erstellt"),
IN_PROGRESS("In Bearbeitung"),
PICKUP_SCHEDULED("Abholung geplant"),
PICKED_UP("Abgeholt"),
IN_TRANSIT("Unterwegs"),
DELIVERED("Zugestellt"),
COMPLETED("Abgeschlossen"),
CANCELLED("Storniert");
private final String displayName;
JobStatus(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
@Override
public String toString() {
return displayName;
}
}

View File

@@ -0,0 +1,33 @@
package de.assecutor.votianlt.model;
public enum Language {
DE("Deutsch"),
EN("English"),
FR("Français"),
ES("Español"),
TR("Türkçe"),
PL("Polski"),
RU("Русский"),
EE("Eesti"),
LV("Latviešu"),
LT("Lietuvių");
private final String displayName;
Language(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
public static Language fromString(String text) {
for (Language language : Language.values()) {
if (language.name().equalsIgnoreCase(text)) {
return language;
}
}
return DE; // Default to German
}
}

View File

@@ -0,0 +1,92 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.time.Instant;
/**
* Represents a GPS position reported by a mobile client.
*/
@Data
@NoArgsConstructor
@Document(collection = "location_positions")
public class LocationPosition {
@Id
private ObjectId id;
/**
* AppUser ID (clientId) - the user who sent this position
*/
@Field("app_user_id")
@Indexed
private String appUserId;
/**
* Latitude in decimal degrees
*/
@Field("latitude")
private Double latitude;
/**
* Longitude in decimal degrees
*/
@Field("longitude")
private Double longitude;
/**
* Accuracy of the position in meters
*/
@Field("accuracy")
private Double accuracy;
/**
* Altitude in meters above sea level (optional)
*/
@Field("altitude")
private Double altitude;
/**
* Speed in meters per second (optional)
*/
@Field("speed")
private Double speed;
/**
* Heading in degrees (0-360) (optional)
*/
@Field("heading")
private Double heading;
/**
* Timestamp when the position was reported (from client)
*/
@Field("timestamp")
private Instant timestamp;
/**
* Timestamp when the position was received by the server
*/
@Field("received_at")
@Indexed(expireAfter = "3600s") // TTL index: auto-delete after 60 minutes
private Instant receivedAt;
public LocationPosition(String appUserId, Double latitude, Double longitude, Double accuracy, Double altitude,
Double speed, Double heading, Instant timestamp) {
this.appUserId = appUserId;
this.latitude = latitude;
this.longitude = longitude;
this.accuracy = accuracy;
this.altitude = altitude;
this.speed = speed;
this.heading = heading;
this.timestamp = timestamp;
this.receivedAt = Instant.now();
}
}

View File

@@ -0,0 +1,190 @@
package de.assecutor.votianlt.model;
import com.fasterxml.jackson.annotation.JsonGetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.time.LocalDateTime;
/**
* Represents a message that can be sent between the server and clients.
* Messages can be either job-related or general messages.
*/
@Data
@NoArgsConstructor
@Document(collection = "messages")
public class Message {
@Id
@JsonIgnore
private ObjectId id;
/**
* Content of the message, either plain text or base64 encoded media
*/
@Field("content")
private String content;
/**
* Declares how to interpret the content payload
*/
@Field("content_type")
private MessageContentType contentType = MessageContentType.TEXT;
/**
* AppUser ID (clientId) - the AppUser to whom this message belongs
*/
@Field("receiver")
private String receiver;
/**
* Timestamp when the message was created
*/
@Field("created_at")
private LocalDateTime createdAt;
/**
* Origin of the message: INCOMING (from client), OUTGOING (to client), or
* SERVER (from server)
*/
@Field("origin")
private MessageOrigin origin;
/**
* Type of message: JOB_RELATED or GENERAL
*/
@Field("message_type")
private MessageType messageType;
/**
* Optional reference to a job (only for job-related messages)
*/
@Field("job_id")
private ObjectId jobId;
/**
* Optional job number for easier reference (denormalized)
*/
@Field("job_number")
private String jobNumber;
/**
* Whether the message has been read by the receiver
*/
@Field("is_read")
private boolean isRead;
/**
* Timestamp when the message was read
*/
@Field("read_at")
private LocalDateTime readAt;
/**
* Delivery status: NOTSEND (failed to deliver), SEND (successfully delivered)
*/
@Field("delivery_status")
private MessageDeliveryStatus deliveryStatus;
/**
* Constructor for general messages
*/
public Message(String content, String receiver, MessageOrigin origin) {
this(content, receiver, origin, MessageContentType.TEXT);
}
/**
* Constructor for general messages with explicit content type
*/
public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType) {
initializeBaseFields(content, receiver, origin, contentType);
this.messageType = MessageType.GENERAL;
}
/**
* Constructor for job-related messages
*/
public Message(String content, String receiver, MessageOrigin origin, ObjectId jobId, String jobNumber) {
this(content, receiver, origin, MessageContentType.TEXT, jobId, jobNumber);
}
/**
* Constructor for job-related messages with explicit content type
*/
public Message(String content, String receiver, MessageOrigin origin, MessageContentType contentType,
ObjectId jobId, String jobNumber) {
initializeBaseFields(content, receiver, origin, contentType);
this.messageType = MessageType.JOB_RELATED;
this.jobId = jobId;
this.jobNumber = jobNumber;
}
/**
* Mark the message as read
*/
public void markAsRead() {
this.isRead = true;
this.readAt = LocalDateTime.now();
}
/**
* Mark the message as sent (successfully delivered)
*/
public void markAsSent() {
this.deliveryStatus = MessageDeliveryStatus.SEND;
}
/**
* Mark the message as not sent (delivery failed)
*/
public void markAsNotSent() {
this.deliveryStatus = MessageDeliveryStatus.NOTSEND;
}
/**
* Check if message was successfully delivered
*/
public boolean isDelivered() {
return deliveryStatus == MessageDeliveryStatus.SEND;
}
/**
* Returns the ObjectId as string for JSON serialization
*/
@JsonGetter("id")
public String getIdAsString() {
return id != null ? id.toString() : null;
}
/**
* Returns the job ObjectId as string for JSON serialization
*/
@JsonGetter("jobId")
public String getJobIdAsString() {
return jobId != null ? jobId.toString() : null;
}
/**
* Ensure callers always receive a non-null content type to simplify rendering
*/
public MessageContentType getContentType() {
return contentType != null ? contentType : MessageContentType.TEXT;
}
private void initializeBaseFields(String content, String receiver, MessageOrigin origin,
MessageContentType contentType) {
this.content = content;
this.receiver = receiver;
this.origin = origin;
this.createdAt = LocalDateTime.now();
this.isRead = false;
this.contentType = contentType != null ? contentType : MessageContentType.TEXT;
// Server messages start as NOTSEND until confirmed delivered
this.deliveryStatus = (origin == MessageOrigin.SERVER) ? MessageDeliveryStatus.NOTSEND : null;
}
}

View File

@@ -0,0 +1,8 @@
package de.assecutor.votianlt.model;
/**
* Supported content variants for chat messages.
*/
public enum MessageContentType {
TEXT, IMAGE
}

View File

@@ -0,0 +1,24 @@
package de.assecutor.votianlt.model;
/**
* Delivery status for messages sent to clients. Tracks whether a message was
* successfully delivered via WebSocket.
*/
public enum MessageDeliveryStatus {
NOTSEND("Nicht gesendet"), SEND("Gesendet");
private final String displayName;
MessageDeliveryStatus(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
@Override
public String toString() {
return displayName;
}
}

View File

@@ -0,0 +1,16 @@
package de.assecutor.votianlt.model;
/**
* Enum representing the origin of a message
*/
public enum MessageOrigin {
/**
* Message received from a client (app user)
*/
CLIENT,
/**
* Message sent from the server
*/
SERVER
}

View File

@@ -0,0 +1,16 @@
package de.assecutor.votianlt.model;
/**
* Enum representing the type of message
*/
public enum MessageType {
/**
* General message not related to a specific job
*/
GENERAL,
/**
* Message related to a specific job
*/
JOB_RELATED
}

View File

@@ -0,0 +1,38 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.bson.types.ObjectId;
import java.time.LocalDateTime;
/**
* Photo entity for storing photo data from task completions. References the job
* ObjectId and stores base64 encoded photos.
*/
@Data
@Document(collection = "photos")
public class Photo {
@Id
private ObjectId id;
private ObjectId taskId;
private String photo; // base64 encoded photos
private LocalDateTime createdAt;
private String completedBy;
// Default constructor
public Photo() {
this.createdAt = LocalDateTime.now();
}
// Constructor with parameters
public Photo(ObjectId taskId, String photo, String completedBy) {
this();
this.taskId = taskId;
this.photo = photo;
this.completedBy = completedBy;
}
}

View File

@@ -0,0 +1,67 @@
package de.assecutor.votianlt.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
@Document(collection = "price_table")
public class PriceTable {
@Id
private String id;
private String monthlyBasePackage;
private String appUsageLicense;
private String revenueParticipation;
private String statisticalEvaluation;
public PriceTable() {
}
public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation,
String statisticalEvaluation) {
this.monthlyBasePackage = monthlyBasePackage;
this.appUsageLicense = appUsageLicense;
this.revenueParticipation = revenueParticipation;
this.statisticalEvaluation = statisticalEvaluation;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getMonthlyBasePackage() {
return monthlyBasePackage;
}
public void setMonthlyBasePackage(String monthlyBasePackage) {
this.monthlyBasePackage = monthlyBasePackage;
}
public String getAppUsageLicense() {
return appUsageLicense;
}
public void setAppUsageLicense(String appUsageLicense) {
this.appUsageLicense = appUsageLicense;
}
public String getRevenueParticipation() {
return revenueParticipation;
}
public void setRevenueParticipation(String revenueParticipation) {
this.revenueParticipation = revenueParticipation;
}
public String getStatisticalEvaluation() {
return statisticalEvaluation;
}
public void setStatisticalEvaluation(String statisticalEvaluation) {
this.statisticalEvaluation = statisticalEvaluation;
}
}

View File

@@ -0,0 +1,44 @@
package de.assecutor.votianlt.model;
import lombok.Data;
/**
* Speichert das Ergebnis einer Routenberechnung zwischen zwei Adressen.
*/
@Data
public class RouteCalculationResult {
private boolean valid;
private double distanceKm;
private int durationSeconds;
private String formattedDistance;
private String formattedDuration;
private String routeMessage;
public RouteCalculationResult() {
this.valid = false;
this.distanceKm = 0.0;
this.durationSeconds = 0;
}
/**
* Gibt die Dauer in Minuten zurück
*/
public int getDurationMinutes() {
return durationSeconds / 60;
}
/**
* Gibt die Dauer formatiert zurück (z.B. "1 Std. 30 Min." oder "45 Min.")
*/
public String getFormattedDurationLong() {
int hours = durationSeconds / 3600;
int minutes = (durationSeconds % 3600) / 60;
if (hours > 0) {
return String.format("%d Std. %d Min.", hours, minutes);
} else {
return String.format("%d Min.", minutes);
}
}
}

View File

@@ -0,0 +1,136 @@
package de.assecutor.votianlt.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.math.BigDecimal;
@Document(collection = "services")
public class Service {
public static final BigDecimal FIXED_VAT_RATE = new BigDecimal("0.19");
@Id
private String id;
private String userId;
private String name;
private CalculationBasis calculationBasis;
private BigDecimal price; // For FLAT_RATE services
private BigDecimal pricePerKilometer; // For DISTANCE services - price per kilometer
private BigDecimal pricePer15Minutes; // For TIME services - price per 15 minutes
private boolean mandatory;
public enum CalculationBasis {
DISTANCE, TIME, FLAT_RATE
}
public Service() {
}
public Service(String userId, String name, CalculationBasis calculationBasis, BigDecimal price,
BigDecimal vatRate) {
this(userId, name, calculationBasis, price, vatRate, false);
}
public Service(String userId, String name, CalculationBasis calculationBasis, BigDecimal price, BigDecimal vatRate,
boolean mandatory) {
this.userId = userId;
this.name = name;
this.calculationBasis = calculationBasis;
this.mandatory = mandatory;
// Set the appropriate price field based on calculation basis
switch (calculationBasis) {
case DISTANCE:
this.pricePerKilometer = price;
break;
case TIME:
this.pricePer15Minutes = price;
break;
case FLAT_RATE:
this.price = price;
break;
}
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public CalculationBasis getCalculationBasis() {
return calculationBasis;
}
public void setCalculationBasis(CalculationBasis calculationBasis) {
this.calculationBasis = calculationBasis;
}
public BigDecimal getVatRate() {
return FIXED_VAT_RATE;
}
public void setVatRate(BigDecimal vatRate) {
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public BigDecimal getPricePerKilometer() {
return pricePerKilometer;
}
public void setPricePerKilometer(BigDecimal pricePerKilometer) {
this.pricePerKilometer = pricePerKilometer;
}
public BigDecimal getPricePer15Minutes() {
return pricePer15Minutes;
}
public void setPricePer15Minutes(BigDecimal pricePer15Minutes) {
this.pricePer15Minutes = pricePer15Minutes;
}
/**
* Get the appropriate price based on calculation basis
*/
public BigDecimal getEffectivePrice() {
return switch (calculationBasis) {
case DISTANCE -> pricePerKilometer;
case TIME -> pricePer15Minutes;
case FLAT_RATE -> price;
};
}
public boolean isMandatory() {
return mandatory;
}
public void setMandatory(boolean mandatory) {
this.mandatory = mandatory;
}
}

View File

@@ -0,0 +1,38 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.bson.types.ObjectId;
import java.time.LocalDateTime;
/**
* Signature entity for storing signature SVG data from task completions.
* References the task ObjectId and stores SVG signature strings.
*/
@Data
@Document(collection = "signatures")
public class Signature {
@Id
private ObjectId id;
private ObjectId taskId;
private String signatureSvg;
private LocalDateTime createdAt;
private String completedBy;
// Default constructor
public Signature() {
this.createdAt = LocalDateTime.now();
}
// Constructor with parameters
public Signature(ObjectId taskId, String signatureSvg, String completedBy) {
this();
this.taskId = taskId;
this.signatureSvg = signatureSvg;
this.completedBy = completedBy;
}
}

View File

@@ -0,0 +1,121 @@
package de.assecutor.votianlt.model;
import com.fasterxml.jackson.annotation.JsonGetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "tasks")
public class TaskEntry {
@Id
@JsonIgnore
private ObjectId id;
@Field("station_id")
@JsonIgnore
private ObjectId stationId;
@Field("job_id")
@JsonIgnore
private ObjectId jobId;
@Field("task_type")
private TaskType taskType = TaskType.CONFIRMATION;
// Task-specific configuration data
@Field("configuration")
private TaskConfiguration configuration;
// Completion tracking
@Field("completed")
private boolean completed = false;
@Field("completed_at")
private LocalDateTime completedAt;
@Field("completed_by")
private String completedBy;
/**
* Returns the ObjectId as string for JSON serialization. This ensures that the
* task id is returned as a string when jobs are retrieved via API.
*/
@JsonGetter("id")
public String getIdAsString() {
return id != null ? id.toString() : null;
}
/**
* Returns the station ObjectId as string for JSON serialization.
*/
@JsonGetter("stationId")
public String getStationIdAsString() {
return stationId != null ? stationId.toHexString() : null;
}
/**
* Returns the legacy job ObjectId as string for internal fallback handling.
*/
public String getJobIdAsString() {
return jobId != null ? jobId.toString() : null;
}
/**
* Enum for different task types
*/
public enum TaskType {
CONFIRMATION(
"Bestätigung"),
SIGNATURE("Unterschrift"),
TODOLIST("To-Do Liste"),
PHOTO("Foto"),
BARCODE("Barcode");
private final String displayName;
TaskType(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}
/**
* Configuration data for different task types
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class TaskConfiguration {
// For CONFIRMATION: button text
private String buttonText;
// For TODOLIST: list of todo items
private List<String> todoItems;
// For PHOTO: min and max photo count
private Integer minPhotoCount;
private Integer maxPhotoCount;
// For BARCODE: min and max barcode count
private Integer minBarcodeCount;
private Integer maxBarcodeCount;
// Generic configuration map for future extensions
private Map<String, Object> additionalConfig;
}
}

View File

@@ -0,0 +1,37 @@
package de.assecutor.votianlt.model;
import de.assecutor.votianlt.model.task.BaseTask;
import lombok.Data;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.index.Indexed;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Document(collection = "task_templates")
public class TaskTemplate {
@Id
private ObjectId id;
@Indexed
private ObjectId userId;
private String templateName;
private List<BaseTask> tasks;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public TaskTemplate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
public void updateTimestamp() {
this.updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,51 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.time.LocalDateTime;
import java.util.Map;
/**
* MongoDB document for caching LLM translations. Stores the original text, all
* translations keyed by language code, and the insertion timestamp.
*/
@Data
@NoArgsConstructor
@Document(collection = "translation_cache")
public class TranslationCacheEntry {
@Id
private ObjectId id;
/**
* The original text that was translated.
*/
@Indexed(unique = true)
@Field("source_text")
private String sourceText;
/**
* Translations keyed by language code (e.g. "de", "en", "fr").
*/
@Field("translations")
private Map<String, String> translations;
/**
* When this entry was inserted into the cache.
*/
@Indexed
@Field("inserted_at")
private LocalDateTime insertedAt;
public TranslationCacheEntry(String sourceText, Map<String, String> translations) {
this.sourceText = sourceText;
this.translations = translations;
this.insertedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,71 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.index.Indexed;
import java.time.LocalDateTime;
import java.util.Set;
@Data
@Document(collection = "users")
public class User {
@Id
private ObjectId id;
private int usrId;
private String title;
private String name; // Nachname
private String firstname; // Vorname
// Firmen-/Adressdaten
private String company; // Firma
private String companyAddition; // Firmenzusatz
private String street; // Straße
private String houseNumber; // Hausnr
private String addressAddition; // Adresszusatz (optional)
private String zip; // Postleitzahl
private String city; // Stadt
// Abweichende Rechnungsadresse
private boolean diffInvoiceAddress; // Checkbox für abweichende Rechnungsadresse
private String invCompany; // Rechnungsadresse: Firma
private String invCompanyAddition; // Rechnungsadresse: Firmenzusatz
private String invFirstname; // Rechnungsadresse: Vorname
private String invLastname; // Rechnungsadresse: Nachname
private String invStreet; // Rechnungsadresse: Straße
private String invHouseNumber; // Rechnungsadresse: Hausnr
private String invAddressAddition; // Rechnungsadresse: Adresszusatz
private String invZip; // Rechnungsadresse: Postleitzahl
private String invCity; // Rechnungsadresse: Stadt
@Indexed(unique = true)
private String email;
private String phone;
private String phone2;
private String fax;
private String password;
private byte isActivated;
private byte isEmailConfirmed;
private String passwordCode;
private LocalDateTime passwordTimestamp;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Set<String> roles;
// Digitale Abwicklung und App-Nutzer Ortung
private boolean digitalProcessingEnabled = true; // Digitale Abwicklung per App
private boolean locationTrackingEnabled = true; // App-Nutzer orten
// 2-Faktor-Authentifizierung (standardmäßig aktiviert für neue Nutzer)
private boolean twoFactorEnabled = true;
// Spracheinstellung (standardmäßig Deutsch)
@Field("language")
private Language language = Language.DE;
}

View File

@@ -0,0 +1,44 @@
package de.assecutor.votianlt.model;
import lombok.Data;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.index.Indexed;
import java.time.LocalDateTime;
@Data
@Document(collection = "user_invoice_data")
public class UserInvoiceData {
@Id
private ObjectId id;
@Indexed
private ObjectId userId;
private boolean billingEnabled;
private String prefix;
private String ustId;
private String taxNumber;
private String bankName;
private String iban;
private String taxRate;
private String introText;
private String paymentTerms;
private long nextInvoiceNumber = 0;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public UserInvoiceData() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
public void updateTimestamp() {
this.updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,375 @@
package de.assecutor.votianlt.model.invoices;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDate;
import java.math.BigDecimal;
import java.util.List;
@Document(collection = "customerInvoices")
public class CustomerInvoice {
@Id
private String id;
// Pflichtangaben nach §14 UStG (German VAT law)
private String invoiceNumber; // Fortlaufende Rechnungsnummer
private LocalDate invoiceDate; // Rechnungsdatum
private LocalDate deliveryDate; // Leistungsdatum
// Rechnungssteller (Sender)
private String senderName;
private String senderAddress;
private String senderPostcode;
private String senderCity;
private String senderCountry;
private String senderTaxNumber; // Steuernummer
private String senderVatId; // USt-IdNr.
private String senderPhone;
private String senderEmail;
private String senderWebsite;
// Rechnungsempfänger (Recipient)
private String recipientName;
private String recipientCompany;
private String recipientAddress;
private String recipientPostcode;
private String recipientCity;
private String recipientCountry;
private String recipientVatId; // USt-IdNr. des Empfängers (falls vorhanden)
// Rechnungsdetails
private String description; // Beschreibung der Leistung
private List<CustomerInvoiceItem> items;
// Beträge
private BigDecimal netAmount; // Nettobetrag
private BigDecimal vatRate; // Steuersatz (z.B. 19% = 0.19)
private BigDecimal vatAmount; // Steuerbetrag
private BigDecimal totalAmount; // Bruttobetrag
// Zahlungsdetails
private String paymentTerms; // Zahlungsbedingungen
private LocalDate paymentDueDate; // Fälligkeitsdatum
private String bankAccount; // Bankverbindung
private String iban;
private String bic;
// Zusätzliche rechtliche Angaben
private String legalNotes; // Rechtliche Hinweise
private String reverseChargeNote; // Hinweis auf Reverse Charge (falls zutreffend)
// Verknüpfung mit Auftrag und Benutzer
private String jobId; // Referenz auf den Auftrag
private String userId; // Referenz auf den Benutzer (Rechnungsersteller)
// Gespeicherte PDF-Daten (Base64-kodiert)
private byte[] pdfData;
// Constructors
public CustomerInvoice() {
}
public CustomerInvoice(String invoiceNumber, LocalDate invoiceDate, String description) {
this.invoiceNumber = invoiceNumber;
this.invoiceDate = invoiceDate;
this.description = description;
}
// Getters and Setters
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getInvoiceNumber() {
return invoiceNumber;
}
public void setInvoiceNumber(String invoiceNumber) {
this.invoiceNumber = invoiceNumber;
}
public LocalDate getInvoiceDate() {
return invoiceDate;
}
public void setInvoiceDate(LocalDate invoiceDate) {
this.invoiceDate = invoiceDate;
}
public LocalDate getDeliveryDate() {
return deliveryDate;
}
public void setDeliveryDate(LocalDate deliveryDate) {
this.deliveryDate = deliveryDate;
}
public String getSenderName() {
return senderName;
}
public void setSenderName(String senderName) {
this.senderName = senderName;
}
public String getSenderAddress() {
return senderAddress;
}
public void setSenderAddress(String senderAddress) {
this.senderAddress = senderAddress;
}
public String getSenderPostcode() {
return senderPostcode;
}
public void setSenderPostcode(String senderPostcode) {
this.senderPostcode = senderPostcode;
}
public String getSenderCity() {
return senderCity;
}
public void setSenderCity(String senderCity) {
this.senderCity = senderCity;
}
public String getSenderCountry() {
return senderCountry;
}
public void setSenderCountry(String senderCountry) {
this.senderCountry = senderCountry;
}
public String getSenderTaxNumber() {
return senderTaxNumber;
}
public void setSenderTaxNumber(String senderTaxNumber) {
this.senderTaxNumber = senderTaxNumber;
}
public String getSenderVatId() {
return senderVatId;
}
public void setSenderVatId(String senderVatId) {
this.senderVatId = senderVatId;
}
public String getSenderPhone() {
return senderPhone;
}
public void setSenderPhone(String senderPhone) {
this.senderPhone = senderPhone;
}
public String getSenderEmail() {
return senderEmail;
}
public void setSenderEmail(String senderEmail) {
this.senderEmail = senderEmail;
}
public String getSenderWebsite() {
return senderWebsite;
}
public void setSenderWebsite(String senderWebsite) {
this.senderWebsite = senderWebsite;
}
public String getRecipientName() {
return recipientName;
}
public void setRecipientName(String recipientName) {
this.recipientName = recipientName;
}
public String getRecipientCompany() {
return recipientCompany;
}
public void setRecipientCompany(String recipientCompany) {
this.recipientCompany = recipientCompany;
}
public String getRecipientAddress() {
return recipientAddress;
}
public void setRecipientAddress(String recipientAddress) {
this.recipientAddress = recipientAddress;
}
public String getRecipientPostcode() {
return recipientPostcode;
}
public void setRecipientPostcode(String recipientPostcode) {
this.recipientPostcode = recipientPostcode;
}
public String getRecipientCity() {
return recipientCity;
}
public void setRecipientCity(String recipientCity) {
this.recipientCity = recipientCity;
}
public String getRecipientCountry() {
return recipientCountry;
}
public void setRecipientCountry(String recipientCountry) {
this.recipientCountry = recipientCountry;
}
public String getRecipientVatId() {
return recipientVatId;
}
public void setRecipientVatId(String recipientVatId) {
this.recipientVatId = recipientVatId;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public List<CustomerInvoiceItem> getItems() {
return items;
}
public void setItems(List<CustomerInvoiceItem> items) {
this.items = items;
}
public BigDecimal getNetAmount() {
return netAmount;
}
public void setNetAmount(BigDecimal netAmount) {
this.netAmount = netAmount;
}
public BigDecimal getVatRate() {
return vatRate;
}
public void setVatRate(BigDecimal vatRate) {
this.vatRate = vatRate;
}
public BigDecimal getVatAmount() {
return vatAmount;
}
public void setVatAmount(BigDecimal vatAmount) {
this.vatAmount = vatAmount;
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
public void setTotalAmount(BigDecimal totalAmount) {
this.totalAmount = totalAmount;
}
public String getPaymentTerms() {
return paymentTerms;
}
public void setPaymentTerms(String paymentTerms) {
this.paymentTerms = paymentTerms;
}
public LocalDate getPaymentDueDate() {
return paymentDueDate;
}
public void setPaymentDueDate(LocalDate paymentDueDate) {
this.paymentDueDate = paymentDueDate;
}
public String getBankAccount() {
return bankAccount;
}
public void setBankAccount(String bankAccount) {
this.bankAccount = bankAccount;
}
public String getIban() {
return iban;
}
public void setIban(String iban) {
this.iban = iban;
}
public String getBic() {
return bic;
}
public void setBic(String bic) {
this.bic = bic;
}
public String getLegalNotes() {
return legalNotes;
}
public void setLegalNotes(String legalNotes) {
this.legalNotes = legalNotes;
}
public String getReverseChargeNote() {
return reverseChargeNote;
}
public void setReverseChargeNote(String reverseChargeNote) {
this.reverseChargeNote = reverseChargeNote;
}
public String getJobId() {
return jobId;
}
public void setJobId(String jobId) {
this.jobId = jobId;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public byte[] getPdfData() {
return pdfData;
}
public void setPdfData(byte[] pdfData) {
this.pdfData = pdfData;
}
}

View File

@@ -0,0 +1,383 @@
package de.assecutor.votianlt.model.invoices;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.math.BigDecimal;
import java.text.NumberFormat;
import java.util.List;
import java.util.Locale;
public class CustomerInvoiceData {
private String invoiceNumber;
private LocalDate invoiceDate;
private LocalDate deliveryDate;
private String description;
// Rechnungssteller
private String senderName;
private String senderAddress;
private String senderPostcode;
private String senderCity;
private String senderCountry;
private String senderTaxNumber;
private String senderVatId;
private String senderPhone;
private String senderEmail;
private String senderWebsite;
// Rechnungsempfänger
private String recipientName;
private String recipientCompany;
private String recipientAddress;
private String recipientPostcode;
private String recipientCity;
private String recipientCountry;
private String recipientVatId;
private List<CustomerInvoiceItem> items;
private BigDecimal netAmount;
private BigDecimal vatRate;
private BigDecimal vatAmount;
private BigDecimal totalAmount;
// Zahlungsdetails
private String paymentTerms;
private LocalDate paymentDueDate;
private String bankAccount;
private String iban;
private String bic;
// Rechtliche Angaben
private String legalNotes;
private String reverseChargeNote;
// Number formatter for German locale
private static final NumberFormat CURRENCY_FORMAT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
// Constructors
public CustomerInvoiceData() {
}
public CustomerInvoiceData(String invoiceNumber, LocalDate invoiceDate, String description) {
this.invoiceNumber = invoiceNumber;
this.invoiceDate = invoiceDate;
this.description = description;
}
// Getters and Setters
public String getInvoiceNumber() {
return invoiceNumber;
}
public void setInvoiceNumber(String invoiceNumber) {
this.invoiceNumber = invoiceNumber;
}
public LocalDate getInvoiceDate() {
return invoiceDate;
}
public void setInvoiceDate(LocalDate invoiceDate) {
this.invoiceDate = invoiceDate;
}
public LocalDate getDeliveryDate() {
return deliveryDate;
}
public void setDeliveryDate(LocalDate deliveryDate) {
this.deliveryDate = deliveryDate;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getSenderName() {
return senderName;
}
public void setSenderName(String senderName) {
this.senderName = senderName;
}
public String getSenderAddress() {
return senderAddress;
}
public void setSenderAddress(String senderAddress) {
this.senderAddress = senderAddress;
}
public String getSenderPostcode() {
return senderPostcode;
}
public void setSenderPostcode(String senderPostcode) {
this.senderPostcode = senderPostcode;
}
public String getSenderCity() {
return senderCity;
}
public void setSenderCity(String senderCity) {
this.senderCity = senderCity;
}
public String getSenderCountry() {
return senderCountry;
}
public void setSenderCountry(String senderCountry) {
this.senderCountry = senderCountry;
}
public String getSenderTaxNumber() {
return senderTaxNumber;
}
public void setSenderTaxNumber(String senderTaxNumber) {
this.senderTaxNumber = senderTaxNumber;
}
public String getSenderVatId() {
return senderVatId;
}
public void setSenderVatId(String senderVatId) {
this.senderVatId = senderVatId;
}
public String getSenderPhone() {
return senderPhone;
}
public void setSenderPhone(String senderPhone) {
this.senderPhone = senderPhone;
}
public String getSenderEmail() {
return senderEmail;
}
public void setSenderEmail(String senderEmail) {
this.senderEmail = senderEmail;
}
public String getSenderWebsite() {
return senderWebsite;
}
public void setSenderWebsite(String senderWebsite) {
this.senderWebsite = senderWebsite;
}
public String getRecipientName() {
return recipientName;
}
public void setRecipientName(String recipientName) {
this.recipientName = recipientName;
}
public String getRecipientCompany() {
return recipientCompany;
}
public void setRecipientCompany(String recipientCompany) {
this.recipientCompany = recipientCompany;
}
public String getRecipientAddress() {
return recipientAddress;
}
public void setRecipientAddress(String recipientAddress) {
this.recipientAddress = recipientAddress;
}
public String getRecipientPostcode() {
return recipientPostcode;
}
public void setRecipientPostcode(String recipientPostcode) {
this.recipientPostcode = recipientPostcode;
}
public String getRecipientCity() {
return recipientCity;
}
public void setRecipientCity(String recipientCity) {
this.recipientCity = recipientCity;
}
public String getRecipientCountry() {
return recipientCountry;
}
public void setRecipientCountry(String recipientCountry) {
this.recipientCountry = recipientCountry;
}
public String getRecipientVatId() {
return recipientVatId;
}
public void setRecipientVatId(String recipientVatId) {
this.recipientVatId = recipientVatId;
}
public List<CustomerInvoiceItem> getItems() {
return items;
}
public void setItems(List<CustomerInvoiceItem> items) {
this.items = items;
}
public BigDecimal getNetAmount() {
return netAmount;
}
public void setNetAmount(BigDecimal netAmount) {
this.netAmount = netAmount;
}
public BigDecimal getVatRate() {
return vatRate;
}
public void setVatRate(BigDecimal vatRate) {
this.vatRate = vatRate;
}
public BigDecimal getVatAmount() {
return vatAmount;
}
public void setVatAmount(BigDecimal vatAmount) {
this.vatAmount = vatAmount;
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
public void setTotalAmount(BigDecimal totalAmount) {
this.totalAmount = totalAmount;
}
public String getPaymentTerms() {
return paymentTerms;
}
public void setPaymentTerms(String paymentTerms) {
this.paymentTerms = paymentTerms;
}
public LocalDate getPaymentDueDate() {
return paymentDueDate;
}
public void setPaymentDueDate(LocalDate paymentDueDate) {
this.paymentDueDate = paymentDueDate;
}
public String getBankAccount() {
return bankAccount;
}
public void setBankAccount(String bankAccount) {
this.bankAccount = bankAccount;
}
public String getIban() {
return iban;
}
public void setIban(String iban) {
this.iban = iban;
}
public String getBic() {
return bic;
}
public void setBic(String bic) {
this.bic = bic;
}
public String getLegalNotes() {
return legalNotes;
}
public void setLegalNotes(String legalNotes) {
this.legalNotes = legalNotes;
}
public String getReverseChargeNote() {
return reverseChargeNote;
}
public void setReverseChargeNote(String reverseChargeNote) {
this.reverseChargeNote = reverseChargeNote;
}
// Formatting methods for PDF generation
public String getFormattedInvoiceDate() {
return invoiceDate != null ? invoiceDate.format(DATE_FORMAT) : "";
}
public String getFormattedDeliveryDate() {
return deliveryDate != null ? deliveryDate.format(DATE_FORMAT) : "";
}
public String getFormattedPaymentDueDate() {
return paymentDueDate != null ? paymentDueDate.format(DATE_FORMAT) : "";
}
public String getFormattedNetAmount() {
return netAmount != null ? CURRENCY_FORMAT.format(netAmount) : "0,00 €";
}
public String getFormattedVatAmount() {
return vatAmount != null ? CURRENCY_FORMAT.format(vatAmount) : "0,00 €";
}
public String getFormattedTotalAmount() {
return totalAmount != null ? CURRENCY_FORMAT.format(totalAmount) : "0,00 €";
}
public String getFormattedVatRate() {
if (vatRate != null) {
return String.format("%.0f%%", vatRate.multiply(new BigDecimal("100")));
}
return "19%";
}
// Legacy methods for backward compatibility
public String getDate() {
return getFormattedInvoiceDate();
}
public String getText() {
return description;
}
public String getRecipientDepartment() {
return recipientCompany;
}
public String getRecipientStreet() {
return recipientAddress;
}
}

Some files were not shown because too many files have changed in this diff Show More