Compare commits

...

10 Commits

46 changed files with 1917 additions and 273 deletions

View File

@@ -1,10 +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 target/*.jar app.jar
COPY ${JAR_FILE} app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=production"]

View File

@@ -1,6 +1,6 @@
docker buildx build --platform linux/amd64 -t appcreationgmbh/votianlt:0.8.0 --push .
docker buildx build --platform linux/amd64 -t repository.assecutor.de/votianlt:0.9.10 --push .
docker buildx build --platform linux/amd64 -t registry.assecutor.de/votianlt:0.9.10 --push .
adsg
G8m0T3vz

View File

@@ -2,6 +2,7 @@
> Gilt für alle Views unter `src/main/java/de/assecutor/votianlt/pages/view/`
> Theme-Datei: `src/main/frontend/themes/votian-modern/styles.css`
> Stand: UI-Änderungen bis 23.03.2026 berücksichtigt (`Landing-Hero-CTA/Demo-Button`, `ViewToolbar`-Migrationen, Landing-/Dashboard-Updates, TabSheet-Dialoge, Message-/Statistics-Layouts)
---
@@ -159,8 +160,8 @@ Jede View muss **genau eine** der folgenden Root-Klassen tragen:
| `statistics-chat-view` | Statistik/Chat | `VerticalLayout` |
| `dashboard-view` | Dashboard-Seiten | `VerticalLayout` |
| `dashboard-home-view` | Haupt-Dashboard (transparenter Hintergrund) | `VerticalLayout` |
| `landing-view` | Startseite (unauthentifiziert, weißer Hintergrund) | `Main` |
| `login-view` | Login-Seite (weißer Hintergrund, fullscreen) | `Main` |
| `landing-view` | Startseite (Shell-Gradient im Body, weiße Inhalts-Panels) | `Main` |
| `login-view` | Login-Seite (Shell-Gradient, einspaltig, fullscreen) | `Main` |
### Kritische Java-Regeln
@@ -243,6 +244,11 @@ Einheitliche Toolbar für alle Seiten-Überschriften:
add(new ViewToolbar(getTranslation("my.view.title")));
// Mit Aktionsbutton:
add(new ViewToolbar(getTranslation("my.view.title"), myButton));
// Mit gruppierten Sekundäraktionen:
add(new ViewToolbar(
getTranslation("my.view.title"),
ViewToolbar.group(backButton, exportButton),
addButton));
```
```css
@@ -251,13 +257,27 @@ add(new ViewToolbar(getTranslation("my.view.title"), myButton));
padding: 0.75rem 1rem;
box-sizing: border-box;
}
.view-toolbar-title-row {
gap: 0.8rem;
}
.view-toolbar-title {
color: var(--app-text-strong);
font-weight: 800;
letter-spacing: -0.05em;
}
.view-toolbar-actions,
.view-toolbar-group {
width: 100%;
justify-content: flex-end;
}
```
**Regeln**:
- `ViewToolbar` ist Standard für neue Header und ersetzt ad-hoc `HorizontalLayout`+`H2`/`H1`-Konstrukte.
- Bereits migriert: `EditProfileView`, `ImprintView`, `StatisticsView`, `UserMessagesView`, `MessageDetailsView`.
- Titel im Toolbar-Header bleiben einzeilig (`white-space: nowrap` wird im Component-Code gesetzt).
- Ausnahme: Für komplexe Header-Zeilen wie in `MessageDetailsView` darf der Inhalt einer `ViewToolbar` ersetzt werden, die Basiskomponente bleibt aber `ViewToolbar`.
---
## 8. Daten-Grids
@@ -289,6 +309,7 @@ add(panel);
- Hintergrund: `rgba(255,255,255,0.88)`, `border-radius: 24px`
- Header: Gedämpftes Grau, Uppercase, `font-weight: 800`, `font-size: 0.76rem`
- Selektierte Zeile: `--app-accent-soft` Hintergrund
- Auch eingebettete Grids in Formular-/Summary-Bereichen werden so gekapselt; kein nacktes `Grid` direkt in einen Content-Abschnitt hängen.
---
@@ -377,6 +398,17 @@ body:has(.dashboard-home-view) {
}
```
**Aktuelles Strukturmuster**:
- Das eingeloggte Dashboard besteht aus Hero + Systemsektion; App-Overview und Footer bleiben auf der öffentlichen Landing Page.
- Dashboard-Feature-Karten dürfen vollständig klickbar sein, indem `feature-card` in `RouterLink.feature-card-link` gewrappt wird.
```java
RouterLink link = new RouterLink();
link.setRoute(ShowJobsView.class);
link.add(card);
link.addClassName("feature-card-link");
```
---
## 12. Sidebar / Navigation
@@ -406,8 +438,21 @@ header.addClickListener(event -> UI.getCurrent().navigate("dashboard"));
transform: translateX(4px);
background: rgba(255,255,255,0.14);
}
.app-nav-row-root {
width: 20.5rem;
}
.app-nav-row-management-child {
width: 18.25rem;
}
.app-nav-row-user-child {
width: 17rem;
}
```
**Zusatzregeln**:
- Root-Einträge und Kind-Einträge bekommen unterschiedliche Breitenklassen entsprechend ihrer Ebene im `TreeGrid`.
- Drawer-Labels werden im Renderer mit `white-space: nowrap` versehen; Zeilenumbrüche in der Navigation sind nicht vorgesehen.
### User Menu
```css
@@ -433,7 +478,7 @@ header.addClickListener(event -> UI.getCurrent().navigate("dashboard"));
| `simple-card` | 26px | rgba(255,255,255,0.88) | Einfache Karten |
| `detail-card` | 24px | rgba(255,255,255,0.88) | Detail-Informationen |
| `hero-panel` | 34px | Dunkelblau-Gradient | Hero-Bereiche |
| `feature-card` | 24px | Linear-Gradient weiß/blau | Feature-Kacheln |
| `feature-card` | 24px | Linear-Gradient weiß/blau | Feature-Kacheln, optional klickbar via `feature-card-link` |
| `message-card` | 22px | Linear-Gradient weiß | Nachrichten |
| `station-tile` | 24px | Linear-Gradient weiß | Station-Kacheln |
| `job-task-card` | 22px | Linear-Gradient weiß | Aufgaben-Karten |
@@ -561,6 +606,49 @@ header.addClickListener(event -> UI.getCurrent().navigate("dashboard"));
## 17. Message & Chat Components
### Header & Thread Layout
Message-Views verwenden `ViewToolbar` als erstes sichtbares Header-Element. Eigene `.message-thread-header`-Layouts sollen für neue Implementierungen nicht mehr aufgebaut werden.
```java
addClassName("message-hub-view");
VerticalLayout contentLayout = new VerticalLayout();
contentLayout.setPadding(true);
contentLayout.setSpacing(true);
contentLayout.setWidthFull();
contentLayout.setHeightFull();
contentLayout.addClassName("message-thread-layout");
contentLayout.add(new ViewToolbar(getTranslation("usermessages.title.with", clientName)));
```
```css
.message-thread-layout {
width: 100%;
margin: 0;
padding: 0;
background: transparent;
border: none;
box-shadow: none;
}
.message-thread-scroller {
border: 1px solid var(--app-border-strong);
background: linear-gradient(180deg, rgba(249,250,251,0.95), rgba(243,244,246,0.88));
border-radius: 24px;
overflow-y: auto !important;
}
.message-thread {
padding: 1rem !important;
}
.message-thread-input {
border: 1px solid var(--app-border-strong);
background: rgba(255,255,255,0.82);
border-radius: 22px;
padding: 0.85rem 1rem;
}
```
### Message Card
```css
@@ -588,8 +676,8 @@ header.addClickListener(event -> UI.getCurrent().navigate("dashboard"));
box-shadow: var(--app-shadow-sm);
}
.message-bubble.client {
background: rgba(255,255,255,0.96);
border: 1px solid var(--app-border-strong);
background: linear-gradient(135deg, rgba(255,255,255,0.98), rgba(241,245,249,0.92));
border: 1px solid rgba(148,163,184,0.25);
}
.message-bubble.server {
background: linear-gradient(135deg, rgba(191,219,254,0.8), rgba(167,243,208,0.74));
@@ -619,7 +707,8 @@ header.addClickListener(event -> UI.getCurrent().navigate("dashboard"));
## 18. Dialoge
```css
vaadin-dialog-overlay::part(content) {
vaadin-dialog-overlay::part(content),
vaadin-confirm-dialog-overlay::part(content) {
border-radius: 28px;
border: 1px solid var(--app-border-strong);
background: rgba(255,255,255,0.94);
@@ -639,13 +728,76 @@ vaadin-dialog-overlay::part(content) {
```css
.dialog-form-panel,
.dialog-content-panel {
.dialog-content-panel,
.dialog-cargo-card {
backdrop-filter: blur(12px);
padding: 1rem;
border-radius: 24px;
}
.dialog-task-card {
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
```
### Station Dialoge mit TabSheet und innerer Karte
`PickupStationDialog` und `DeliveryStationDialog` verwenden nicht mehr die Standard-Overlay-Karte, sondern ein transparentes Overlay mit eigener weißer Innenkarte.
```java
getElement().setAttribute("theme", "no-inner-card");
TabSheet tabSheet = new TabSheet();
tabSheet.setWidthFull();
tabSheet.setSizeFull();
Div frame = new Div();
frame.getStyle().set("border", "10px solid transparent");
frame.getStyle().set("display", "flex");
frame.getStyle().set("flex-direction", "column");
frame.getStyle().set("flex", "1");
frame.setSizeFull();
Div whiteCard = new Div();
whiteCard.getStyle().set("background", "white");
whiteCard.getStyle().set("border-radius", "24px");
whiteCard.getStyle().set("flex", "1");
whiteCard.getStyle().set("overflow", "auto");
whiteCard.setSizeFull();
whiteCard.add(tabSheet);
frame.add(whiteCard);
add(frame);
```
```css
vaadin-dialog-overlay[theme~="no-inner-card"]::part(content) {
border-radius: 0;
border: none;
background: none;
backdrop-filter: none;
box-shadow: none;
padding: 0;
margin: 0;
}
vaadin-tabsheet::part(content) {
border-radius: 0 0 24px 24px;
background: rgba(255,255,255,0.86);
}
[part="tabs-container"] {
background: white;
border-radius: 24px 24px 0 0;
border-bottom: 1px solid #e0e0e0;
}
```
**Tab-Konventionen**:
- `PickupStationDialog`: Adresse, Termine, Fracht
- `DeliveryStationDialog`: Adresse, Aufgaben
- Fehlerindikatoren sitzen direkt am jeweiligen `Tab`
---
## 19. Detail Cards
@@ -758,12 +910,24 @@ loginShell.addClassName("login-shell");
.landing-view {
padding: 20px;
gap: 20px;
background: #ffffff !important;
background: transparent !important;
min-height: 100vh;
min-height: 100dvh;
}
body:has(.landing-view) {
background: #ffffff;
background: var(--app-shell-background);
}
.landing-view .surface-panel {
border: 1px solid var(--app-border-strong);
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.landing-view .landing-header,
.landing-view .section-panel,
.landing-view .app-overview-panel,
.landing-view .footer-panel {
background: var(--app-surface-solid);
}
```
@@ -786,13 +950,58 @@ body:has(.landing-view) {
}
```
### Anonymer Header
- Im anonymen Zustand enthält die Header-Navigation nur `Login`, `Registrieren` und die Sprachwahl.
- Öffentliche Primäraktionen gehören nicht in die Header-Leiste, sondern in das Hero-Panel.
- Der `Demo`-Einstieg wird deshalb nicht als zusätzlicher Header-Button geführt.
```java
private Component createAnonymousNavigation() {
HorizontalLayout navButtons = new HorizontalLayout();
navButtons.setSpacing(true);
navButtons.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER);
navButtons.addClassName("landing-nav");
Button loginBtn = new Button(getTranslation("start.button.login"), event -> login());
loginBtn.addThemeVariants(ButtonVariant.LUMO_CONTRAST);
loginBtn.addClassName("landing-nav-button");
Button registerBtn = new Button(getTranslation("start.button.register"), event -> register());
registerBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
registerBtn.addClassName("landing-nav-button");
Button languageBtn = createLanguageSelector();
navButtons.add(loginBtn, registerBtn, languageBtn);
return navButtons;
}
```
### App-Section vs. Footer
CTA-Text und Slogan liegen auf der Landing Page jetzt im `app-overview-panel`, nicht mehr im Footer.
```css
.app-cta {
margin-top: 0.75rem;
color: var(--app-accent-strong);
font-weight: 700;
max-width: 820px;
}
.app-slogan {
color: var(--app-accent-strong);
font-style: italic;
}
```
### Hero Panel
```css
.hero-panel {
position: relative;
overflow: hidden;
min-height: 340px;
min-height: 520px;
justify-content: center;
text-align: center;
border-radius: 34px;
@@ -810,6 +1019,68 @@ body:has(.landing-view) {
color: rgba(226,232,240,0.92);
font-size: clamp(1rem, 2vw, 1.15rem);
}
.hero-actions {
gap: 1rem;
align-items: center;
}
.hero-action-group {
gap: 0.45rem;
align-items: center;
}
.hero-choice-hint {
max-width: 420px;
margin: 0;
color: rgba(226,232,240,0.82);
font-size: 0.95rem;
line-height: 1.55;
text-align: center;
}
```
### Hero-CTA-Regeln
- Öffentliche CTAs werden in der Hero-Kachel vertikal gestapelt und nicht über Header und Hero verteilt.
- Reihenfolge auf der Landing Page: zuerst `Demo`, danach `Jetzt kostenlos testen`.
- Beide CTA-Buttons verwenden dieselbe Primärdarstellung: `LUMO_PRIMARY`, `LUMO_LARGE`, `setWidthFull()`, Pill-Optik.
- Jeder CTA bekommt direkt darunter einen eigenen Erklärungstext.
- Keine kombinierten Erklärungstexte für mehrere Buttons verwenden; Hinweise immer 1:1 an den jeweiligen CTA binden.
```java
Button demoButton = new Button(getTranslation("start.button.demo"), event -> loginDemo());
demoButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_LARGE);
demoButton.addClassNames("hero-cta", "hero-demo-cta");
demoButton.setWidthFull();
Button ctaButton = new Button(getTranslation("cta.freetest"), event -> register());
ctaButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_LARGE);
ctaButton.addClassName("hero-cta");
ctaButton.setWidthFull();
Paragraph demoHint = new Paragraph(getTranslation("start.hero.demo.hint"));
demoHint.addClassName("hero-choice-hint");
Paragraph trialHint = new Paragraph(getTranslation("start.hero.trial.hint"));
trialHint.addClassName("hero-choice-hint");
VerticalLayout demoAction = new VerticalLayout(demoButton, demoHint);
demoAction.addClassName("hero-action-group");
VerticalLayout trialAction = new VerticalLayout(ctaButton, trialHint);
trialAction.addClassName("hero-action-group");
VerticalLayout heroActions = new VerticalLayout();
heroActions.setPadding(false);
heroActions.setSpacing(false);
heroActions.setWidthFull();
heroActions.setMaxWidth("420px");
heroActions.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER);
heroActions.addClassName("hero-actions");
heroActions.add(demoAction, trialAction);
```
```properties
start.hero.demo.hint=Demo startet sofort mit vorbereiteten Beispieldaten.
start.hero.trial.hint="Jetzt kostenlos testen" erstellt Ihren eigenen Account für den kostenlosen Probemonat.
```
---
@@ -1136,3 +1407,4 @@ Beim Erstellen einer neuen View folgende Punkte prüfen:
- [ ] Schmale Formular-Container haben `setWidth("Xpx")` **und** `setMaxWidth("100%")`
- [ ] Inline-Styles nur für dynamische Werte verwenden, alles andere per CSS-Klassen
- [ ] Sekundäre Buttons (`Abbrechen`, `Zurück`, `Export`) haben `LUMO_TERTIARY`, Hauptaktionen haben `LUMO_PRIMARY`
- [ ] Tabbed Dialoge verwenden das `no-inner-card`-Pattern mit innerer weißer Karte statt zusätzlicher Overlay-Dekoration

View File

@@ -1,6 +1,79 @@
# 1. Login (mit deinen Credentials)
echo "G8m0T3vz" | docker login registry.assecutor.org -u adsg --password-stdin
#!/usr/bin/env bash
# Dann ganz normal pushen
docker buildx build --platform linux/amd64 -t registry.assecutor.org/votianlt:0.9.9 --push .
set -euo pipefail
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly REGISTRY_IMAGE="registry.assecutor.org/votianlt"
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 "./mvnw" ]] || fail "'./mvnw' wurde nicht gefunden oder ist nicht ausführbar."
local version
version="$(
./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} ..."
./mvnw -Pproduction -DskipTests -Drevision="${VERSION}" package
JAR_FILE="target/votianlt-${VERSION}.jar"
[[ -f "${JAR_FILE}" ]] || fail "Release-JAR wurde nicht gefunden: ${JAR_FILE}"
echo "Pushe Image ${REGISTRY_IMAGE}:${VERSION} ..."
docker buildx build \
--platform linux/amd64 \
--build-arg "JAR_FILE=${JAR_FILE}" \
-t "${REGISTRY_IMAGE}:${VERSION}" \
--push \
.
echo "Fertig: ${REGISTRY_IMAGE}:${VERSION}"

View File

@@ -6,11 +6,12 @@
<groupId>de.assecutor.votianlt</groupId>
<artifactId>votianlt</artifactId>
<version>0.9.12</version>
<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>

Binary file not shown.

Binary file not shown.

View File

@@ -165,6 +165,7 @@ window.initProfileInvoiceGenerator = function() {
var w = (el.width || 100) * zoomFactor;
var h = (el.height || 30) * zoomFactor;
var fontSize = (el.fontSize || 14) * zoomFactor;
var textAlign = el.textAlign || 'left';
if (el.type === 'line') {
ctx.strokeStyle = el.color || '#333333';
@@ -269,9 +270,17 @@ window.initProfileInvoiceGenerator = function() {
var fontWeight = (el.isStatic && !el.isCustomer) ? '' : (el.fontStyle || '');
ctx.font = (fontWeight ? fontWeight + ' ' : '') + fontSize + 'px Arial';
ctx.textBaseline = 'top';
ctx.textAlign = textAlign;
var textX = x;
if (textAlign === 'center') {
textX = x + (w / 2);
} else if (textAlign === 'right') {
textX = x + w;
}
lines.forEach(function(line) {
ctx.fillText(line, x, ty);
ctx.fillText(line, textX, ty);
ty += lineHeight;
});
}
@@ -1113,6 +1122,7 @@ window.initProfileInvoiceGenerator = function() {
heightPercent: toPercentY(el.height),
fontSize: el.fontSize,
fontStyle: el.fontStyle,
textAlign: el.textAlign,
color: el.color,
isStatic: el.isStatic,
isCustomer: el.isCustomer,

View File

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

View File

@@ -273,7 +273,6 @@ vaadin-vertical-layout.admin-form-view {
.view-toolbar,
.form-card,
.message-section,
.message-thread-layout,
.statistics-header,
.statistics-input-panel {
padding: clamp(1rem, 1.8vw, 1.5rem);
@@ -362,7 +361,7 @@ vaadin-vertical-layout.admin-form-view {
.hero-panel {
position: relative;
overflow: hidden;
min-height: 340px;
min-height: 520px;
justify-content: center;
text-align: center;
border-radius: 34px;
@@ -431,6 +430,30 @@ vaadin-vertical-layout.admin-form-view {
margin-top: 0.5rem;
}
.hero-actions {
gap: 1rem;
align-items: center;
}
.hero-action-group {
gap: 0.45rem;
align-items: center;
}
.hero-actions .hero-cta,
.hero-actions .hero-demo-cta {
margin-top: 0;
}
.hero-choice-hint {
max-width: 420px;
margin: 0;
color: rgba(226, 232, 240, 0.82);
font-size: 0.95rem;
line-height: 1.55;
text-align: center;
}
.section-title {
margin: 0;
text-align: center;
@@ -1220,8 +1243,8 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
border-radius: 22px !important;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 249, 255, 0.9)) !important;
box-shadow: var(--app-shadow-sm) !important;
max-width: 100% !important;
margin-bottom: 0.85rem !important;
box-sizing: border-box !important;
}
.message-section {
@@ -1265,12 +1288,12 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
}
.message-thread-layout {
width: min(1240px, 100%);
margin: 0 auto;
border: 1px solid var(--app-border-strong);
background: rgba(255, 255, 255, 0.84);
box-shadow: var(--app-shadow-lg);
border-radius: 30px;
width: 100%;
margin: 0;
padding: 0;
background: transparent;
border: none;
box-shadow: none;
}
.message-thread-header {
@@ -1281,10 +1304,14 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
padding: 1rem 1.15rem;
}
.message-thread {
.message-thread-scroller {
border: 1px solid var(--app-border-strong);
background: linear-gradient(180deg, rgba(239, 244, 255, 0.72), rgba(248, 250, 252, 0.92));
background: linear-gradient(180deg, rgba(249, 250, 251, 0.95), rgba(243, 244, 246, 0.88));
border-radius: 24px;
overflow-y: auto !important;
}
.message-thread {
padding: 1rem !important;
}
@@ -1338,8 +1365,8 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
}
.message-bubble.client {
background: rgba(255, 255, 255, 0.96);
border: 1px solid var(--app-border-strong);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(241, 245, 249, 0.92));
border: 1px solid rgba(148, 163, 184, 0.25);
}
.message-bubble.server {
@@ -1381,7 +1408,14 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
justify-content: flex-end;
}
.statistics-header,
.statistics-header {
width: min(1240px, 100%);
margin: 0 auto;
border: none;
background: transparent;
box-shadow: none;
}
.statistics-input-panel {
width: min(1240px, 100%);
margin: 0 auto;
@@ -1398,6 +1432,7 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon {
background: linear-gradient(180deg, rgba(238, 244, 255, 0.75), rgba(248, 250, 252, 0.96));
border-radius: 30px;
box-shadow: var(--app-shadow-lg);
box-sizing: border-box;
}
.station-tile.validated {

View File

@@ -2,6 +2,7 @@ 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;
@@ -18,15 +19,19 @@ public class DataInitializer implements CommandLineRunner {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final DemoModeService demoModeService;
public DataInitializer(UserRepository userRepository, PasswordEncoder passwordEncoder) {
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() {

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

@@ -6,6 +6,7 @@ import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
@@ -211,12 +212,14 @@ public class DeliveryStationDialog extends Dialog {
setWidth("960px");
setHeight("80vh");
// Remove white rounded card from dialog overlay
getElement().setAttribute("theme", "no-inner-card");
// Address form
VerticalLayout formLayout = new VerticalLayout();
formLayout.setPadding(true);
formLayout.setSpacing(true);
formLayout.setWidthFull();
formLayout.addClassName("dialog-form-panel");
// Company with autocomplete
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
@@ -325,7 +328,27 @@ public class DeliveryStationDialog extends Dialog {
createTasksTab(templates, templateSaveCallback));
tasksTab.add(tasksTabError);
add(tabSheet);
// Transparent border frame filling the dialog
Div frame = new Div();
frame.getStyle().set("border", "10px solid transparent");
frame.getStyle().set("border-radius", "0");
frame.getStyle().set("box-sizing", "border-box");
frame.getStyle().set("display", "flex");
frame.getStyle().set("flex-direction", "column");
frame.getStyle().set("flex", "1");
frame.setSizeFull();
// White card with rounded corners inside the frame
Div whiteCard = new Div();
whiteCard.getStyle().set("background", "white");
whiteCard.getStyle().set("border-radius", "24px");
whiteCard.getStyle().set("flex", "1");
whiteCard.getStyle().set("overflow", "auto");
whiteCard.setSizeFull();
whiteCard.add(tabSheet);
frame.add(whiteCard);
add(frame);
// Footer buttons
Button saveButton = new Button(translationHelper.getTranslation("dialog.confirm"), e -> {
@@ -336,20 +359,20 @@ public class DeliveryStationDialog extends Dialog {
}
// Warte-Dialog anzeigen
Dialog loadingDialog = new Dialog();
Dialog loadingDialog = DialogStylingHelper
.createStyledDialog(translationHelper.getTranslation("addjob.validation.dialog.title"), "420px");
loadingDialog.setCloseOnOutsideClick(false);
loadingDialog.setCloseOnEsc(false);
loadingDialog.setHeaderTitle(translationHelper.getTranslation("addjob.validation.dialog.title"));
VerticalLayout loadingContent = new VerticalLayout();
VerticalLayout loadingContent = DialogStylingHelper.createContentLayout("320px",
FlexComponent.Alignment.CENTER);
loadingContent.setAlignItems(FlexComponent.Alignment.CENTER);
loadingContent.setPadding(true);
loadingContent.setSpacing(true);
loadingContent.getStyle().set("text-align", "center");
Span loadingText = new Span(translationHelper.getTranslation("addjob.validation.dialog.loading"));
ProgressBar progressBar = new ProgressBar();
progressBar.setIndeterminate(true);
progressBar.setWidthFull();
loadingContent.add(loadingText, progressBar);
loadingDialog.add(loadingContent);
loadingDialog.add(DialogStylingHelper.wrapContent(loadingContent));
loadingDialog.open();
// Adresse asynchron bei Google validieren
@@ -695,7 +718,6 @@ public class DeliveryStationDialog extends Dialog {
content.setPadding(false);
content.setSpacing(true);
content.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
content.addClassName("dialog-content-panel");
// Task title with template selection
H3 tasksTitle = new H3(translationHelper.getTranslation("addjob.tasks.title"));
@@ -1252,13 +1274,11 @@ public class DeliveryStationDialog extends Dialog {
return;
}
Dialog dialog = new Dialog();
dialog.setHeaderTitle(translationHelper.getTranslation("addjob.tasks.template.save.title"));
dialog.setWidth("400px");
Dialog dialog = DialogStylingHelper
.createStyledDialog(translationHelper.getTranslation("addjob.tasks.template.save.title"), "460px");
dialog.setCloseOnOutsideClick(false);
VerticalLayout dialogLayout = new VerticalLayout();
dialogLayout.setPadding(false);
dialogLayout.setSpacing(true);
VerticalLayout dialogLayout = DialogStylingHelper.createContentLayout("340px");
TextField templateNameField = new TextField(translationHelper.getTranslation("addjob.tasks.template.name"));
templateNameField.setPlaceholder(translationHelper.getTranslation("addjob.tasks.template.name.placeholder"));
@@ -1288,11 +1308,9 @@ public class DeliveryStationDialog extends Dialog {
Button cancelButton = new Button(translationHelper.getTranslation("button.cancel"));
cancelButton.addClickListener(e -> dialog.close());
HorizontalLayout buttonLayout = new HorizontalLayout(cancelButton, saveButton);
buttonLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
dialogLayout.add(templateNameField, buttonLayout);
dialog.add(dialogLayout);
dialogLayout.add(templateNameField);
dialog.add(DialogStylingHelper.wrapContent(dialogLayout));
dialog.getFooter().add(cancelButton, saveButton);
dialog.open();
}

View File

@@ -0,0 +1,68 @@
package de.assecutor.votianlt.pages.base.ui.component;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
/**
* Shared helper for dialogs that should use the station-dialog shell.
*/
public final class DialogStylingHelper {
private DialogStylingHelper() {
}
public static Dialog createStyledDialog(String title, String width) {
Dialog dialog = new Dialog();
apply(dialog, title, width);
return dialog;
}
public static void apply(Dialog dialog, String title, String width) {
if (title != null && !title.isBlank()) {
dialog.setHeaderTitle(title);
}
if (width != null && !width.isBlank()) {
dialog.setWidth(width);
}
dialog.setMaxWidth("95vw");
dialog.getElement().setAttribute("theme", "no-inner-card");
}
public static Component wrapContent(Component content) {
Div frame = new Div();
frame.getStyle().set("border", "10px solid transparent");
frame.getStyle().set("border-radius", "0");
frame.getStyle().set("box-sizing", "border-box");
frame.setWidthFull();
Div whiteCard = new Div();
whiteCard.getStyle().set("background", "white");
whiteCard.getStyle().set("border-radius", "24px");
whiteCard.getStyle().set("overflow", "auto");
whiteCard.setWidthFull();
whiteCard.add(content);
frame.add(whiteCard);
return frame;
}
public static VerticalLayout createContentLayout(String maxWidth) {
return createContentLayout(maxWidth, FlexComponent.Alignment.STRETCH);
}
public static VerticalLayout createContentLayout(String maxWidth, FlexComponent.Alignment alignment) {
VerticalLayout content = new VerticalLayout();
content.setPadding(true);
content.setSpacing(true);
content.setWidthFull();
if (maxWidth != null && !maxWidth.isBlank()) {
content.setMaxWidth(maxWidth);
}
content.setDefaultHorizontalComponentAlignment(alignment);
content.getStyle().set("margin", "0 auto");
return content;
}
}

View File

@@ -485,20 +485,20 @@ public class PickupStationDialog extends Dialog {
}
// Warte-Dialog anzeigen
Dialog loadingDialog = new Dialog();
Dialog loadingDialog = DialogStylingHelper
.createStyledDialog(translationHelper.getTranslation("addjob.validation.dialog.title"), "420px");
loadingDialog.setCloseOnOutsideClick(false);
loadingDialog.setCloseOnEsc(false);
loadingDialog.setHeaderTitle(translationHelper.getTranslation("addjob.validation.dialog.title"));
VerticalLayout loadingContent = new VerticalLayout();
VerticalLayout loadingContent = DialogStylingHelper.createContentLayout("320px",
FlexComponent.Alignment.CENTER);
loadingContent.setAlignItems(FlexComponent.Alignment.CENTER);
loadingContent.setPadding(true);
loadingContent.setSpacing(true);
loadingContent.getStyle().set("text-align", "center");
Span loadingText = new Span(translationHelper.getTranslation("addjob.validation.dialog.loading"));
ProgressBar progressBar = new ProgressBar();
progressBar.setIndeterminate(true);
progressBar.setWidthFull();
loadingContent.add(loadingText, progressBar);
loadingDialog.add(loadingContent);
loadingDialog.add(DialogStylingHelper.wrapContent(loadingContent));
loadingDialog.open();
// Adresse asynchron bei Google validieren

View File

@@ -28,11 +28,13 @@ public final class ViewToolbar extends Composite<Header> {
var title = new H1(viewTitle);
title.addClassNames(FontSize.XLARGE, Margin.NONE, FontWeight.LIGHT);
title.addClassName("view-toolbar-title");
title.getStyle().set("white-space", "nowrap");
toggleAndTitle = new Div(drawerToggle, title);
} else {
var title = new H1(viewTitle);
title.addClassNames(FontSize.XLARGE, Margin.NONE, FontWeight.LIGHT);
title.addClassName("view-toolbar-title");
title.getStyle().set("white-space", "nowrap");
toggleAndTitle = new Div(title);
}
toggleAndTitle.addClassNames(Display.FLEX, AlignItems.CENTER);

View File

@@ -198,6 +198,7 @@ public final class MainLayout extends AppLayout {
// Label
Span label = new Span(item.label());
label.getStyle().set("font-size", "var(--lumo-font-size-s)");
label.getStyle().set("white-space", "nowrap");
label.addClassName("app-nav-label");
row.add(label);
row.setFlexGrow(1, label);

View File

@@ -66,6 +66,7 @@ import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationTile;
import de.assecutor.votianlt.pages.base.ui.component.StationTile;
import de.assecutor.votianlt.pages.base.ui.component.PickupStationDialog;
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationDialog;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import java.time.LocalDate;
import java.time.LocalTime;
@@ -1185,13 +1186,10 @@ public class AddJobView extends Main implements HasDynamicTitle {
}
private void openAddServiceDialog() {
Dialog dialog = new Dialog();
dialog.setHeaderTitle(getTranslation("addjob.services.dialog.title"));
dialog.setWidth("560px");
Dialog dialog = DialogStylingHelper.createStyledDialog(getTranslation("addjob.services.dialog.title"), "720px");
dialog.setCloseOnOutsideClick(false);
VerticalLayout dialogContent = new VerticalLayout();
dialogContent.setPadding(true);
dialogContent.setSpacing(true);
VerticalLayout dialogContent = DialogStylingHelper.createContentLayout("620px");
// Load available services for current user
List<Service> availableServices = serviceRepository
@@ -1224,11 +1222,6 @@ public class AddJobView extends Main implements HasDynamicTitle {
dialogContent.add(serviceCombo, deliveryStationCombo);
HorizontalLayout buttonLayout = new HorizontalLayout();
buttonLayout.setWidthFull();
buttonLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
buttonLayout.setSpacing(true);
Button cancelButton = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
@@ -1245,10 +1238,8 @@ public class AddJobView extends Main implements HasDynamicTitle {
});
addButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
buttonLayout.add(cancelButton, addButton);
dialogContent.add(buttonLayout);
dialog.add(dialogContent);
dialog.add(DialogStylingHelper.wrapContent(dialogContent));
dialog.getFooter().add(cancelButton, addButton);
dialog.open();
}
@@ -2275,15 +2266,13 @@ public class AddJobView extends Main implements HasDynamicTitle {
}
private Dialog createRouteLoadingDialog() {
Dialog dialog = new Dialog();
Dialog dialog = DialogStylingHelper.createStyledDialog(getTranslation("addjob.route.title"), "460px");
dialog.setCloseOnOutsideClick(false);
dialog.setCloseOnEsc(false);
dialog.setHeaderTitle(getTranslation("addjob.route.title"));
VerticalLayout content = new VerticalLayout();
VerticalLayout content = DialogStylingHelper.createContentLayout("340px", FlexComponent.Alignment.CENTER);
content.setAlignItems(FlexComponent.Alignment.CENTER);
content.setPadding(true);
content.setSpacing(true);
content.getStyle().set("text-align", "center");
Span loadingText = new Span("Strecke zwischen allen Stationen wird berechnet...");
ProgressBar progressBar = new ProgressBar();
@@ -2291,7 +2280,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
progressBar.setWidthFull();
content.add(loadingText, progressBar);
dialog.add(content);
dialog.add(DialogStylingHelper.wrapContent(content));
return dialog;
}
@@ -2316,13 +2305,9 @@ public class AddJobView extends Main implements HasDynamicTitle {
}
private void showRouteSummaryDialog(RouteCalculationResult routeResult) {
Dialog dialog = new Dialog();
dialog.setHeaderTitle(getTranslation("addjob.route.title"));
dialog.setWidth("420px");
Dialog dialog = DialogStylingHelper.createStyledDialog(getTranslation("addjob.route.title"), "480px");
VerticalLayout content = new VerticalLayout();
content.setPadding(false);
content.setSpacing(true);
VerticalLayout content = DialogStylingHelper.createContentLayout("360px");
content.add(createRouteSummaryRow(getTranslation("addjob.route.distance"), routeResult.getFormattedDistance()));
content.add(
createRouteSummaryRow(getTranslation("addjob.route.duration"), routeResult.getFormattedDurationLong()));
@@ -2330,7 +2315,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
Button closeButton = new Button(getTranslation("dialog.confirm"), event -> dialog.close());
closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.add(content);
dialog.add(DialogStylingHelper.wrapContent(content));
dialog.getFooter().add(closeButton);
dialog.open();
}

View File

@@ -5,6 +5,7 @@ import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
@@ -17,6 +18,7 @@ import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.HasUrlParameter;
import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.model.Customer;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.service.CustomerService;
import jakarta.annotation.security.RolesAllowed;
import org.bson.types.ObjectId;
@@ -180,10 +182,13 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
private void deleteCustomer() {
// Show confirmation dialog
Dialog confirmDialog = new Dialog();
confirmDialog.add(getTranslation("editcustomer.dialog.delete.text"));
Dialog confirmDialog = DialogStylingHelper
.createStyledDialog(getTranslation("editcustomer.dialog.delete.confirm"), "460px");
confirmDialog.setCloseOnOutsideClick(false);
VerticalLayout dialogContent = DialogStylingHelper.createContentLayout("320px");
dialogContent.add(new Span(getTranslation("editcustomer.dialog.delete.text")));
HorizontalLayout buttonLayout = new HorizontalLayout();
Button confirmDeleteButton = new Button(getTranslation("editcustomer.dialog.delete.confirm"), e -> {
if (customer != null && customer.getId() != null) {
Notification.show(getTranslation("editcustomer.notification.deleted"), 3000,
@@ -197,9 +202,8 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
Button cancelDeleteButton = new Button(getTranslation("button.cancel"), e -> confirmDialog.close());
cancelDeleteButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
buttonLayout.add(confirmDeleteButton, cancelDeleteButton);
buttonLayout.setSpacing(true);
confirmDialog.add(buttonLayout);
confirmDialog.add(DialogStylingHelper.wrapContent(dialogContent));
confirmDialog.getFooter().add(cancelDeleteButton, confirmDeleteButton);
confirmDialog.open();
}

View File

@@ -1,6 +1,7 @@
package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.button.Button;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.formlayout.FormLayout;
@@ -52,6 +53,7 @@ import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.ClientCallable;
import jakarta.annotation.security.RolesAllowed;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
@Route(value = "edit-profile", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" })
@@ -111,6 +113,10 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
formColumn.setHeightFull();
formColumn.setPadding(false);
formColumn.setSpacing(false);
ViewToolbar toolbar = new ViewToolbar(getTranslation("page.title.profile.edit"));
formColumn.add(toolbar);
// TabSheet
TabSheet tabSheet = new TabSheet();
tabSheet.setSizeFull();
@@ -1566,16 +1572,14 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
* Open dialog for adding/editing a service
*/
private void openServiceDialog(Service service) {
Dialog dialog = new Dialog();
dialog.setHeaderTitle(service == null ? getTranslation("profile.services.dialog.create")
: getTranslation("profile.services.dialog.edit"));
dialog.setWidth("500px");
Dialog dialog = DialogStylingHelper.createStyledDialog(
service == null ? getTranslation("profile.services.dialog.create")
: getTranslation("profile.services.dialog.edit"),
"560px");
dialog.setCloseOnOutsideClick(false);
// Form layout
VerticalLayout formLayout = new VerticalLayout();
formLayout.setPadding(true);
formLayout.setSpacing(true);
formLayout.setWidthFull();
VerticalLayout formLayout = DialogStylingHelper.createContentLayout("420px");
// Name field
TextField nameField = new TextField(getTranslation("common.name"));
@@ -1680,11 +1684,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
mandatoryCheckbox);
// Action buttons
HorizontalLayout buttonLayout = new HorizontalLayout();
buttonLayout.setWidthFull();
buttonLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
buttonLayout.setSpacing(true);
Button cancelButton = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
@@ -1711,10 +1710,8 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
});
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
buttonLayout.add(cancelButton, saveButton);
formLayout.add(buttonLayout);
dialog.add(formLayout);
dialog.add(DialogStylingHelper.wrapContent(formLayout));
dialog.getFooter().add(cancelButton, saveButton);
dialog.open();
}

View File

@@ -2,6 +2,7 @@ package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import org.springframework.core.io.ClassPathResource;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
@@ -13,9 +14,16 @@ public class ImprintView extends VerticalLayout implements HasDynamicTitle {
public ImprintView() {
setSizeFull();
setPadding(true);
setSpacing(true);
setAlignItems(Alignment.CENTER);
addClassNames("data-view", "form-shell");
setSpacing(false);
addClassName("form-page");
VerticalLayout content = new VerticalLayout();
content.setWidthFull();
content.setPadding(false);
content.setSpacing(false);
ViewToolbar toolbar = new ViewToolbar(getTranslation("page.title.imprint"));
content.add(toolbar);
try {
// Load HTML content from resources
@@ -27,15 +35,17 @@ public class ImprintView extends VerticalLayout implements HasDynamicTitle {
imprintDiv.addClassNames("form-card", "form-shell");
imprintDiv.getElement().setProperty("innerHTML", htmlContent);
add(imprintDiv);
content.add(imprintDiv);
} catch (Exception e) {
// Fallback content in case of error
Div errorDiv = new Div();
errorDiv.addClassNames("form-card", "form-shell");
errorDiv.setText(getTranslation("imprint.error", e.getMessage()));
add(errorDiv);
content.add(errorDiv);
}
add(content);
}
@Override

View File

@@ -23,6 +23,7 @@ import com.vaadin.flow.component.upload.Upload;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.view.AdminLayout;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import jakarta.annotation.security.RolesAllowed;
@@ -490,12 +491,11 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
colorPreviewLayout.add(colorPreview, colorHexLabel);
// Color Picker Dialog
Dialog colorDialog = new Dialog();
colorDialog.setHeaderTitle(getTranslation("invoicegenerator.color.dialog.title"));
Dialog colorDialog = DialogStylingHelper
.createStyledDialog(getTranslation("invoicegenerator.color.dialog.title"), "460px");
colorDialog.setCloseOnOutsideClick(false);
VerticalLayout dialogLayout = new VerticalLayout();
dialogLayout.setSpacing(true);
dialogLayout.setPadding(true);
VerticalLayout dialogLayout = DialogStylingHelper.createContentLayout("320px");
// Color Picker im Dialog
Input dialogColorPicker = new Input();
@@ -521,7 +521,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
});
dialogLayout.add(dialogColorPicker, dialogHexField);
colorDialog.add(dialogLayout);
colorDialog.add(DialogStylingHelper.wrapContent(dialogLayout));
// Dialog Buttons
Button dialogCancelButton = new Button(getTranslation("invoicegenerator.button.cancel"), e -> {

View File

@@ -37,6 +37,7 @@ import de.assecutor.votianlt.model.task.BarcodeTask;
import de.assecutor.votianlt.model.task.CommentTask;
import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.pages.base.ui.component.StationTile;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import lombok.extern.slf4j.Slf4j;
import de.assecutor.votianlt.repository.CargoItemRepository;
@@ -521,7 +522,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
private List<String> buildDeliverySummaryDetails(List<BaseTask> tasks) {
if (tasks == null || tasks.isEmpty()) {
return List.of(getTranslation("addjob.tab.tasks") + ": " + getTranslation("jobsummary.tasks.none"));
return new ArrayList<>(
List.of(getTranslation("addjob.tab.tasks") + ": " + getTranslation("jobsummary.tasks.none")));
}
List<String> summaries = new ArrayList<>();
@@ -532,7 +534,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
}
if (summaries.isEmpty()) {
return List.of(getTranslation("addjob.tab.tasks") + ": " + getTranslation("jobsummary.tasks.none"));
return new ArrayList<>(
List.of(getTranslation("addjob.tab.tasks") + ": " + getTranslation("jobsummary.tasks.none")));
}
return summaries;
}
@@ -650,7 +653,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
return sortVisibleTasks(station.getTasks());
}
return List.of();
return new ArrayList<>();
}
private List<BaseTask> sortVisibleTasks(List<BaseTask> tasks) {
@@ -666,9 +669,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
}
private void showStationTasksDialog(String stationTitle, List<BaseTask> tasks) {
Dialog dialog = new Dialog();
dialog.setWidth("720px");
dialog.setMaxWidth("95vw");
Dialog dialog = DialogStylingHelper.createStyledDialog(stationTitle, "720px");
dialog.setMaxHeight("85vh");
dialog.setResizable(true);
dialog.setHeaderTitle(stationTitle);
@@ -676,10 +677,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
taskCards.clear();
VerticalLayout dialogContent = new VerticalLayout();
dialogContent.setPadding(true);
dialogContent.setSpacing(true);
dialogContent.setWidthFull();
VerticalLayout dialogContent = DialogStylingHelper.createContentLayout("620px");
dialogContent.getStyle().set("min-width", "0");
dialogContent.addClassName("dialog-content-panel");
@@ -708,7 +706,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.getFooter().add(closeButton);
dialog.add(dialogContent);
dialog.add(DialogStylingHelper.wrapContent(dialogContent));
dialog.open();
}
@@ -1093,23 +1091,16 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
}
private void showTaskDetailsDialog(BaseTask task) {
Dialog dialog = new Dialog();
dialog.setWidth("500px");
Dialog dialog = DialogStylingHelper.createStyledDialog("Aufgaben-Details", "560px");
dialog.setResizable(true);
dialog.setDraggable(true);
// Reset all task card hover states when dialog closes
dialog.addDialogCloseActionListener(e -> resetAllTaskCardHoverStates());
VerticalLayout dialogContent = new VerticalLayout();
dialogContent.setPadding(true);
dialogContent.setSpacing(true);
VerticalLayout dialogContent = DialogStylingHelper.createContentLayout("420px");
dialogContent.addClassName("dialog-content-panel");
// Header
H4 header = new H4("Aufgaben-Details");
dialogContent.add(header);
// Task type and status
Span typeSpan = new Span("Typ: " + task.getDisplayName());
typeSpan.getStyle().set("font-weight", "bold");
@@ -1144,11 +1135,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
});
closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
HorizontalLayout buttonLayout = new HorizontalLayout(closeButton);
buttonLayout.setJustifyContentMode(HorizontalLayout.JustifyContentMode.END);
dialogContent.add(buttonLayout);
dialog.add(dialogContent);
dialog.add(DialogStylingHelper.wrapContent(dialogContent));
dialog.getFooter().add(closeButton);
dialog.open();
}

View File

@@ -21,13 +21,12 @@ import com.vaadin.flow.server.auth.AnonymousAllowed;
import de.assecutor.votianlt.security.totp.TwoFactorService;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.security.SessionAuthenticationService;
import de.assecutor.votianlt.service.DemoModeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import com.vaadin.flow.server.VaadinSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import jakarta.annotation.PostConstruct;
@@ -53,6 +52,9 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af
@Autowired
private UserRepository userRepository;
@Autowired
private SessionAuthenticationService sessionAuthenticationService;
@Value("${app.security.two-factor.enabled:false}")
private boolean twoFactorEnabledGlobal;
@@ -140,6 +142,15 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af
}
private void handlePasswordLogin(String username, String password) {
hideInlineFlash();
if (DemoModeService.isDemoUsername(username)) {
pendingAuth = null;
loginForm.setError(true);
showInlineFlash(getTranslation("login.demo.only.button"));
return;
}
try {
// Prüfe Benutzername/Passwort
Authentication auth = authenticationManager
@@ -158,13 +169,7 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af
Notification.show(getTranslation("login.2fa.sent"), 3000, Notification.Position.BOTTOM_CENTER);
} else {
// 2FA deaktiviert: Direkt anmelden
SecurityContextHolder.getContext().setAuthentication(auth);
var vaadinSession = VaadinSession.getCurrent();
if (vaadinSession != null) {
var wrappedSession = vaadinSession.getSession();
wrappedSession.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
SecurityContextHolder.getContext());
}
sessionAuthenticationService.storeAuthentication(auth);
// Check if user is admin and redirect accordingly
if (auth.getAuthorities().stream()
.anyMatch(authority -> authority.getAuthority().equals("ROLE_ADMIN"))) {
@@ -196,19 +201,12 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af
return;
}
// 2FA korrekt: Benutzer nun anmelden
SecurityContextHolder.getContext().setAuthentication(pendingAuth);
// Persistiere SecurityContext in der HTTP-Session, damit Vaadin/Security ihn in
// neuen Requests sieht
var vaadinSession = VaadinSession.getCurrent();
if (vaadinSession != null) {
var wrappedSession = vaadinSession.getSession();
wrappedSession.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
SecurityContextHolder.getContext());
}
Authentication authenticatedUser = pendingAuth;
sessionAuthenticationService.storeAuthentication(authenticatedUser);
this.pendingAuth = null;
// Full reload, damit der neue SecurityContext im UI sicher greift
// Check if user is admin and redirect accordingly
if (SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream()
if (authenticatedUser.getAuthorities().stream()
.anyMatch(auth -> auth.getAuthority().equals("ROLE_ADMIN"))) {
UI.getCurrent().getPage().setLocation("/admin-dashboard");
} else {
@@ -246,4 +244,14 @@ public class LoginView extends VerticalLayout implements BeforeEnterObserver, Af
public String getPageTitle() {
return getTranslation("page.title.login");
}
private void showInlineFlash(String message) {
flashBox.setText(message);
flashBox.getStyle().set("display", "block");
}
private void hideInlineFlash() {
flashBox.setText("");
flashBox.getStyle().set("display", "none");
}
}

View File

@@ -7,10 +7,12 @@ import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.Image;
import com.vaadin.flow.component.html.Main;
import com.vaadin.flow.component.html.Span;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
@@ -160,7 +162,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver, Has
String conversationTitle = resolveConversationTitle(filteredMessages, conversationId);
HorizontalLayout headerLayout = createHeaderLayout(clientName, conversationTitle);
ViewToolbar headerLayout = createHeaderLayout(clientName, conversationTitle);
contentLayout.add(headerLayout);
// Store current messages for rendering and future updates
@@ -182,6 +184,7 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver, Has
messagesScroller.setHeightFull();
messagesScroller.setScrollDirection(Scroller.ScrollDirection.VERTICAL);
messagesScroller.getStyle().set("flex", "1 1 auto");
messagesScroller.addClassName("message-thread-scroller");
contentLayout.add(messagesScroller);
contentLayout.setFlexGrow(1, messagesScroller);
@@ -260,11 +263,9 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver, Has
}
private void openImageUploadDialog() {
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Bild anhängen");
Dialog dialog = DialogStylingHelper.createStyledDialog("Bild anhängen", "560px");
dialog.setCloseOnEsc(true);
dialog.setCloseOnOutsideClick(true);
dialog.setWidth("480px");
MemoryBuffer buffer = new MemoryBuffer();
Upload upload = new Upload(buffer);
@@ -368,19 +369,11 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver, Has
sendMessageToParticipant(base64, MessageContentType.IMAGE);
});
VerticalLayout dialogContent = new VerticalLayout(upload, helper, previewWrapper);
dialogContent.setSpacing(true);
dialogContent.setPadding(false);
dialogContent.setAlignItems(FlexComponent.Alignment.STRETCH);
dialogContent.setWidthFull();
VerticalLayout dialogContent = DialogStylingHelper.createContentLayout("440px");
dialogContent.add(upload, helper, previewWrapper);
HorizontalLayout buttonBar = new HorizontalLayout(cancelButton, confirmButton);
buttonBar.setWidthFull();
buttonBar.setSpacing(true);
buttonBar.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
dialog.add(dialogContent);
dialog.getFooter().add(buttonBar);
dialog.add(DialogStylingHelper.wrapContent(dialogContent));
dialog.getFooter().add(cancelButton, confirmButton);
dialog.open();
}
@@ -668,30 +661,34 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver, Has
}
}
private HorizontalLayout createHeaderLayout(String clientName, String conversationTitle) {
Button backButton = new Button("Zurück", VaadinIcon.ARROW_LEFT.create());
backButton.addThemeVariants(com.vaadin.flow.component.button.ButtonVariant.LUMO_TERTIARY);
backButton.addClickListener(e -> UI.getCurrent().navigate("user-messages/" + participantKey));
private ViewToolbar createHeaderLayout(String clientName, String conversationTitle) {
// Create title with subtitle in a custom layout
VerticalLayout titleLayout = new VerticalLayout();
titleLayout.setPadding(false);
titleLayout.setSpacing(false);
H2 title = new H2(clientName);
title.getStyle().set("margin", "0");
H1 title = new H1(clientName);
title.addClassNames(com.vaadin.flow.theme.lumo.LumoUtility.FontSize.XLARGE, com.vaadin.flow.theme.lumo.LumoUtility.Margin.NONE, com.vaadin.flow.theme.lumo.LumoUtility.FontWeight.LIGHT);
title.addClassName("view-toolbar-title");
Span subtitle = new Span(conversationTitle);
subtitle.addClassName("message-subtitle");
titleLayout.add(title);
titleLayout.add(title, subtitle);
// Use ViewToolbar for consistent header layout with drawer toggle
ViewToolbar toolbar = new ViewToolbar("");
toolbar.getContent().removeAll();
HorizontalLayout layout = new HorizontalLayout(backButton, titleLayout);
layout.setWidthFull();
layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
layout.setSpacing(true);
layout.addClassName("message-thread-header");
// Build custom header content with drawer toggle
com.vaadin.flow.component.applayout.DrawerToggle drawerToggle = new com.vaadin.flow.component.applayout.DrawerToggle();
drawerToggle.addClassNames(com.vaadin.flow.theme.lumo.LumoUtility.Margin.NONE);
drawerToggle.addClassName("view-toolbar-toggle");
return layout;
com.vaadin.flow.component.html.Div titleRow = new com.vaadin.flow.component.html.Div(drawerToggle, titleLayout);
titleRow.addClassNames(com.vaadin.flow.theme.lumo.LumoUtility.Display.FLEX, com.vaadin.flow.theme.lumo.LumoUtility.AlignItems.CENTER);
titleRow.addClassName("view-toolbar-title-row");
toolbar.getContent().add(titleRow);
return toolbar;
}
private HorizontalLayout createMessageInputArea() {
@@ -709,9 +706,10 @@ public class MessageDetailsView extends Main implements BeforeEnterObserver, Has
attachButton.getElement().setAttribute("aria-label", "Bild anhängen");
attachButton.addClickListener(event -> openImageUploadDialog());
Button sendButton = new Button("Senden", VaadinIcon.PAPERPLANE.create());
Button sendButton = new Button(VaadinIcon.PAPERPLANE.create());
sendButton.addThemeVariants(com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY);
sendButton.getStyle().set("height", "60px");
sendButton.getStyle().set("min-width", "90px");
sendButton.addClickListener(e -> {
String message = messageInput.getValue();

View File

@@ -9,6 +9,8 @@ import com.vaadin.flow.component.contextmenu.ContextMenu;
import com.vaadin.flow.component.html.*;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
@@ -18,8 +20,12 @@ import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import de.assecutor.votianlt.model.Language;
import de.assecutor.votianlt.security.SessionAuthenticationService;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.DemoModeService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import java.util.Locale;
@@ -28,10 +34,18 @@ import java.util.Locale;
public class StartView extends VerticalLayout implements BeforeEnterObserver, HasDynamicTitle {
private final SecurityService securityService;
private final DemoModeService demoModeService;
private final SessionAuthenticationService sessionAuthenticationService;
private final AuthenticationManager authenticationManager;
private final String appVersion;
public StartView(SecurityService securityService, @Value("${app.version:unknown}") String appVersion) {
public StartView(SecurityService securityService, DemoModeService demoModeService,
SessionAuthenticationService sessionAuthenticationService, AuthenticationManager authenticationManager,
@Value("${app.version:unknown}") String appVersion) {
this.securityService = securityService;
this.demoModeService = demoModeService;
this.sessionAuthenticationService = sessionAuthenticationService;
this.authenticationManager = authenticationManager;
this.appVersion = appVersion;
addClassName("landing-view");
setSizeFull();
@@ -278,11 +292,48 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver, Ha
Paragraph heroDescription = new Paragraph(getTranslation("start.hero.description"));
heroDescription.addClassName("hero-panel-text");
Button demoButton = new Button(getTranslation("start.button.demo"), event -> loginDemo());
demoButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_LARGE);
demoButton.addClassNames("hero-cta", "hero-demo-cta");
demoButton.setWidthFull();
Button ctaButton = new Button(getTranslation("cta.freetest"), event -> register());
ctaButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_LARGE);
ctaButton.addClassName("hero-cta");
ctaButton.setWidthFull();
heroSection.add(heroIcon, heroTitle, heroDescription, ctaButton);
Paragraph demoHint = new Paragraph(getTranslation("start.hero.demo.hint"));
demoHint.addClassName("hero-choice-hint");
Paragraph trialHint = new Paragraph(getTranslation("start.hero.trial.hint"));
trialHint.addClassName("hero-choice-hint");
VerticalLayout demoAction = new VerticalLayout();
demoAction.setPadding(false);
demoAction.setSpacing(false);
demoAction.setWidthFull();
demoAction.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER);
demoAction.addClassName("hero-action-group");
demoAction.add(demoButton, demoHint);
VerticalLayout trialAction = new VerticalLayout();
trialAction.setPadding(false);
trialAction.setSpacing(false);
trialAction.setWidthFull();
trialAction.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER);
trialAction.addClassName("hero-action-group");
trialAction.add(ctaButton, trialHint);
VerticalLayout heroActions = new VerticalLayout();
heroActions.setPadding(false);
heroActions.setSpacing(false);
heroActions.setWidthFull();
heroActions.setMaxWidth("420px");
heroActions.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER);
heroActions.addClassName("hero-actions");
heroActions.add(demoAction, trialAction);
heroSection.add(heroIcon, heroTitle, heroDescription, heroActions);
return heroSection;
}
@@ -407,6 +458,38 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver, Ha
UI.getCurrent().navigate("login");
}
private void loginDemo() {
String sessionId = sessionAuthenticationService.getCurrentSessionId().orElse(null);
if (sessionId == null) {
showDemoNotification(getTranslation("demo.start.error"), NotificationVariant.LUMO_ERROR);
return;
}
boolean prepared = false;
try {
prepared = demoModeService.tryPrepareDemoSession(sessionId);
if (!prepared) {
showDemoNotification(getTranslation("demo.session.active"), NotificationVariant.LUMO_ERROR);
return;
}
var auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(DemoModeService.DEMO_USERNAME, DemoModeService.DEMO_PASSWORD));
sessionAuthenticationService.storeAuthentication(auth);
UI.getCurrent().getPage().setLocation("/dashboard");
} catch (Exception ex) {
if (prepared) {
demoModeService.cleanupAndReleaseIfOwned(sessionId);
}
showDemoNotification(getTranslation("demo.start.error"), NotificationVariant.LUMO_ERROR);
}
}
private void showDemoNotification(String message, NotificationVariant variant) {
Notification notification = Notification.show(message, 4000, Notification.Position.TOP_CENTER);
notification.addThemeVariants(variant);
}
@Override
public String getPageTitle() {
return getTranslation("page.title.welcome");

View File

@@ -7,7 +7,7 @@ import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dependency.JavaScript;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H2;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
@@ -56,7 +56,7 @@ public class StatisticsView extends VerticalLayout implements HasDynamicTitle {
addClassName("statistics-chat-view");
// Header
HorizontalLayout header = createHeader();
ViewToolbar header = createHeader();
add(header);
// Chat Container mit Scroll
@@ -81,25 +81,8 @@ public class StatisticsView extends VerticalLayout implements HasDynamicTitle {
add(inputArea);
}
private HorizontalLayout createHeader() {
HorizontalLayout header = new HorizontalLayout();
header.setWidthFull();
header.setPadding(true);
header.setAlignItems(FlexComponent.Alignment.CENTER);
header.addClassName("statistics-header");
Icon aiIcon = VaadinIcon.MAGIC.create();
aiIcon.getStyle().set("color", "var(--lumo-primary-color)");
H2 title = new H2(getTranslation("statistics.title"));
title.getStyle().set("margin", "0").set("font-size", "var(--lumo-font-size-xl)");
Span subtitle = new Span(getTranslation("statistics.subtitle"));
subtitle.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size", "var(--lumo-font-size-s)")
.set("margin-left", "var(--lumo-space-m)");
header.add(aiIcon, title, subtitle);
return header;
private ViewToolbar createHeader() {
return new ViewToolbar(getTranslation("statistics.title"));
}
private HorizontalLayout createInputArea() {

View File

@@ -1,14 +1,11 @@
package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Main;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.VaadinIcon;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.BeforeEvent;
@@ -83,46 +80,25 @@ public class UserMessagesView extends Main implements HasUrlParameter<String>, H
String clientName = client != null ? client.getVorname() + " " + client.getNachname()
: Optional.ofNullable(participantKey).orElse(getTranslation("usermessages.unknown.participant"));
HorizontalLayout headerLayout = createHeaderLayout(clientName);
ViewToolbar headerLayout = createHeaderLayout(clientName);
contentLayout.add(headerLayout);
List<Message> conversation = messageService.getMessagesForAppUserAscending(participantKey);
Map<MessageType, List<Message>> messagesByType = conversation.stream().collect(Collectors
.groupingBy(message -> Optional.ofNullable(message.getMessageType()).orElse(MessageType.GENERAL)));
VerticalLayout generalSection = createGeneralMessagesSection(messagesByType.get(MessageType.GENERAL));
VerticalLayout jobSection = createJobMessagesSection(messagesByType.get(MessageType.JOB_RELATED));
contentLayout.add(generalSection, jobSection);
addGeneralMessagesTo(contentLayout, messagesByType.get(MessageType.GENERAL));
addJobMessagesTo(contentLayout, messagesByType.get(MessageType.JOB_RELATED));
}
private HorizontalLayout createHeaderLayout(String clientName) {
Button backButton = new Button(getTranslation("button.back"), VaadinIcon.ARROW_LEFT.create());
backButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
backButton.addClickListener(e -> UI.getCurrent().navigate("messages"));
H2 title = new H2(getTranslation("usermessages.title.with", clientName));
HorizontalLayout layout = new HorizontalLayout(backButton, title);
layout.setWidthFull();
layout.setAlignItems(com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment.CENTER);
layout.setSpacing(true);
layout.addClassName("message-thread-header");
return layout;
private ViewToolbar createHeaderLayout(String clientName) {
return new ViewToolbar(getTranslation("usermessages.title.with", clientName));
}
private VerticalLayout createGeneralMessagesSection(List<Message> generalMessages) {
VerticalLayout section = new VerticalLayout();
section.setPadding(true);
section.setSpacing(true);
section.setWidthFull();
section.addClassName("message-section");
private void addGeneralMessagesTo(VerticalLayout target, List<Message> generalMessages) {
H3 title = new H3(getTranslation("usermessages.general.title"));
title.addClassName("message-section-title");
section.add(title);
target.add(title);
List<Message> sortedMessages = new ArrayList<>();
if (generalMessages != null) {
@@ -139,26 +115,18 @@ public class UserMessagesView extends Main implements HasUrlParameter<String>, H
LocalDateTime lastMessageTime = latest != null ? latest.getCreatedAt() : null;
String preview = resolvePreview(latest);
section.add(createMessageCard(getTranslation("usermessages.general.conversation"), preview, lastMessageTime,
target.add(createMessageCard(getTranslation("usermessages.general.conversation"), preview, lastMessageTime,
messageCount, unreadCount, "general"));
return section;
}
private VerticalLayout createJobMessagesSection(List<Message> jobMessages) {
VerticalLayout section = new VerticalLayout();
section.setPadding(true);
section.setSpacing(true);
section.setWidthFull();
section.addClassName("message-section");
private void addJobMessagesTo(VerticalLayout target, List<Message> jobMessages) {
H3 title = new H3(getTranslation("usermessages.job.title"));
title.addClassName("message-section-title");
section.add(title);
target.add(title);
if (jobMessages == null || jobMessages.isEmpty()) {
section.add(new Span(getTranslation("usermessages.no.job.messages")));
return section;
target.add(new Span(getTranslation("usermessages.no.job.messages")));
return;
}
Map<String, List<Message>> messagesByJob = jobMessages.stream()
@@ -171,11 +139,9 @@ public class UserMessagesView extends Main implements HasUrlParameter<String>, H
.filter(message -> message.getOrigin() == MessageOrigin.CLIENT && !message.isRead()).count();
String conversationTitle = getTranslation("usermessages.job.conversation", jobKey);
section.add(createMessageCard(conversationTitle, resolvePreview(latest), latest.getCreatedAt(),
target.add(createMessageCard(conversationTitle, resolvePreview(latest), latest.getCreatedAt(),
messages.size(), unreadCount, "job-" + sanitizeConversationId(jobKey)));
});
return section;
}
private String resolvePreview(Message message) {
@@ -236,7 +202,7 @@ public class UserMessagesView extends Main implements HasUrlParameter<String>, H
// Add all elements to card
VerticalLayout cardContent = new VerticalLayout(titleRow, preview, metaRow);
cardContent.setWidthFull();
cardContent.setPadding(false);
cardContent.setPadding(true);
cardContent.setSpacing(false);
card.add(cardContent);

View File

@@ -1,6 +1,8 @@
package de.assecutor.votianlt.security;
import com.vaadin.flow.spring.security.AuthenticationContext;
import de.assecutor.votianlt.service.DemoModeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
@@ -12,12 +14,15 @@ import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
@Slf4j
public class SecurityService {
private final AuthenticationContext authenticationContext;
private final DemoModeService demoModeService;
public SecurityService(AuthenticationContext authenticationContext) {
public SecurityService(AuthenticationContext authenticationContext, DemoModeService demoModeService) {
this.authenticationContext = authenticationContext;
this.demoModeService = demoModeService;
}
public Optional<UserDetails> getAuthenticatedUser() {
@@ -99,6 +104,14 @@ public class SecurityService {
}
public void logout() {
try {
String authenticatedUsername = getAuthenticatedUser().map(UserDetails::getUsername).orElse(null);
if (DemoModeService.isDemoUsername(authenticatedUsername)) {
demoModeService.cleanupCurrentSessionIfOwned();
}
} catch (Exception ex) {
log.warn("Demo logout cleanup failed: {}", ex.getMessage(), ex);
}
authenticationContext.logout();
}

View File

@@ -0,0 +1,37 @@
package de.assecutor.votianlt.security;
import com.vaadin.flow.server.VaadinSession;
import java.util.Optional;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.stereotype.Service;
@Service
public class SessionAuthenticationService {
public void storeAuthentication(Authentication authentication) {
SecurityContextHolder.getContext().setAuthentication(authentication);
VaadinSession vaadinSession = VaadinSession.getCurrent();
if (vaadinSession == null) {
return;
}
var wrappedSession = vaadinSession.getSession();
if (wrappedSession == null) {
return;
}
wrappedSession.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
SecurityContextHolder.getContext());
}
public Optional<String> getCurrentSessionId() {
VaadinSession vaadinSession = VaadinSession.getCurrent();
if (vaadinSession == null || vaadinSession.getSession() == null) {
return Optional.empty();
}
return Optional.ofNullable(vaadinSession.getSession().getId());
}
}

View File

@@ -358,6 +358,7 @@ public class CustomerInvoiceService {
int fontSize = element.has("fontSize") ? element.get("fontSize").asInt(14) : 14;
String color = element.has("color") ? element.get("color").asText("#333333") : "#333333";
String textAlign = element.has("textAlign") ? element.get("textAlign").asText("left") : "left";
// Convert percentages to mm (A4 is 210mm x 297mm)
double mmX = xPercent / 100.0 * 210.0;
@@ -392,6 +393,17 @@ public class CustomerInvoiceService {
} else {
// Vertically center content for other elements
htmlBuilder.append("display:flex;align-items:center;");
switch (textAlign) {
case "center":
htmlBuilder.append("justify-content:center;text-align:center;");
break;
case "right":
htmlBuilder.append("justify-content:flex-end;text-align:right;");
break;
default:
htmlBuilder.append("justify-content:flex-start;text-align:left;");
break;
}
}
}
htmlBuilder.append("'");
@@ -649,6 +661,7 @@ public class CustomerInvoiceService {
int fontSize = element.has("fontSize") ? element.get("fontSize").asInt(14) : 14;
String color = element.has("color") ? element.get("color").asText("#333333") : "#333333";
String textAlign = element.has("textAlign") ? element.get("textAlign").asText("left") : "left";
// Convert percentages to mm (A4 is 210mm x 297mm)
double mmX = xPercent / 100.0 * 210.0;
@@ -682,6 +695,17 @@ public class CustomerInvoiceService {
htmlBuilder.append("display:block;overflow:visible;padding:0;");
} else {
htmlBuilder.append("display:flex;align-items:center;");
switch (textAlign) {
case "center":
htmlBuilder.append("justify-content:center;text-align:center;");
break;
case "right":
htmlBuilder.append("justify-content:flex-end;text-align:right;");
break;
default:
htmlBuilder.append("justify-content:flex-start;text-align:left;");
break;
}
}
}
htmlBuilder.append("'");

View File

@@ -0,0 +1,547 @@
package de.assecutor.votianlt.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.Barcode;
import de.assecutor.votianlt.model.Comment;
import de.assecutor.votianlt.model.Customer;
import de.assecutor.votianlt.model.DeliveryStation;
import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobServiceSelection;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.Language;
import de.assecutor.votianlt.model.LocationPosition;
import de.assecutor.votianlt.model.Photo;
import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.model.Signature;
import de.assecutor.votianlt.model.TaskTemplate;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.task.BaseTask;
import de.assecutor.votianlt.pages.domain.CustomerRepository;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.BarcodeRepository;
import de.assecutor.votianlt.repository.CargoItemRepository;
import de.assecutor.votianlt.repository.CommentRepository;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.InvoiceTemplateRepository;
import de.assecutor.votianlt.repository.JobHistoryRepository;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.LocationPositionRepository;
import de.assecutor.votianlt.repository.MessageRepository;
import de.assecutor.votianlt.repository.PhotoRepository;
import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.SignatureRepository;
import de.assecutor.votianlt.repository.TaskRepository;
import de.assecutor.votianlt.repository.TaskTemplateRepository;
import de.assecutor.votianlt.repository.UserInvoiceDataRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SessionAuthenticationService;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
@org.springframework.stereotype.Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class DemoModeService {
public static final String DEMO_USERNAME = "demo";
public static final String DEMO_PASSWORD = "demo";
private static final String DEMO_FIRST_NAME = "Demo";
private static final String DEMO_LAST_NAME = "Benutzer";
private static final String DEMO_INVOICE_TEMPLATE_RESOURCE = "templates/demo_invoice_template_default.json";
private final DemoSessionRegistry demoSessionRegistry;
private final SessionAuthenticationService sessionAuthenticationService;
private final UserRepository userRepository;
private final CustomerRepository customerRepository;
private final JobRepository jobRepository;
private final CargoItemRepository cargoItemRepository;
private final TaskRepository taskRepository;
private final JobHistoryRepository jobHistoryRepository;
private final CustomerInvoiceRepository customerInvoiceRepository;
private final ServiceRepository serviceRepository;
private final AppUserRepository appUserRepository;
private final TaskTemplateRepository taskTemplateRepository;
private final InvoiceTemplateRepository invoiceTemplateRepository;
private final UserInvoiceDataRepository userInvoiceDataRepository;
private final MessageRepository messageRepository;
private final LocationPositionRepository locationPositionRepository;
private final PhotoRepository photoRepository;
private final SignatureRepository signatureRepository;
private final BarcodeRepository barcodeRepository;
private final CommentRepository commentRepository;
private final PasswordEncoder passwordEncoder;
public static boolean isDemoUsername(String username) {
return username != null && DEMO_USERNAME.equalsIgnoreCase(username.trim());
}
public void ensureDemoUser() {
User demoUser = userRepository.findByEmail(DEMO_USERNAME).orElseGet(User::new);
applyDemoUserDefaults(demoUser);
userRepository.save(demoUser);
}
public boolean tryPrepareDemoSession(String sessionId) {
if (!demoSessionRegistry.acquire(sessionId)) {
return false;
}
try {
ensureDemoUser();
cleanupDemoOwnedData();
resetDemoUserProfile();
seedDemoData();
return true;
} catch (RuntimeException ex) {
demoSessionRegistry.release(sessionId);
throw ex;
}
}
public void cleanupCurrentSessionIfOwned() {
sessionAuthenticationService.getCurrentSessionId().ifPresent(this::cleanupAndReleaseIfOwned);
}
public void cleanupAndReleaseIfOwned(String sessionId) {
if (!demoSessionRegistry.isHeldBy(sessionId)) {
return;
}
try {
cleanupDemoOwnedData();
resetDemoUserProfile();
} catch (RuntimeException ex) {
log.warn("Demo cleanup failed for session {}: {}", sessionId, ex.getMessage(), ex);
} finally {
demoSessionRegistry.release(sessionId);
}
}
public void resetDemoUserProfile() {
User demoUser = getDemoUser();
applyDemoUserDefaults(demoUser);
userRepository.save(demoUser);
}
public void cleanupDemoOwnedData() {
Optional<User> demoUserOptional = userRepository.findByEmail(DEMO_USERNAME);
if (demoUserOptional.isEmpty()) {
return;
}
User demoUser = demoUserOptional.get();
ObjectId demoUserId = demoUser.getId();
if (demoUserId == null) {
return;
}
String demoUserIdHex = demoUserId.toHexString();
List<AppUser> demoAppUsers = appUserRepository.findByErstelltVon(demoUserId);
cleanupDemoAppUserData(demoAppUsers);
List<Job> demoJobs = jobRepository.findByCreatedBy(demoUserIdHex);
cleanupDemoJobData(demoJobs);
List<Customer> demoCustomers = customerRepository.findByOwner(demoUserId);
if (!demoCustomers.isEmpty()) {
customerRepository.deleteAll(demoCustomers);
}
serviceRepository.deleteByUserId(demoUserIdHex);
List<TaskTemplate> demoTaskTemplates = taskTemplateRepository.findByUserIdOrderByTemplateNameAsc(demoUserId);
if (!demoTaskTemplates.isEmpty()) {
taskTemplateRepository.deleteAll(demoTaskTemplates);
}
invoiceTemplateRepository.deleteByUserId(demoUserIdHex);
userInvoiceDataRepository.deleteByUserId(demoUserId);
List<CustomerInvoice> demoInvoices = customerInvoiceRepository.findByUserId(demoUserIdHex);
if (!demoInvoices.isEmpty()) {
customerInvoiceRepository.deleteAll(demoInvoices);
}
if (!demoAppUsers.isEmpty()) {
appUserRepository.deleteAll(demoAppUsers);
}
}
public void seedDemoData() {
User demoUser = getDemoUser();
List<Service> services = seedDemoServices(demoUser);
List<Customer> customers = seedDemoCustomers(demoUser);
seedDemoJobs(demoUser, customers, services);
seedDemoInvoiceTemplate(demoUser);
}
private void cleanupDemoAppUserData(List<AppUser> demoAppUsers) {
for (AppUser appUser : demoAppUsers) {
String appUserId = appUser.getIdAsString();
if (appUserId == null || appUserId.isBlank()) {
continue;
}
var messages = messageRepository.findByReceiverOrderByCreatedAtAsc(appUserId);
if (!messages.isEmpty()) {
messageRepository.deleteAll(messages);
}
var positions = locationPositionRepository.findByAppUserIdOrderByTimestampDesc(appUserId);
if (!positions.isEmpty()) {
locationPositionRepository.deleteAll(positions);
}
}
}
private void cleanupDemoJobData(List<Job> demoJobs) {
if (demoJobs.isEmpty()) {
return;
}
Set<ObjectId> stationIds = new LinkedHashSet<>();
Set<ObjectId> jobIds = new LinkedHashSet<>();
Set<String> jobIdStrings = new LinkedHashSet<>();
for (Job job : demoJobs) {
if (job == null || job.getId() == null) {
continue;
}
jobIds.add(job.getId());
jobIdStrings.add(job.getIdAsString());
if (job.getDeliveryStations() == null) {
continue;
}
for (DeliveryStation station : job.getDeliveryStations()) {
if (station != null && station.getStationId() != null) {
stationIds.add(station.getStationId());
}
}
}
Map<ObjectId, BaseTask> tasksById = new LinkedHashMap<>();
if (!stationIds.isEmpty()) {
for (BaseTask task : taskRepository.findByStationIdIn(new ArrayList<>(stationIds))) {
if (task != null && task.getId() != null) {
tasksById.put(task.getId(), task);
}
}
}
for (ObjectId jobId : jobIds) {
for (BaseTask task : taskRepository.findByJobIdOrderByTaskOrderAsc(jobId)) {
if (task != null && task.getId() != null) {
tasksById.put(task.getId(), task);
}
}
}
cleanupTaskArtifacts(tasksById.keySet());
if (!tasksById.isEmpty()) {
taskRepository.deleteAll(tasksById.values());
}
for (ObjectId jobId : jobIds) {
var cargoItems = cargoItemRepository.findByJobId(jobId);
if (!cargoItems.isEmpty()) {
cargoItemRepository.deleteAll(cargoItems);
}
jobHistoryRepository.deleteByJobId(jobId);
}
List<CustomerInvoice> invoicesToDelete = new ArrayList<>();
for (String jobId : jobIdStrings) {
customerInvoiceRepository.findByJobId(jobId).ifPresent(invoicesToDelete::add);
}
if (!invoicesToDelete.isEmpty()) {
customerInvoiceRepository.deleteAll(dedupeInvoices(invoicesToDelete));
}
jobRepository.deleteAll(demoJobs);
}
private void cleanupTaskArtifacts(Collection<ObjectId> taskIds) {
for (ObjectId taskId : taskIds) {
if (taskId == null) {
continue;
}
List<Photo> photos = photoRepository.findByTaskId(taskId);
if (!photos.isEmpty()) {
photoRepository.deleteAll(photos);
}
List<Signature> signatures = signatureRepository.findByTaskId(taskId);
if (!signatures.isEmpty()) {
signatureRepository.deleteAll(signatures);
}
List<Barcode> barcodes = barcodeRepository.findByTaskId(taskId);
if (!barcodes.isEmpty()) {
barcodeRepository.deleteAll(barcodes);
}
List<Comment> comments = commentRepository.findByTaskIdOrderByCreatedAtDesc(taskId);
if (!comments.isEmpty()) {
commentRepository.deleteAll(comments);
}
}
}
private List<CustomerInvoice> dedupeInvoices(List<CustomerInvoice> invoices) {
Map<String, CustomerInvoice> unique = new LinkedHashMap<>();
for (CustomerInvoice invoice : invoices) {
if (invoice == null || invoice.getId() == null) {
continue;
}
unique.putIfAbsent(invoice.getId(), invoice);
}
return new ArrayList<>(unique.values());
}
private List<Customer> seedDemoCustomers(User demoUser) {
ObjectId demoUserId = demoUser.getId();
Customer firstCustomer = new Customer();
firstCustomer.setTitle("Frau");
firstCustomer.setCompanyName("Demo Bau GmbH");
firstCustomer.setFirstname("Anna");
firstCustomer.setLastName("Sommer");
firstCustomer.setTelephone("030 1234567");
firstCustomer.setMail("anna.sommer@demo.invalid");
firstCustomer.setStreet("Musterstrasse");
firstCustomer.setHouseNumber("12");
firstCustomer.setZip("10115");
firstCustomer.setCity("Berlin");
firstCustomer.setCreatedBy(demoUserId);
firstCustomer.setOwner(demoUserId);
Customer secondCustomer = new Customer();
secondCustomer.setTitle("Herr");
secondCustomer.setCompanyName("Nordhandel AG");
secondCustomer.setFirstname("Lukas");
secondCustomer.setLastName("Becker");
secondCustomer.setTelephone("040 7654321");
secondCustomer.setMail("lukas.becker@demo.invalid");
secondCustomer.setStreet("Hafenallee");
secondCustomer.setHouseNumber("8");
secondCustomer.setZip("20095");
secondCustomer.setCity("Hamburg");
secondCustomer.setCreatedBy(demoUserId);
secondCustomer.setOwner(demoUserId);
return customerRepository.saveAll(List.of(firstCustomer, secondCustomer));
}
private List<Service> seedDemoServices(User demoUser) {
String demoUserId = demoUser.getId().toHexString();
Service activeService = createFlatRateService(demoUserId, "Expresszustellung", "149.00");
Service completedService = createFlatRateService(demoUserId, "Konsolidierungszustellung", "219.00");
return serviceRepository.saveAll(List.of(activeService, completedService));
}
private void seedDemoJobs(User demoUser, List<Customer> customers, List<Service> services) {
Map<String, Customer> customersByCompany = new LinkedHashMap<>();
for (Customer customer : customers) {
customersByCompany.put(customer.getCompanyName(), customer);
}
Map<String, Service> servicesByName = new LinkedHashMap<>();
for (Service service : services) {
servicesByName.put(service.getName(), service);
}
LocalDateTime now = LocalDateTime.now();
String demoUserId = demoUser.getId().toHexString();
Job activeJob = new Job();
activeJob.setJobNumber("DEMO-AKTIV-001");
activeJob.setStatus(JobStatus.IN_PROGRESS);
activeJob.setCreatedAt(now.minusDays(2));
activeJob.setUpdatedAt(now.minusHours(3));
activeJob.setCreatedBy(demoUserId);
activeJob.setDraft(false);
activeJob.setDigitalProcessing(false);
activeJob.setPickupDate(LocalDate.now().minusDays(1));
activeJob.setPickupTime(LocalTime.of(9, 30));
activeJob.setRemark("Demo-Auftrag aktiv");
activeJob.setPrice(new BigDecimal("149.00"));
applyCustomerToPickup(activeJob, customersByCompany.get("Demo Bau GmbH"));
activeJob.setCustomerSelection("Demo Bau GmbH | Anna Sommer");
activeJob.setDeliveryStations(new ArrayList<>(List.of(
createDeliveryStation("Potsdam Expresslager", "Herr", "Timo", "Kranz", "0331 444555",
"Lange Bruecke", "4", null, "14467", "Potsdam", LocalDate.now(), LocalTime.of(14, 0), 0))));
activeJob.setServiceIds(new ArrayList<>(List.of(servicesByName.get("Expresszustellung").getId())));
activeJob.setSelectedServices(new ArrayList<>(List.of(
createJobServiceSelection(servicesByName.get("Expresszustellung"), 0))));
activeJob.syncFlatDeliveryFieldsFromStations();
Job completedJob = new Job();
completedJob.setJobNumber("DEMO-ERLEDIGT-001");
completedJob.setStatus(JobStatus.COMPLETED);
completedJob.setCreatedAt(now.minusDays(8));
completedJob.setUpdatedAt(now.minusDays(5));
completedJob.setCreatedBy(demoUserId);
completedJob.setDraft(false);
completedJob.setDigitalProcessing(false);
completedJob.setPickupDate(LocalDate.now().minusDays(7));
completedJob.setPickupTime(LocalTime.of(8, 15));
completedJob.setRemark("Demo-Auftrag abgeschlossen");
completedJob.setPrice(new BigDecimal("219.00"));
applyCustomerToPickup(completedJob, customersByCompany.get("Nordhandel AG"));
completedJob.setCustomerSelection("Nordhandel AG | Lukas Becker");
completedJob.setDeliveryStations(new ArrayList<>(List.of(
createDeliveryStation("Bremen Konsolidierung", "Frau", "Mara", "Jensen", "0421 987654", "Kontorweg",
"19", null, "28195", "Bremen", LocalDate.now().minusDays(6), LocalTime.of(16, 30), 0))));
completedJob.setServiceIds(new ArrayList<>(List.of(servicesByName.get("Konsolidierungszustellung").getId())));
completedJob.setSelectedServices(new ArrayList<>(List.of(
createJobServiceSelection(servicesByName.get("Konsolidierungszustellung"), 0))));
completedJob.syncFlatDeliveryFieldsFromStations();
jobRepository.saveAll(List.of(activeJob, completedJob));
}
private Service createFlatRateService(String userId, String name, String price) {
Service service = new Service();
service.setId(new ObjectId().toHexString());
service.setUserId(userId);
service.setName(name);
service.setCalculationBasis(Service.CalculationBasis.FLAT_RATE);
service.setPrice(new BigDecimal(price));
service.setMandatory(false);
return service;
}
private JobServiceSelection createJobServiceSelection(Service service, int deliveryStationOrder) {
JobServiceSelection selection = new JobServiceSelection();
selection.setServiceId(service.getId());
selection.setDeliveryStationOrder(deliveryStationOrder);
return selection;
}
private void seedDemoInvoiceTemplate(User demoUser) {
InvoiceTemplate template = new InvoiceTemplate(demoUser.getId().toHexString(), "Einfach",
createDemoInvoiceTemplateData());
invoiceTemplateRepository.save(template);
}
private String createDemoInvoiceTemplateData() {
try {
String templateJson = new String(
new ClassPathResource(DEMO_INVOICE_TEMPLATE_RESOURCE).getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
return new ObjectMapper().writeValueAsString(templateJson);
} catch (IOException ex) {
throw new IllegalStateException("Could not load demo invoice template", ex);
}
}
private void applyCustomerToPickup(Job job, Customer customer) {
if (job == null || customer == null) {
return;
}
job.setPickupCompany(customer.getCompanyName());
job.setPickupSalutation(customer.getTitle());
job.setPickupFirstName(customer.getFirstname());
job.setPickupLastName(customer.getLastName());
job.setPickupPhone(customer.getTelephone());
job.setPickupStreet(customer.getStreet());
job.setPickupHouseNumber(customer.getHouseNumber());
job.setPickupAddressAddition(customer.getAddressAddition());
job.setPickupZip(customer.getZip());
job.setPickupCity(customer.getCity());
}
private DeliveryStation createDeliveryStation(String company, String salutation, String firstName, String lastName,
String phone, String street, String houseNumber, String addressAddition, String zip, String city,
LocalDate deliveryDate, LocalTime deliveryTime, int stationOrder) {
DeliveryStation station = new DeliveryStation();
station.ensureStationId();
station.setStationOrder(stationOrder);
station.setCompany(company);
station.setSalutation(salutation);
station.setFirstName(firstName);
station.setLastName(lastName);
station.setPhone(phone);
station.setStreet(street);
station.setHouseNumber(houseNumber);
station.setAddressAddition(addressAddition);
station.setZip(zip);
station.setCity(city);
station.setDeliveryDate(deliveryDate);
station.setDeliveryTime(deliveryTime);
station.setTasks(new ArrayList<>());
return station;
}
private User getDemoUser() {
return userRepository.findByEmail(DEMO_USERNAME)
.orElseThrow(() -> new IllegalStateException("Demo user does not exist"));
}
private void applyDemoUserDefaults(User demoUser) {
LocalDateTime now = LocalDateTime.now();
if (demoUser.getCreatedAt() == null) {
demoUser.setCreatedAt(now);
}
demoUser.setEmail(DEMO_USERNAME);
demoUser.setPassword(passwordEncoder.encode(DEMO_PASSWORD));
demoUser.setFirstname(DEMO_FIRST_NAME);
demoUser.setName(DEMO_LAST_NAME);
demoUser.setTitle(null);
demoUser.setCompany(null);
demoUser.setCompanyAddition(null);
demoUser.setStreet(null);
demoUser.setHouseNumber(null);
demoUser.setAddressAddition(null);
demoUser.setZip(null);
demoUser.setCity(null);
demoUser.setDiffInvoiceAddress(false);
demoUser.setInvCompany(null);
demoUser.setInvCompanyAddition(null);
demoUser.setInvFirstname(null);
demoUser.setInvLastname(null);
demoUser.setInvStreet(null);
demoUser.setInvHouseNumber(null);
demoUser.setInvAddressAddition(null);
demoUser.setInvZip(null);
demoUser.setInvCity(null);
demoUser.setPhone(null);
demoUser.setPhone2(null);
demoUser.setFax(null);
demoUser.setPasswordCode(null);
demoUser.setPasswordTimestamp(null);
demoUser.setIsActivated((byte) 1);
demoUser.setIsEmailConfirmed((byte) 1);
demoUser.setRoles(Set.of("USER"));
demoUser.setDigitalProcessingEnabled(true);
demoUser.setLocationTrackingEnabled(true);
demoUser.setTwoFactorEnabled(false);
demoUser.setLanguage(Language.DE);
demoUser.setUpdatedAt(now);
}
}

View File

@@ -0,0 +1,40 @@
package de.assecutor.votianlt.service;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.stereotype.Service;
@Service
public class DemoSessionRegistry {
private final AtomicReference<String> activeSessionId = new AtomicReference<>();
public synchronized boolean acquire(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
return false;
}
String current = activeSessionId.get();
if (current == null || Objects.equals(current, sessionId)) {
activeSessionId.set(sessionId);
return true;
}
return false;
}
public boolean isHeldBy(String sessionId) {
return sessionId != null && sessionId.equals(activeSessionId.get());
}
public synchronized void release(String sessionId) {
if (isHeldBy(sessionId)) {
activeSessionId.set(null);
}
}
public Optional<String> getActiveSessionId() {
return Optional.ofNullable(activeSessionId.get());
}
}

View File

@@ -806,11 +806,17 @@ register.notification.failed=Registrierung fehlgeschlagen: {0}
# Start Page
start.title=VotianLT - Ihr digitaler Transportpartner
start.button.login=Anmelden
start.button.demo=Demo
start.button.register=Registrieren
login.demo.only.button=Der Demo-Zugang ist nur über den Demo-Button auf der Startseite verfügbar.
demo.session.active=Der Demo-Modus wird bereits von einem anderen Nutzer verwendet. Bitte versuchen Sie es später erneut.
demo.start.error=Der Demo-Modus konnte nicht gestartet werden.
start.button.createorder=Auftragserstellung
start.button.notifications=Benachrichtigungen
start.button.nonotifications=Keine neuen Benachrichtigungen
start.hero.description=Für Solo-Selbstständige und Kleinunternehmer im Transportgewerbe - volldigital und aus einem Guss. Konzentrieren Sie sich auf Ihr Geschäft, wir kümmern uns um die Büroarbeit.
start.hero.demo.hint=Demo startet sofort mit vorbereiteten Beispieldaten.
start.hero.trial.hint="Jetzt kostenlos testen" erstellt Ihren eigenen Account für den kostenlosen Probemonat.
start.system.title=Das System
start.system.intro=Für Solo-Selbstständige und Kleinunternehmer im Transportgewerbe ist von entscheidender Bedeutung, dass sie sich in erster Linie auf ihr eigentliches Geschäft konzentrieren können: Kunden gewinnen und Waren von A nach B liefern.
start.feature.setup.title=Einrichtungsassistent

View File

@@ -728,11 +728,17 @@ register.notification.success=Registreerimine \u00f5nnestus. Palun logige sisse.
register.notification.failed=Registreerimine eba\u00f5nnestus: {0}
start.title=VotianLT - Teie digitaalne transpordipartner
start.button.login=Logi sisse
start.button.demo=Demo
start.button.register=Registreeru
login.demo.only.button=Demo ligipääs on võimalik ainult avalehe Demo nupu kaudu.
demo.session.active=Demorežiimi kasutab juba teine kasutaja. Palun proovige hiljem uuesti.
demo.start.error=Demorežiimi ei õnnestunud käivitada.
start.button.createorder=Tellimuse loomine
start.button.notifications=Teavitused
start.button.nonotifications=Uusi teavitusi pole
start.hero.description=\u00dcksikettev\u00f5tjatele ja v\u00e4ikeettev\u00f5tjatele transpordisektoris \u2013 t\u00e4ielikult digitaalne ja terviklik. Keskenduge oma \u00e4rile, meie hoolitseme kontoritöö eest.
start.hero.demo.hint=Demo k\u00e4ivitub kohe ettevalmistatud n\u00e4idisandmetega.
start.hero.trial.hint="Proovi kohe tasuta" loob sinu isikliku konto tasuta proovikuuks.
start.system.title=S\u00fcsteem
start.system.intro=\u00dcksikettev\u00f5tjate ja v\u00e4ikeettev\u00f5tjate jaoks transpordisektoris on otsustava t\u00e4htsusega, et nad saaksid keskenduda oma p\u00f5hitegevusele: klientide v\u00f5itmine ja kaupade kohaletoimetamine punktist A punkti B.
start.feature.setup.title=Seadistusabi

View File

@@ -806,11 +806,17 @@ register.notification.failed=Registration failed: {0}
# Start Page
start.title=VotianLT - Your Digital Transport Partner
start.button.login=Log In
start.button.demo=Demo
start.button.register=Register
login.demo.only.button=Demo access is only available through the Demo button on the start page.
demo.session.active=Demo mode is already being used by another user. Please try again later.
demo.start.error=Demo mode could not be started.
start.button.createorder=Create Job
start.button.notifications=Notifications
start.button.nonotifications=No new notifications
start.hero.description=For solo self-employed and small business owners in the transport industry - fully digital and all-in-one. Focus on your business, we take care of the paperwork.
start.hero.demo.hint=Demo starts immediately with prepared sample data.
start.hero.trial.hint="Try now for free" creates your own account for the free trial month.
start.system.title=The System
start.system.intro=For solo self-employed and small business owners in the transport industry, it is crucial that they can primarily focus on their actual business: winning customers and delivering goods from A to B.
start.feature.setup.title=Setup Wizard

View File

@@ -806,11 +806,17 @@ register.notification.failed=El registro ha fallado: {0}
# Start Page
start.title=VotianLT - Su socio digital de transporte
start.button.login=Iniciar sesi\u00f3n
start.button.demo=Demo
start.button.register=Registrarse
login.demo.only.button=El acceso demo solo est\u00e1 disponible mediante el bot\u00f3n Demo de la p\u00e1gina de inicio.
demo.session.active=Otro usuario ya est\u00e1 utilizando el modo demo. Por favor, int\u00e9ntelo de nuevo m\u00e1s tarde.
demo.start.error=No se pudo iniciar el modo demo.
start.button.createorder=Crear pedido
start.button.notifications=Notificaciones
start.button.nonotifications=No hay nuevas notificaciones
start.hero.description=Para aut\u00f3nomos y peque\u00f1os empresarios del sector del transporte - totalmente digital y de una sola pieza. Conc\u00e9ntrese en su negocio, nosotros nos encargamos del trabajo de oficina.
start.hero.demo.hint=La demo se inicia inmediatamente con datos de ejemplo preparados.
start.hero.trial.hint="Probar gratis ahora" crea su propia cuenta para el mes de prueba gratuito.
start.system.title=El sistema
start.system.intro=Para aut\u00f3nomos y peque\u00f1os empresarios del sector del transporte es de vital importancia poder concentrarse en su negocio principal: captar clientes y entregar mercanc\u00edas de A a B.
start.feature.setup.title=Asistente de configuraci\u00f3n

View File

@@ -806,11 +806,17 @@ register.notification.failed=\u00c9chec de l'inscription : {0}
# Start Page
start.title=VotianLT - Votre partenaire de transport num\u00e9rique
start.button.login=Se connecter
start.button.demo=Demo
start.button.register=S'inscrire
login.demo.only.button=L'acc\u00e8s d\u00e9mo est disponible uniquement via le bouton Demo de la page d'accueil.
demo.session.active=Le mode d\u00e9mo est d\u00e9j\u00e0 utilis\u00e9 par un autre utilisateur. Veuillez r\u00e9essayer plus tard.
demo.start.error=Impossible de d\u00e9marrer le mode d\u00e9mo.
start.button.createorder=Cr\u00e9ation de mission
start.button.notifications=Notifications
start.button.nonotifications=Aucune nouvelle notification
start.hero.description=Pour les travailleurs ind\u00e9pendants et les petites entreprises du secteur du transport - enti\u00e8rement num\u00e9rique et int\u00e9gr\u00e9. Concentrez-vous sur votre activit\u00e9, nous nous occupons de la paperasse.
start.hero.demo.hint=La d\u00e9mo d\u00e9marre imm\u00e9diatement avec des donn\u00e9es d'exemple pr\u00e9par\u00e9es.
start.hero.trial.hint="Tester gratuitement maintenant" cr\u00e9e votre propre compte pour le mois d'essai gratuit.
start.system.title=Le syst\u00e8me
start.system.intro=Pour les travailleurs ind\u00e9pendants et les petites entreprises du secteur du transport, il est essentiel de pouvoir se concentrer en priorit\u00e9 sur leur activit\u00e9 principale : gagner des clients et livrer des marchandises de A \u00e0 B.
start.feature.setup.title=Assistant de configuration

View File

@@ -806,11 +806,17 @@ register.notification.failed=Registracija nepavyko: {0}
# Start Page
start.title=VotianLT - Jūsų skaitmeninis transporto partneris
start.button.login=Prisijungti
start.button.demo=Demo
start.button.register=Registruotis
login.demo.only.button=Demo prieiga galima tik per prad\u017eios puslapio mygtuk\u0105 „Demo“.
demo.session.active=Demo re\u017eimu jau naudojasi kitas vartotojas. Bandykite dar kart\u0105 v\u0117liau.
demo.start.error=Nepavyko paleisti demo re\u017eimo.
start.button.createorder=Užsakymo kūrimas
start.button.notifications=Pranešimai
start.button.nonotifications=Nėra naujų pranešimų
start.hero.description=Individualiems verslininkams ir smulkaus verslo savininkams transporto sektoriuje visiškai skaitmeninis ir vientisas sprendimas. Sutelkite dėmesį į savo verslą, mes pasirūpinsime biuro darbu.
start.hero.demo.hint=Demo režimas iš karto paleidžiamas su paruoštais pavyzdiniais duomenimis.
start.hero.trial.hint=„Išbandyti nemokamai dabar“ sukuria jūsų paskyrą nemokamam bandomajam mėnesiui.
start.system.title=Sistema
start.system.intro=Individualiems verslininkams ir smulkaus verslo savininkams transporto sektoriuje labai svarbu, kad jie galėtų visų pirma sutelkti dėmesį į savo pagrindinį verslą: klientų pritraukimą ir prekių pristatymą iš taško A į tašką B.
start.feature.setup.title=Sąrankos vedlys

View File

@@ -806,11 +806,17 @@ register.notification.failed=Reģistrācija neizdevās: {0}
# Start Page
start.title=VotianLT - Jūsu digitālais transporta partneris
start.button.login=Pieteikties
start.button.demo=Demo
start.button.register=Reģistrēties
login.demo.only.button=Demonstrācijas piekļuve ir pieejama tikai caur sākumlapas pogu "Demo".
demo.session.active=Demonstrācijas režīmu jau izmanto cits lietotājs. Lūdzu, mēģiniet vēlreiz vēlāk.
demo.start.error=Neizdevās palaist demonstrācijas režīmu.
start.button.createorder=Izveidot uzdevumu
start.button.notifications=Paziņojumi
start.button.nonotifications=Nav jaunu paziņojumu
start.hero.description=Individuālajiem uzņēmējiem un mazajiem uzņēmumiem transporta nozarē \u2013 pilnībā digitāli un no viena avota. Koncentrējieties uz savu biznesu, mēs parūpēsimies par biroja darbu.
start.hero.demo.hint=Demo režīms tiek palaists uzreiz ar sagatavotiem parauga datiem.
start.hero.trial.hint=“Izmēģināt bez maksas tagad” izveido jūsu kontu bezmaksas izmēģinājuma mēnesim.
start.system.title=Sistēma
start.system.intro=Individuālajiem uzņēmējiem un mazajiem uzņēmumiem transporta nozarē ir būtiski svarīgi, lai viņi varētu koncentrēties uz savu pamatdarbību: klientu piesaisti un preču piegādi no A uz B.
start.feature.setup.title=Iestatīšanas palīgs

View File

@@ -806,11 +806,17 @@ register.notification.failed=Rejestracja nie powiod\u0142a si\u0119: {0}
# Start Page
start.title=VotianLT - Tw\u00f3j cyfrowy partner transportowy
start.button.login=Zaloguj si\u0119
start.button.demo=Demo
start.button.register=Zarejestruj si\u0119
login.demo.only.button=Dost\u0119p demo jest dost\u0119pny wy\u0142\u0105cznie przez przycisk Demo na stronie startowej.
demo.session.active=Tryb demo jest ju\u017c u\u017cywany przez innego u\u017cytkownika. Prosz\u0119 spr\u00f3bowa\u0107 ponownie p\u00f3\u017aniej.
demo.start.error=Nie uda\u0142o si\u0119 uruchomi\u0107 trybu demo.
start.button.createorder=Tworzenie zlece\u0144
start.button.notifications=Powiadomienia
start.button.nonotifications=Brak nowych powiadomie\u0144
start.hero.description=Dla samozatrudnionych i ma\u0142ych przedsi\u0119biorc\u00f3w w bran\u017cy transportowej - w pe\u0142ni cyfrowo i kompleksowo. Skoncentruj si\u0119 na swoim biznesie, a my zajmiemy si\u0119 prac\u0105 biurow\u0105.
start.hero.demo.hint=Demo uruchamia si\u0119 od razu z przygotowanymi danymi przyk\u0142adowymi.
start.hero.trial.hint="Testuj teraz za darmo" tworzy Twoje w\u0142asne konto na bezp\u0142atny miesi\u0105c pr\u00f3bny.
start.system.title=System
start.system.intro=Dla samozatrudnionych i ma\u0142ych przedsi\u0119biorc\u00f3w w bran\u017cy transportowej kluczowe jest, aby mogli skupi\u0107 si\u0119 przede wszystkim na swoim w\u0142a\u015bciwym biznesie: pozyskiwaniu klient\u00f3w i dostarczaniu towar\u00f3w z punktu A do punktu B.
start.feature.setup.title=Kreator konfiguracji

View File

@@ -806,11 +806,17 @@ register.notification.failed=Регистрация не удалась: {0}
# Start Page
start.title=VotianLT - Ваш цифровой транспортный партнёр
start.button.login=Войти
start.button.demo=Demo
start.button.register=Зарегистрироваться
login.demo.only.button=Демо-доступ доступен только через кнопку Demo на стартовой странице.
demo.session.active=Демо-режим уже используется другим пользователем. Пожалуйста, попробуйте позже.
demo.start.error=Не удалось запустить демо-режим.
start.button.createorder=Создание заказа
start.button.notifications=Уведомления
start.button.nonotifications=Нет новых уведомлений
start.hero.description=Для индивидуальных предпринимателей и малого бизнеса в транспортной отрасли \u2013 полностью цифровое и комплексное решение. Сосредоточьтесь на вашем бизнесе, а мы позаботимся о бумажной работе.
start.hero.demo.hint=Демо-режим запускается сразу с подготовленными примерными данными.
start.hero.trial.hint="Попробовать бесплатно сейчас" создаёт ваш собственный аккаунт для бесплатного пробного месяца.
start.system.title=Система
start.system.intro=Для индивидуальных предпринимателей и малого бизнеса в транспортной отрасли крайне важно сосредоточиться прежде всего на своём основном деле: привлечении клиентов и доставке товаров из пункта А в пункт Б.
start.feature.setup.title=Мастер настройки

View File

@@ -806,11 +806,17 @@ register.notification.failed=Kay\u0131t ba\u015far\u0131s\u0131z: {0}
# Start Page
start.title=VotianLT - Dijital Ta\u015f\u0131mac\u0131l\u0131k Orta\u011f\u0131n\u0131z
start.button.login=Giri\u015f Yap
start.button.demo=Demo
start.button.register=Kay\u0131t Ol
login.demo.only.button=Demo eri\u015fimi yaln\u0131zca ba\u015flang\u0131\u00e7 sayfas\u0131ndaki Demo d\u00fc\u011fmesi \u00fczerinden kullan\u0131labilir.
demo.session.active=Demo modu zaten ba\u015fka bir kullan\u0131c\u0131 taraf\u0131ndan kullan\u0131l\u0131yor. L\u00fctfen daha sonra tekrar deneyin.
demo.start.error=Demo modu ba\u015flat\u0131lamad\u0131.
start.button.createorder=\u0130\u015f Olu\u015ftur
start.button.notifications=Bildirimler
start.button.nonotifications=Yeni bildirim yok
start.hero.description=Ta\u015f\u0131mac\u0131l\u0131k sekt\u00f6r\u00fcndeki bireysel giri\u015fimciler ve k\u00fc\u00e7\u00fck i\u015fletme sahipleri i\u00e7in - tamamen dijital ve b\u00fct\u00fcnle\u015fik. \u0130\u015finize odaklan\u0131n, b\u00fcro i\u015flerini biz halledelim.
start.hero.demo.hint=Demo, \u00f6nceden haz\u0131rlanm\u0131\u015f \u00f6rnek verilerle hemen ba\u015flar.
start.hero.trial.hint="Hemen \u00fccretsiz deneyin" \u00fccretsiz deneme ay\u0131 i\u00e7in kendi hesab\u0131n\u0131z\u0131 olu\u015fturur.
start.system.title=Sistem
start.system.intro=Ta\u015f\u0131mac\u0131l\u0131k sekt\u00f6r\u00fcndeki bireysel giri\u015fimciler ve k\u00fc\u00e7\u00fck i\u015fletme sahipleri i\u00e7in as\u0131l i\u015flerine odaklanabilmeleri b\u00fcy\u00fck \u00f6nem ta\u015f\u0131maktad\u0131r: m\u00fc\u015fteri kazanmak ve mallar\u0131 A'dan B'ye ta\u015f\u0131mak.
start.feature.setup.title=Kurulum Sihirbaz\u0131

View File

@@ -0,0 +1,187 @@
{
"elements": [
{
"id": "demo-title",
"type": "header",
"text": "Rechnung",
"xPercent": "7.56",
"yPercent": "6.53",
"widthPercent": "28.07",
"heightPercent": "4.04",
"fontSize": 24,
"fontStyle": "bold",
"color": "#111111"
},
{
"id": "demo-number-label",
"type": "text",
"text": "Rechnungsnr.",
"xPercent": "67.06",
"yPercent": "7.48",
"widthPercent": "17.98",
"heightPercent": "2.97",
"fontSize": 11,
"fontStyle": "bold",
"color": "#111111"
},
{
"id": "demo-number-value",
"type": "text",
"text": "000000",
"xPercent": "84.03",
"yPercent": "7.48",
"widthPercent": "10.08",
"heightPercent": "2.97",
"fontSize": 11,
"fontStyle": "normal",
"color": "#111111",
"isStatic": true,
"variable": "invoice.number"
},
{
"id": "demo-date-label",
"type": "text",
"text": "Datum",
"xPercent": "67.06",
"yPercent": "11.52",
"widthPercent": "17.98",
"heightPercent": "2.97",
"fontSize": 11,
"fontStyle": "bold",
"color": "#111111"
},
{
"id": "demo-date-value",
"type": "text",
"text": "2026-01-01",
"xPercent": "84.03",
"yPercent": "11.52",
"widthPercent": "10.08",
"heightPercent": "2.97",
"fontSize": 11,
"fontStyle": "normal",
"color": "#111111",
"isStatic": true,
"variable": "invoice.date"
},
{
"id": "demo-sender",
"type": "text",
"text": null,
"xPercent": "7.56",
"yPercent": "16.51",
"widthPercent": "30.08",
"heightPercent": "2.97",
"fontSize": 12,
"fontStyle": "bold",
"color": "#111111",
"isStatic": true,
"variable": "masterdata.contact_name"
},
{
"id": "demo-customer",
"type": "customer",
"text": "Kundenname\nStraße Nr.\nPLZ Ort",
"xPercent": "7.56",
"yPercent": "25.06",
"widthPercent": "33.95",
"heightPercent": "12.00",
"fontSize": 12,
"fontStyle": "normal",
"color": "#111111"
},
{
"id": "demo-services",
"type": "text",
"text": "Leistungen",
"xPercent": "7.56",
"yPercent": "42.04",
"widthPercent": "85.04",
"heightPercent": "28.03",
"fontSize": 11,
"fontStyle": "normal",
"color": "#111111",
"isStatic": true,
"variable": "services.list"
},
{
"id": "demo-net-label",
"type": "text",
"text": "Netto",
"xPercent": "67.06",
"yPercent": "73.99",
"widthPercent": "11.93",
"heightPercent": "2.97",
"fontSize": 11,
"fontStyle": "bold",
"color": "#111111"
},
{
"id": "demo-net-value",
"type": "text",
"text": "0,00 €",
"xPercent": "80.00",
"yPercent": "73.99",
"widthPercent": "13.95",
"heightPercent": "2.97",
"fontSize": 11,
"fontStyle": "normal",
"color": "#111111",
"isStatic": true,
"variable": "invoice.net_total"
},
{
"id": "demo-total-label",
"type": "text",
"text": "Gesamt",
"xPercent": "67.06",
"yPercent": "78.98",
"widthPercent": "11.93",
"heightPercent": "3.44",
"fontSize": 13,
"fontStyle": "bold",
"color": "#111111"
},
{
"id": "demo-total-value",
"type": "text",
"text": "0,00 €",
"xPercent": "80.00",
"yPercent": "78.98",
"widthPercent": "13.95",
"heightPercent": "3.44",
"fontSize": 13,
"fontStyle": "bold",
"color": "#111111",
"isStatic": true,
"variable": "invoice.gross_total"
},
{
"id": "demo-template-note",
"type": "text",
"text": "Hinweis: Dieses Rechnungstemplate kann vom Benutzer ",
"xPercent": "0.00",
"yPercent": "20.78",
"widthPercent": "100.00",
"heightPercent": "4.51",
"fontSize": 20,
"fontStyle": "normal",
"textAlign": "center",
"color": "#555555"
},
{
"id": "element-1",
"type": "text",
"text": "frei angepasst werden.",
"xPercent": "31.93",
"yPercent": "24.94",
"widthPercent": "25.21",
"heightPercent": "3.56",
"fontSize": 20,
"color": "#333333",
"isStatic": false,
"isCustomer": false,
"variable": null
}
]
}

View File

@@ -0,0 +1,165 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.pages.domain.CustomerRepository;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.BarcodeRepository;
import de.assecutor.votianlt.repository.CargoItemRepository;
import de.assecutor.votianlt.repository.CommentRepository;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.InvoiceTemplateRepository;
import de.assecutor.votianlt.repository.JobHistoryRepository;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.LocationPositionRepository;
import de.assecutor.votianlt.repository.MessageRepository;
import de.assecutor.votianlt.repository.PhotoRepository;
import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.SignatureRepository;
import de.assecutor.votianlt.repository.TaskRepository;
import de.assecutor.votianlt.repository.TaskTemplateRepository;
import de.assecutor.votianlt.repository.UserInvoiceDataRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SessionAuthenticationService;
import java.util.List;
import java.util.Optional;
import org.bson.types.ObjectId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
@ExtendWith(MockitoExtension.class)
class DemoModeServiceTest {
@Mock
private SessionAuthenticationService sessionAuthenticationService;
@Mock
private UserRepository userRepository;
@Mock
private CustomerRepository customerRepository;
@Mock
private JobRepository jobRepository;
@Mock
private CargoItemRepository cargoItemRepository;
@Mock
private TaskRepository taskRepository;
@Mock
private JobHistoryRepository jobHistoryRepository;
@Mock
private CustomerInvoiceRepository customerInvoiceRepository;
@Mock
private ServiceRepository serviceRepository;
@Mock
private AppUserRepository appUserRepository;
@Mock
private TaskTemplateRepository taskTemplateRepository;
@Mock
private InvoiceTemplateRepository invoiceTemplateRepository;
@Mock
private UserInvoiceDataRepository userInvoiceDataRepository;
@Mock
private MessageRepository messageRepository;
@Mock
private LocationPositionRepository locationPositionRepository;
@Mock
private PhotoRepository photoRepository;
@Mock
private SignatureRepository signatureRepository;
@Mock
private BarcodeRepository barcodeRepository;
@Mock
private CommentRepository commentRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Captor
private ArgumentCaptor<List<Job>> jobsCaptor;
@Captor
private ArgumentCaptor<List<de.assecutor.votianlt.model.Service>> servicesCaptor;
@Captor
private ArgumentCaptor<InvoiceTemplate> invoiceTemplateCaptor;
private DemoSessionRegistry demoSessionRegistry;
private DemoModeService demoModeService;
@BeforeEach
void setUp() {
demoSessionRegistry = new DemoSessionRegistry();
demoModeService = new DemoModeService(demoSessionRegistry, sessionAuthenticationService, userRepository,
customerRepository, jobRepository, cargoItemRepository, taskRepository, jobHistoryRepository,
customerInvoiceRepository, serviceRepository, appUserRepository, taskTemplateRepository,
invoiceTemplateRepository, userInvoiceDataRepository, messageRepository, locationPositionRepository,
photoRepository, signatureRepository, barcodeRepository, commentRepository, passwordEncoder);
}
@Test
void tryPrepareDemoSessionSeedsTwoDemoJobs() {
User demoUser = new User();
demoUser.setId(new ObjectId());
when(userRepository.findByEmail(DemoModeService.DEMO_USERNAME)).thenReturn(Optional.of(demoUser));
when(passwordEncoder.encode(DemoModeService.DEMO_PASSWORD)).thenReturn("encoded-demo");
when(customerRepository.findByOwner(demoUser.getId())).thenReturn(List.of());
when(jobRepository.findByCreatedBy(demoUser.getId().toHexString())).thenReturn(List.of());
when(appUserRepository.findByErstelltVon(demoUser.getId())).thenReturn(List.of());
when(taskTemplateRepository.findByUserIdOrderByTemplateNameAsc(demoUser.getId())).thenReturn(List.of());
when(customerInvoiceRepository.findByUserId(demoUser.getId().toHexString())).thenReturn(List.of());
when(customerRepository.saveAll(anyList())).thenAnswer(invocation -> invocation.getArgument(0));
when(serviceRepository.saveAll(anyList())).thenAnswer(invocation -> invocation.getArgument(0));
when(jobRepository.saveAll(anyList())).thenAnswer(invocation -> invocation.getArgument(0));
boolean prepared = demoModeService.tryPrepareDemoSession("demo-session");
assertThat(prepared).isTrue();
verify(serviceRepository).saveAll(servicesCaptor.capture());
assertThat(servicesCaptor.getValue()).hasSize(2);
verify(invoiceTemplateRepository).save(invoiceTemplateCaptor.capture());
assertThat(invoiceTemplateCaptor.getValue().getUserId()).isEqualTo(demoUser.getId().toHexString());
assertThat(invoiceTemplateCaptor.getValue().getTemplateData())
.contains("elements")
.contains("services.list")
.contains("invoice.gross_total")
.contains("frei angepasst werden")
.contains("textAlign")
.contains("center")
.contains("fontSize")
.contains("20");
verify(jobRepository).saveAll(jobsCaptor.capture());
assertThat(jobsCaptor.getValue()).hasSize(2);
assertThat(jobsCaptor.getValue()).extracting(Job::getJobNumber)
.containsExactly("DEMO-AKTIV-001", "DEMO-ERLEDIGT-001");
assertThat(jobsCaptor.getValue()).extracting(Job::getStatus)
.containsExactly(JobStatus.IN_PROGRESS, JobStatus.COMPLETED);
assertThat(jobsCaptor.getValue()).allSatisfy(job -> {
assertThat(job.getServiceIds()).hasSize(1);
assertThat(job.getSelectedServices()).hasSize(1);
assertThat(job.getSelectedServices().get(0).getDeliveryStationOrder()).isEqualTo(0);
assertThat(job.getSelectedServices().get(0).getServiceId()).isEqualTo(job.getServiceIds().get(0));
});
assertThat(demoSessionRegistry.isHeldBy("demo-session")).isTrue();
}
@Test
void cleanupAndReleaseIfOwnedSkipsForeignSessions() {
demoSessionRegistry.acquire("active-session");
demoModeService.cleanupAndReleaseIfOwned("other-session");
verify(userRepository, never()).findByEmail(anyString());
assertThat(demoSessionRegistry.isHeldBy("active-session")).isTrue();
}
}

View File

@@ -0,0 +1,29 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class DemoSessionRegistryTest {
private final DemoSessionRegistry registry = new DemoSessionRegistry();
@Test
void acquireAllowsSameSessionButBlocksOtherSessions() {
assertThat(registry.acquire("session-a")).isTrue();
assertThat(registry.acquire("session-a")).isTrue();
assertThat(registry.acquire("session-b")).isFalse();
assertThat(registry.isHeldBy("session-a")).isTrue();
assertThat(registry.isHeldBy("session-b")).isFalse();
}
@Test
void releaseFreesLockForNextSession() {
assertThat(registry.acquire("session-a")).isTrue();
registry.release("session-a");
assertThat(registry.acquire("session-b")).isTrue();
assertThat(registry.isHeldBy("session-b")).isTrue();
}
}