Compare commits
53 Commits
6f31eee9b0
...
feature/TT
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29a0336c39 | ||
|
|
78f548eb28 | ||
|
|
01fa55553b | ||
|
|
804bb9dc0a | ||
|
|
0d2334da48 | ||
|
|
f24fef9b16 | ||
|
|
13d8970b7a | ||
|
|
eed815c646 | ||
|
|
5bacd33ea0 | ||
|
|
9bf604719a | ||
|
|
8dcd02c593 | ||
|
|
9efdacea9e | ||
|
|
587e2dbdf2 | ||
|
|
4f3a5d4d66 | ||
|
|
590babea9e | ||
|
|
4e9fd80296 | ||
|
|
eb60e5af16 | ||
|
|
eb02ef4490 | ||
|
|
3c705b6ad8 | ||
|
|
464d644c4f | ||
|
|
c04e4f85fc | ||
|
|
d3c41b3e6c | ||
|
|
b1202577a8 | ||
|
|
39af511bbd | ||
|
|
32dfb0d6b4 | ||
|
|
e70a559f1d | ||
|
|
bbf3e4a20e | ||
|
|
64abe911ee | ||
|
|
d3265d364c | ||
|
|
8be2920952 | ||
|
|
98db0678b4 | ||
|
|
f88274be3d | ||
|
|
6d56458bb9 | ||
|
|
daf43b7ce2 | ||
| 6b72da2d1e | |||
|
|
0361bd7bed | ||
| 8435b976d6 | |||
| ac3993545e | |||
| c62127e24d | |||
| f36fd63a04 | |||
| 75ddbecc79 | |||
| 7de22a723e | |||
| a64d56bbd9 | |||
| 7446839a7b | |||
|
|
2c7dd8c8f2 | ||
|
|
44672876be | ||
|
|
e828e83991 | ||
|
|
6b57a0c5e5 | ||
|
|
0f005ca130 | ||
|
|
acb96dc1c6 | ||
|
|
171f71209b | ||
|
|
94931bd7e0 | ||
|
|
9e4b0de915 |
61
.gitea/workflows/buildAndDeply.yaml
Normal file
61
.gitea/workflows/buildAndDeply.yaml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
name: Build-und-Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ci-java21-node20:latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Map gitea into /etc/hosts (ohne ip)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
gw_hex="$(awk '$2=="00000000" {print $3; exit}' /proc/net/route || true)"
|
||||||
|
if [ -n "${gw_hex:-}" ]; then
|
||||||
|
gw="$(printf "%d.%d.%d.%d" 0x${gw_hex:6:2} 0x${gw_hex:4:2} 0x${gw_hex:2:2} 0x${gw_hex:0:2})"
|
||||||
|
else
|
||||||
|
gw="172.17.0.1"
|
||||||
|
fi
|
||||||
|
echo "$gw gitea" >> /etc/hosts
|
||||||
|
getent hosts gitea || true
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🧰 Gradle vorbereiten & Version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
test -f ./gradlew || { echo "gradlew fehlt – bitte lokal 'gradle wrapper' ausführen und committen."; exit 1; }
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew --version
|
||||||
|
|
||||||
|
- name: 🏗️ Build Backend + Vaadin (Production)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
# Vaadin im Production-Mode bündeln
|
||||||
|
./gradlew clean build -x test --no-daemon -Pvaadin.productionMode
|
||||||
|
ls -al build/libs
|
||||||
|
JAR="$(ls -1 build/libs/*.jar | head -n1)"
|
||||||
|
echo "Gebautes JAR: $JAR"
|
||||||
|
# kurze Sichtprüfung, dass Frontend drin ist
|
||||||
|
jar tf "$JAR" | grep -E 'VAADIN|static/|index\.html' | head || true
|
||||||
|
|
||||||
|
- name: 📦 Nach /shared_jar_data deployen (fester Name + Archiv)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
ts="$(date +'%Y%m%d-%H%M%S')"
|
||||||
|
mkdir -p /shared_jar_data /shared_jar_data/archive
|
||||||
|
SRC="$(ls -1 build/libs/*.jar | head -n1)"
|
||||||
|
# WICHTIG: Fester Name, den docker-compose startet
|
||||||
|
cp -f "$SRC" "/shared_jar_data/MyTimeTracker-0.0.1-SNAPSHOT.jar"
|
||||||
|
# Zusätzlich archivieren
|
||||||
|
cp -f "$SRC" "/shared_jar_data/archive/MyTimeTracker-${ts}.jar"
|
||||||
|
ls -al /shared_jar_data | tail -n +1
|
||||||
14
.gitea/workflows/restart.yaml
Normal file
14
.gitea/workflows/restart.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name: Docker Restart Manuell
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
restart-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Docker Compose Restart
|
||||||
|
run: |
|
||||||
|
cd /home/docker/apps/mytimetracker
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -91,4 +91,6 @@ coverage/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*.bak
|
*.bak
|
||||||
*.tmp
|
*.tmpdata/*.mv.db
|
||||||
|
src/main/frontend/generated/
|
||||||
|
/data/*.mv.db
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
FROM ubuntu:latest
|
FROM eclipse-temurin:17-jdk-alpine
|
||||||
LABEL authors="me"
|
WORKDIR /app
|
||||||
|
COPY --chown=appuser:appuser build/libs/MyTimeTracker-0.0.1-SNAPSHOT.jar app.jar
|
||||||
ENTRYPOINT ["top", "-b"]
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
46
HELP.md
Normal file
46
HELP.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Read Me First
|
||||||
|
|
||||||
|
The following was discovered as part of building this project:
|
||||||
|
|
||||||
|
* No Docker Compose services found. As of now, the application won't start! Please add at least one service to the
|
||||||
|
`compose.yaml` file.
|
||||||
|
|
||||||
|
# Getting Started
|
||||||
|
|
||||||
|
### Reference Documentation
|
||||||
|
|
||||||
|
For further reference, please consider the following sections:
|
||||||
|
|
||||||
|
* [Official Gradle documentation](https://docs.gradle.org)
|
||||||
|
* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.3/gradle-plugin)
|
||||||
|
* [Create an OCI image](https://docs.spring.io/spring-boot/3.5.3/gradle-plugin/packaging-oci-image.html)
|
||||||
|
* [Spring Data JPA](https://docs.spring.io/spring-boot/3.5.3/reference/data/sql.html#data.sql.jpa-and-spring-data)
|
||||||
|
* [Spring Boot DevTools](https://docs.spring.io/spring-boot/3.5.3/reference/using/devtools.html)
|
||||||
|
* [Docker Compose Support](https://docs.spring.io/spring-boot/3.5.3/reference/features/dev-services.html#features.dev-services.docker-compose)
|
||||||
|
* [Spring Security](https://docs.spring.io/spring-boot/3.5.3/reference/web/spring-security.html)
|
||||||
|
* [Vaadin](https://vaadin.com/docs)
|
||||||
|
|
||||||
|
### Guides
|
||||||
|
|
||||||
|
The following guides illustrate how to use some features concretely:
|
||||||
|
|
||||||
|
* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/)
|
||||||
|
* [Securing a Web Application](https://spring.io/guides/gs/securing-web/)
|
||||||
|
* [Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/)
|
||||||
|
* [Authenticating a User with LDAP](https://spring.io/guides/gs/authenticating-ldap/)
|
||||||
|
* [Creating CRUD UI with Vaadin](https://spring.io/guides/gs/crud-with-vaadin/)
|
||||||
|
|
||||||
|
### Additional Links
|
||||||
|
|
||||||
|
These additional references should also help you:
|
||||||
|
|
||||||
|
* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle)
|
||||||
|
|
||||||
|
### Docker Compose support
|
||||||
|
|
||||||
|
This project contains a Docker Compose file named `compose.yaml`.
|
||||||
|
|
||||||
|
However, no services were found. As of now, the application won't start!
|
||||||
|
|
||||||
|
Please make sure to add at least one service in the `compose.yaml` file.
|
||||||
|
|
||||||
17
build.gradle
17
build.gradle
@@ -10,7 +10,7 @@ version = '0.0.1-SNAPSHOT'
|
|||||||
|
|
||||||
java {
|
java {
|
||||||
toolchain {
|
toolchain {
|
||||||
languageVersion = JavaLanguageVersion.of(17)
|
languageVersion = JavaLanguageVersion.of(21)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ configurations {
|
|||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url 'https://maven.vaadin.com/vaadin-addons' }
|
||||||
}
|
}
|
||||||
|
|
||||||
ext {
|
ext {
|
||||||
@@ -32,10 +33,16 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
implementation 'com.vaadin:vaadin-spring-boot-starter'
|
implementation 'com.vaadin:vaadin-spring-boot-starter'
|
||||||
|
implementation("org.vaadin.crudui:crudui:7.2.0")
|
||||||
|
|
||||||
|
runtimeOnly 'com.mysql:mysql-connector-j:8.3.0'
|
||||||
|
|
||||||
|
developmentOnly 'com.h2database:h2'
|
||||||
|
|
||||||
compileOnly 'org.projectlombok:lombok'
|
compileOnly 'org.projectlombok:lombok'
|
||||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
|
||||||
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
|
|
||||||
annotationProcessor 'org.projectlombok:lombok'
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
|
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||||
|
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
testImplementation 'org.springframework.security:spring-security-test'
|
testImplementation 'org.springframework.security:spring-security-test'
|
||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
@@ -50,3 +57,7 @@ dependencyManagement {
|
|||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vaadin {
|
||||||
|
productionMode = true
|
||||||
|
}
|
||||||
|
|||||||
40
compose.yml
40
compose.yml
@@ -1,31 +1,51 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: mysql:8
|
image: mysql:8
|
||||||
restart: always
|
|
||||||
container_name: mytimetracker-db
|
container_name: mytimetracker-db
|
||||||
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
MYSQL_DATABASE: mytimetracker
|
MYSQL_DATABASE: mytimetracker
|
||||||
MYSQL_ROOT_PASSWORD: root
|
MYSQL_ROOT_PASSWORD: root
|
||||||
MYSQL_USER: appuser
|
MYSQL_USER: appuser
|
||||||
MYSQL_PASSWORD: apppass
|
MYSQL_PASSWORD: apppass
|
||||||
ports:
|
|
||||||
- "3306:3306"
|
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/mysql
|
- db_data:/var/lib/mysql
|
||||||
|
expose:
|
||||||
|
- "3306"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
image: eclipse-temurin:17-jdk-alpine
|
||||||
container_name: mytimetracker-app
|
container_name: mytimetracker-app
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
ports:
|
working_dir: /app
|
||||||
- "8080:8080"
|
command: java -jar /app/MyTimeTracker-0.0.1-SNAPSHOT.jar
|
||||||
environment:
|
environment:
|
||||||
SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/mytimetracker?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
SPRING_PROFILES_ACTIVE: docker
|
||||||
SPRING_DATASOURCE_USERNAME: appuser
|
volumes:
|
||||||
SPRING_DATASOURCE_PASSWORD: apppass
|
- shared_jar_data:/app:ro # ⬅️ Direkt ins /app mounten
|
||||||
SPRING_JPA_HIBERNATE_DDL_AUTO: update
|
ports:
|
||||||
|
- "8400:8080"
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.mytimetracker.rule=Host(`timetracker.nilzbu.de`)"
|
||||||
|
- "traefik.http.routers.mytimetracker.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mytimetracker.tls=true"
|
||||||
|
- "traefik.http.routers.mytimetracker.tls.certresolver=letsEncrypt"
|
||||||
|
- "traefik.http.services.mytimetracker.loadbalancer.server.port=8080"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
shared_jar_data:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
2461
data/zeiterfassungdb.trace.db
Normal file
2461
data/zeiterfassungdb.trace.db
Normal file
File diff suppressed because it is too large
Load Diff
108
package.json
Normal file
108
package.json
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
{
|
||||||
|
"name": "no-name",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@polymer/polymer": "3.5.2",
|
||||||
|
"@vaadin/bundles": "24.8.2",
|
||||||
|
"@vaadin/common-frontend": "0.0.19",
|
||||||
|
"@vaadin/polymer-legacy-adapter": "24.8.2",
|
||||||
|
"@vaadin/react-components": "24.8.2",
|
||||||
|
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||||
|
"@vaadin/vaadin-lumo-styles": "24.8.2",
|
||||||
|
"@vaadin/vaadin-material-styles": "24.8.2",
|
||||||
|
"@vaadin/vaadin-themable-mixin": "24.8.2",
|
||||||
|
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
|
"construct-style-sheets-polyfill": "3.1.0",
|
||||||
|
"date-fns": "2.29.3",
|
||||||
|
"lit": "3.3.0",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"react-router": "7.6.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/preset-react": "7.27.1",
|
||||||
|
"@preact/signals-react-transform": "0.5.1",
|
||||||
|
"@rollup/plugin-replace": "6.0.2",
|
||||||
|
"@rollup/pluginutils": "5.1.4",
|
||||||
|
"@types/react": "18.3.23",
|
||||||
|
"@types/react-dom": "18.3.7",
|
||||||
|
"@vitejs/plugin-react": "4.5.0",
|
||||||
|
"async": "3.2.6",
|
||||||
|
"glob": "11.0.2",
|
||||||
|
"magic-string": "0.30.17",
|
||||||
|
"rollup-plugin-brotli": "3.1.0",
|
||||||
|
"rollup-plugin-visualizer": "5.14.0",
|
||||||
|
"strip-css-comments": "5.0.0",
|
||||||
|
"transform-ast": "2.4.4",
|
||||||
|
"typescript": "5.8.3",
|
||||||
|
"vite": "6.3.5",
|
||||||
|
"vite-plugin-checker": "0.9.3",
|
||||||
|
"workbox-build": "7.3.0",
|
||||||
|
"workbox-core": "7.3.0",
|
||||||
|
"workbox-precaching": "7.3.0"
|
||||||
|
},
|
||||||
|
"vaadin": {
|
||||||
|
"dependencies": {
|
||||||
|
"@polymer/polymer": "3.5.2",
|
||||||
|
"@vaadin/bundles": "24.8.2",
|
||||||
|
"@vaadin/common-frontend": "0.0.19",
|
||||||
|
"@vaadin/polymer-legacy-adapter": "24.8.2",
|
||||||
|
"@vaadin/react-components": "24.8.2",
|
||||||
|
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||||
|
"@vaadin/vaadin-lumo-styles": "24.8.2",
|
||||||
|
"@vaadin/vaadin-material-styles": "24.8.2",
|
||||||
|
"@vaadin/vaadin-themable-mixin": "24.8.2",
|
||||||
|
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||||
|
"construct-style-sheets-polyfill": "3.1.0",
|
||||||
|
"date-fns": "2.29.3",
|
||||||
|
"lit": "3.3.0",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"react-router": "7.6.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/preset-react": "7.27.1",
|
||||||
|
"@preact/signals-react-transform": "0.5.1",
|
||||||
|
"@rollup/plugin-replace": "6.0.2",
|
||||||
|
"@rollup/pluginutils": "5.1.4",
|
||||||
|
"@types/react": "18.3.23",
|
||||||
|
"@types/react-dom": "18.3.7",
|
||||||
|
"@vitejs/plugin-react": "4.5.0",
|
||||||
|
"async": "3.2.6",
|
||||||
|
"glob": "11.0.2",
|
||||||
|
"magic-string": "0.30.17",
|
||||||
|
"rollup-plugin-brotli": "3.1.0",
|
||||||
|
"rollup-plugin-visualizer": "5.14.0",
|
||||||
|
"strip-css-comments": "5.0.0",
|
||||||
|
"transform-ast": "2.4.4",
|
||||||
|
"typescript": "5.8.3",
|
||||||
|
"vite": "6.3.5",
|
||||||
|
"vite-plugin-checker": "0.9.3",
|
||||||
|
"workbox-build": "7.3.0",
|
||||||
|
"workbox-core": "7.3.0",
|
||||||
|
"workbox-precaching": "7.3.0"
|
||||||
|
},
|
||||||
|
"hash": "ec93d40cd90dd1cc764cbc254769d2ed4e05c17d29a2dc228e367bcd2fb20f42"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@vaadin/bundles": "$@vaadin/bundles",
|
||||||
|
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
|
||||||
|
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
|
||||||
|
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
|
||||||
|
"@vaadin/react-components": "$@vaadin/react-components",
|
||||||
|
"@vaadin/common-frontend": "$@vaadin/common-frontend",
|
||||||
|
"react-dom": "$react-dom",
|
||||||
|
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
|
||||||
|
"lit": "$lit",
|
||||||
|
"@polymer/polymer": "$@polymer/polymer",
|
||||||
|
"react": "$react",
|
||||||
|
"react-router": "$react-router",
|
||||||
|
"date-fns": "$date-fns",
|
||||||
|
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
|
||||||
|
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
|
||||||
|
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles",
|
||||||
|
"chart.js": "$chart.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.9.0'
|
||||||
|
}
|
||||||
|
|
||||||
rootProject.name = 'MyTimeTracker'
|
rootProject.name = 'MyTimeTracker'
|
||||||
|
|||||||
32
src/main/bundles/README.md
Normal file
32
src/main/bundles/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
This directory is automatically generated by Vaadin and contains the pre-compiled
|
||||||
|
frontend files/resources for your project (frontend development bundle).
|
||||||
|
|
||||||
|
It should be added to Version Control System and committed, so that other developers
|
||||||
|
do not have to compile it again.
|
||||||
|
|
||||||
|
Frontend development bundle is automatically updated when needed:
|
||||||
|
- an npm/pnpm package is added with @NpmPackage or directly into package.json
|
||||||
|
- CSS, JavaScript or TypeScript files are added with @CssImport, @JsModule or @JavaScript
|
||||||
|
- Vaadin add-on with front-end customizations is added
|
||||||
|
- Custom theme imports/assets added into 'theme.json' file
|
||||||
|
- Exported web component is added.
|
||||||
|
|
||||||
|
If your project development needs a hot deployment of the frontend changes,
|
||||||
|
you can switch Flow to use Vite development server (default in Vaadin 23.3 and earlier versions):
|
||||||
|
- set `vaadin.frontend.hotdeploy=true` in `application.properties`
|
||||||
|
- configure `vaadin-maven-plugin`:
|
||||||
|
```
|
||||||
|
<configuration>
|
||||||
|
<frontendHotdeploy>true</frontendHotdeploy>
|
||||||
|
</configuration>
|
||||||
|
```
|
||||||
|
- configure `jetty-maven-plugin`:
|
||||||
|
```
|
||||||
|
<configuration>
|
||||||
|
<systemProperties>
|
||||||
|
<vaadin.frontend.hotdeploy>true</vaadin.frontend.hotdeploy>
|
||||||
|
</systemProperties>
|
||||||
|
</configuration>
|
||||||
|
```
|
||||||
|
|
||||||
|
Read more [about Vaadin development mode](https://vaadin.com/docs/next/flow/configuration/development-mode#precompiled-bundle).
|
||||||
BIN
src/main/bundles/dev.bundle
Normal file
BIN
src/main/bundles/dev.bundle
Normal file
Binary file not shown.
BIN
src/main/bundles/prod.bundle
Normal file
BIN
src/main/bundles/prod.bundle
Normal file
Binary file not shown.
@@ -1 +1,2 @@
|
|||||||
src/main/frontend/chart-helper.js
|
import Chart from 'chart.js/auto';
|
||||||
|
window.Chart = Chart;
|
||||||
23
src/main/frontend/index.html
Normal file
23
src/main/frontend/index.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
This file is auto-generated by Vaadin.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<style>
|
||||||
|
html, body, #outlet {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- This outlet div is where the views are rendered -->
|
||||||
|
<div id="outlet"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
window.customElements.define('chart-js', class extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 400;
|
||||||
|
canvas.height = 200;
|
||||||
|
this.appendChild(canvas);
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
}
|
||||||
|
|
||||||
|
initChart(type, dataString) {
|
||||||
|
const config = JSON.parse(dataString);
|
||||||
|
this.chart = new Chart(this.ctx, {
|
||||||
|
type: type,
|
||||||
|
data: config.data,
|
||||||
|
options: config.options || {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChart(dataString) {
|
||||||
|
const config = JSON.parse(dataString);
|
||||||
|
this.chart.data = config.data;
|
||||||
|
this.chart.options = config.options || {};
|
||||||
|
this.chart.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,32 +1,69 @@
|
|||||||
:root {
|
:root {
|
||||||
/* Hintergrund- und Textfarben */
|
--lumo-font-size: 1rem;
|
||||||
--lumo-base-color: hsl(0, 0%, 8%); /* Sehr dunkles Grau (fast schwarz) */
|
--lumo-font-size-xxxl: 3rem;
|
||||||
--lumo-body-text-color: hsl(150, 30%, 90%); /* Hellgrünliches Weiß für Text */
|
--lumo-font-size-xxl: 2.25rem;
|
||||||
|
--lumo-font-size-xl: 1.75rem;
|
||||||
|
--lumo-font-size-l: 1.375rem;
|
||||||
|
--lumo-font-size-m: 1.125rem;
|
||||||
|
--lumo-font-size-s: 1rem;
|
||||||
|
--lumo-font-size-xs: 0.875rem;
|
||||||
|
--lumo-font-size-xxs: 0.8125rem;
|
||||||
|
|
||||||
/* Primärfarbe in grün */
|
--lumo-primary-text-color: rgb(9, 134, 24);
|
||||||
--lumo-primary-color: hsl(145, 80%, 40%);
|
--lumo-primary-color-50pct: rgba(9, 134, 24, 0.5);
|
||||||
--lumo-primary-color-50pct: hsla(145, 80%, 40%, 0.5);
|
--lumo-primary-color-10pct: rgba(9, 134, 24, 0.1);
|
||||||
--lumo-primary-color-10pct: hsla(145, 80%, 40%, 0.1);
|
--lumo-primary-color: hsl(127, 87%, 28%);
|
||||||
|
|
||||||
/* Sekundärfarben / Akzente */
|
--lumo-base-color: #dadcc7;
|
||||||
--lumo-success-color: hsl(145, 80%, 35%);
|
|
||||||
--lumo-error-color: hsl(0, 80%, 60%);
|
--lumo-tint-5pct: rgba(101, 105, 63, 0.05);
|
||||||
--lumo-border-radius: 6px;
|
--lumo-tint-10pct: rgba(101, 105, 63, 0.1);
|
||||||
|
--lumo-tint-20pct: rgba(101, 105, 63, 0.2);
|
||||||
|
--lumo-tint-30pct: rgba(101, 105, 63, 0.3);
|
||||||
|
--lumo-tint-40pct: rgba(101, 105, 63, 0.4);
|
||||||
|
--lumo-tint-50pct: rgba(101, 105, 63, 0.5);
|
||||||
|
--lumo-tint-60pct: rgba(101, 105, 63, 0.6);
|
||||||
|
--lumo-tint-70pct: rgba(101, 105, 63, 0.7);
|
||||||
|
--lumo-tint-80pct: rgba(101, 105, 63, 0.8);
|
||||||
|
--lumo-tint-90pct: rgba(101, 105, 63, 0.9);
|
||||||
|
--lumo-tint: #65693f;
|
||||||
|
|
||||||
|
--lumo-success-text-color: rgb(56, 204, 36);
|
||||||
|
--lumo-success-color-50pct: rgba(56, 204, 36, 0.5);
|
||||||
|
--lumo-success-color-10pct: rgba(56, 204, 36, 0.1);
|
||||||
|
--lumo-success-color: hsl(113, 70%, 47%);
|
||||||
|
|
||||||
|
/* Lumo-Tokens, die IntelliJ anmeckert – mit sinnvollen Defaults */
|
||||||
|
--lumo-space-xs: 0.25rem;
|
||||||
|
--lumo-border-radius-s: 0.25rem;
|
||||||
|
--lumo-contrast-10pct: rgba(0, 0, 0, 0.06);
|
||||||
|
--lumo-size-s: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hintergrundfarbe für Schaltflächen */
|
/* Deckender Hintergrund + Rahmen für die Filter-Wrapper im Header */
|
||||||
[theme~="primary"] {
|
.filter-cell {
|
||||||
background-color: var(--lumo-primary-color);
|
background: var(--lumo-base-color, #fff);
|
||||||
color: white;
|
padding: var(--lumo-space-xs, 0.25rem);
|
||||||
|
border-radius: var(--lumo-border-radius-s, 0.25rem);
|
||||||
|
/* zarte Kontur; Fallback, falls IntelliJ die Var. nicht kennt */
|
||||||
|
box-shadow: inset 0 0 0 1px var(--lumo-contrast-10pct, rgba(0,0,0,0.06));
|
||||||
|
display: block;
|
||||||
|
background-clip: padding-box; /* verhindert „Ausbluten“ */
|
||||||
}
|
}
|
||||||
|
|
||||||
vaadin-button {
|
vaadin-grid::part(header-row),
|
||||||
border-radius: var(--lumo-border-radius);
|
|
||||||
|
vaadin-grid::part(header-cell) {
|
||||||
|
background: var(--lumo-base-color, #fff);
|
||||||
|
box-shadow: 0 1px 0 0 var(--lumo-contrast-10pct, rgba(0,0,0,0.06));
|
||||||
|
min-height: var(--lumo-size-s, 2rem); /* gleichmäßige Höhe */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grid-Styling */
|
.filter-cell :is(vaadin-date-picker,
|
||||||
vaadin-grid {
|
vaadin-combo-box,
|
||||||
border-radius: var(--lumo-border-radius);
|
vaadin-text-field,
|
||||||
background-color: var(--lumo-base-color);
|
vaadin-checkbox,
|
||||||
color: var(--lumo-body-text-color);
|
vaadin-button) {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,9 @@ package de.nilzbu.mytimetracker.config;
|
|||||||
|
|
||||||
import com.vaadin.flow.component.page.AppShellConfigurator;
|
import com.vaadin.flow.component.page.AppShellConfigurator;
|
||||||
import com.vaadin.flow.theme.Theme;
|
import com.vaadin.flow.theme.Theme;
|
||||||
|
import com.vaadin.flow.theme.lumo.Lumo;
|
||||||
|
import com.vaadin.flow.theme.material.Material;
|
||||||
|
|
||||||
@Theme(value = "my-theme") // oder dein tatsächlicher Theme-Name
|
@Theme(value = "myTheme")
|
||||||
public class AppShellConfig implements AppShellConfigurator {
|
public class AppShellConfig implements AppShellConfigurator {
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,31 @@
|
|||||||
package de.nilzbu.mytimetracker.config;
|
package de.nilzbu.mytimetracker.config;
|
||||||
|
|
||||||
|
import de.nilzbu.mytimetracker.model.User;
|
||||||
|
import de.nilzbu.mytimetracker.repository.UserRepository;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
public class DataInitializer {
|
public class DataInitializer {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CommandLineRunner initDefaultAdmin(UserRepository userRepository) {
|
||||||
|
return args -> {
|
||||||
|
if (userRepository.findByUsername("admin").isEmpty()) {
|
||||||
|
User admin = User.builder()
|
||||||
|
.username("admin")
|
||||||
|
.password(new BCryptPasswordEncoder().encode("admin"))
|
||||||
|
.roles(Set.of("ROLE_ADMIN"))
|
||||||
|
.enabled(true)
|
||||||
|
.locked(false)
|
||||||
|
.build();
|
||||||
|
userRepository.save(admin);
|
||||||
|
System.out.println(">> Default-Admin wurde erstellt: admin / admin");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,86 @@
|
|||||||
package de.nilzbu.mytimetracker.config;
|
package de.nilzbu.mytimetracker.config;
|
||||||
|
|
||||||
|
import de.nilzbu.mytimetracker.model.DayStatus;
|
||||||
|
import de.nilzbu.mytimetracker.model.TimeEntry;
|
||||||
|
import de.nilzbu.mytimetracker.model.User;
|
||||||
|
import de.nilzbu.mytimetracker.repository.TimeEntryRepository;
|
||||||
|
import de.nilzbu.mytimetracker.repository.UserRepository;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.*;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Component
|
||||||
public class SeedDataGenerator {
|
public class SeedDataGenerator {
|
||||||
|
|
||||||
|
private final TimeEntryRepository timeEntryRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public SeedDataGenerator(TimeEntryRepository timeEntryRepository, UserRepository userRepository) {
|
||||||
|
this.timeEntryRepository = timeEntryRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void generate() {
|
||||||
|
Optional<User> userOpt = userRepository.findByUsername("admin"); // passe ggf. an
|
||||||
|
if (userOpt.isEmpty()) return;
|
||||||
|
|
||||||
|
User user = userOpt.get();
|
||||||
|
Random random = new Random();
|
||||||
|
|
||||||
|
for (int year = 2020; year <= 2025; year++) {
|
||||||
|
Set<LocalDate> allWorkDays = new HashSet<>();
|
||||||
|
LocalDate start = LocalDate.of(year, 1, 1);
|
||||||
|
LocalDate end = year == LocalDate.now().getYear() ? LocalDate.now() : LocalDate.of(year, 12, 31);
|
||||||
|
|
||||||
|
for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) {
|
||||||
|
DayOfWeek dow = date.getDayOfWeek();
|
||||||
|
if (dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY) {
|
||||||
|
allWorkDays.add(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LocalDate> workDays = new ArrayList<>(allWorkDays);
|
||||||
|
Collections.shuffle(workDays);
|
||||||
|
|
||||||
|
List<LocalDate> vacationDays = workDays.subList(0, Math.min(30, workDays.size()));
|
||||||
|
List<LocalDate> sickDays = workDays.subList(30, Math.min(45, workDays.size()));
|
||||||
|
|
||||||
|
for (LocalDate date : allWorkDays) {
|
||||||
|
if (vacationDays.contains(date)) {
|
||||||
|
saveEntry(user, date, null, null, 0, 0, DayStatus.VACATION, "Urlaub");
|
||||||
|
} else if (sickDays.contains(date)) {
|
||||||
|
saveEntry(user, date, null, null, 0, 0, DayStatus.SICK, "Krank");
|
||||||
|
} else {
|
||||||
|
LocalTime startTime = LocalTime.of(8, 0).plusMinutes(random.nextInt(90)); // 08:00–09:30
|
||||||
|
int workMinutes = 450 + random.nextInt(61); // 450–510
|
||||||
|
int pause = 30 + random.nextInt(31); // 30–60 min Pause
|
||||||
|
LocalTime endTime = startTime.plusMinutes(workMinutes + pause);
|
||||||
|
int target = 480;
|
||||||
|
DayStatus status = random.nextDouble() < 0.03 ? DayStatus.OFFICE : DayStatus.REMOTE;
|
||||||
|
|
||||||
|
saveEntry(user, date, startTime, endTime, pause, target, status, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveEntry(User user, LocalDate date, LocalTime start, LocalTime end, int pause, int target, DayStatus status, String comment) {
|
||||||
|
if (timeEntryRepository.findByUserAndDate(user, date).isPresent()) return;
|
||||||
|
|
||||||
|
TimeEntry entry = TimeEntry.builder()
|
||||||
|
.user(user)
|
||||||
|
.date(date)
|
||||||
|
.startTime(start)
|
||||||
|
.endTime(end)
|
||||||
|
.pauseMinutes(pause)
|
||||||
|
.targetMinutes(target)
|
||||||
|
.status(status)
|
||||||
|
.comment(comment)
|
||||||
|
.build();
|
||||||
|
timeEntryRepository.save(entry);
|
||||||
|
System.out.println("Inserted entry for: " + date + " [" + status + "]");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,18 @@
|
|||||||
package de.nilzbu.mytimetracker.model;
|
package de.nilzbu.mytimetracker.model;
|
||||||
|
|
||||||
public class DayStatus {
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public enum DayStatus {
|
||||||
|
OFFICE("In office"),
|
||||||
|
REMOTE("Remote work"),
|
||||||
|
SICK("Sick leave"),
|
||||||
|
VACATION("Vacation");
|
||||||
|
|
||||||
|
private final String displayName;
|
||||||
|
|
||||||
|
DayStatus(String displayName) {
|
||||||
|
this.displayName = displayName;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,56 @@
|
|||||||
package de.nilzbu.mytimetracker.model;
|
package de.nilzbu.mytimetracker.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@Table(
|
||||||
|
name = "time_entries",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "date"})
|
||||||
|
)
|
||||||
public class TimeEntry {
|
public class TimeEntry {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private LocalDate date;
|
||||||
|
|
||||||
|
private LocalTime startTime;
|
||||||
|
|
||||||
|
private LocalTime endTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pausenzeit in Minuten
|
||||||
|
*/
|
||||||
|
private int pauseMinutes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sollarbeitszeit in Minuten
|
||||||
|
*/
|
||||||
|
private int targetMinutes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status des Tages (normal, krank, Urlaub usw.)
|
||||||
|
*/
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private DayStatus status = DayStatus.REMOTE;
|
||||||
|
|
||||||
|
private String comment;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,46 @@
|
|||||||
package de.nilzbu.mytimetracker.model;
|
package de.nilzbu.mytimetracker.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
public class User {
|
public class User {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(nullable = false, unique = true)
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@ElementCollection(fetch = FetchType.EAGER)
|
||||||
|
@CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
|
||||||
|
@Column(name = "role")
|
||||||
|
private Set<String> roles; // z.B. ROLE_USER, ROLE_ADMIN
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private boolean locked = false;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
private List<TimeEntry> timeEntries;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,21 @@
|
|||||||
package de.nilzbu.mytimetracker.repository;
|
package de.nilzbu.mytimetracker.repository;
|
||||||
|
|
||||||
public class TimeEntryRepository {
|
import de.nilzbu.mytimetracker.model.TimeEntry;
|
||||||
|
import de.nilzbu.mytimetracker.model.User;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface TimeEntryRepository extends JpaRepository<TimeEntry, Long> {
|
||||||
|
|
||||||
|
List<TimeEntry> findAllByUserOrderByDateDesc(User user);
|
||||||
|
|
||||||
|
Optional<TimeEntry> findByUserAndDate(User user, LocalDate date);
|
||||||
|
|
||||||
|
List<TimeEntry> findByUserAndDateBetween(User user, LocalDate start, LocalDate end);
|
||||||
|
|
||||||
|
Optional<TimeEntry> findFirstByUserOrderByDateAsc(User user);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
package de.nilzbu.mytimetracker.repository;
|
package de.nilzbu.mytimetracker.repository;
|
||||||
|
|
||||||
public class UserRepository {
|
import de.nilzbu.mytimetracker.model.User;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
|
Optional<User> findByUsername(String username);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,23 @@
|
|||||||
package de.nilzbu.mytimetracker.security;
|
package de.nilzbu.mytimetracker.security;
|
||||||
|
|
||||||
public class SecurityConfig {
|
import com.vaadin.flow.spring.security.VaadinWebSecurity;
|
||||||
|
import de.nilzbu.mytimetracker.ui.view.LoginView;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SecurityConfig extends VaadinWebSecurity {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void configure(HttpSecurity http) throws Exception {
|
||||||
|
super.configure(http);
|
||||||
|
setLoginView(http, LoginView.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public BCryptPasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,38 @@
|
|||||||
package de.nilzbu.mytimetracker.security;
|
package de.nilzbu.mytimetracker.security;
|
||||||
|
|
||||||
public class UserDetailsServiceImpl {
|
import de.nilzbu.mytimetracker.model.User;
|
||||||
|
import de.nilzbu.mytimetracker.repository.UserRepository;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.*;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class UserDetailsServiceImpl implements UserDetailsService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public UserDetailsServiceImpl(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
|
User user = userRepository.findByUsername(username)
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("Benutzer nicht gefunden: " + username));
|
||||||
|
|
||||||
|
return new org.springframework.security.core.userdetails.User(
|
||||||
|
user.getUsername(),
|
||||||
|
user.getPassword(),
|
||||||
|
user.isEnabled(),
|
||||||
|
true, // accountNonExpired
|
||||||
|
true, // credentialsNonExpired
|
||||||
|
!user.isLocked(), // accountNonLocked
|
||||||
|
user.getRoles().stream()
|
||||||
|
.map(SimpleGrantedAuthority::new)
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,85 @@
|
|||||||
package de.nilzbu.mytimetracker.service;
|
package de.nilzbu.mytimetracker.service;
|
||||||
|
|
||||||
|
import de.nilzbu.mytimetracker.model.TimeEntry;
|
||||||
|
import de.nilzbu.mytimetracker.model.User;
|
||||||
|
import de.nilzbu.mytimetracker.repository.TimeEntryRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
public class TimeEntryService {
|
public class TimeEntryService {
|
||||||
|
|
||||||
|
private final TimeEntryRepository repository;
|
||||||
|
|
||||||
|
public TimeEntryService(TimeEntryRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TimeEntry> getEntriesForUser(User user) {
|
||||||
|
return repository.findAllByUserOrderByDateDesc(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getNumberOfEntriesForUser(User user) {
|
||||||
|
return getEntriesForUser(user).size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeEntry save(TimeEntry entry) {
|
||||||
|
return repository.save(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int calculateNetWorkMinutes(TimeEntry entry) {
|
||||||
|
if (entry.getStartTime() == null || entry.getEndTime() == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
long total = Duration.between(entry.getStartTime(), entry.getEndTime()).toMinutes();
|
||||||
|
return (int) (total - entry.getPauseMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
public long calculateDeviation(TimeEntry entry) {
|
||||||
|
return calculateNetWorkMinutes(entry) - entry.getTargetMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(TimeEntry entry) {
|
||||||
|
repository.delete(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TimeEntry> getEntriesForMonth(User user, int year, int month) {
|
||||||
|
LocalDate start = LocalDate.of(year, month, 1);
|
||||||
|
LocalDate end = start.withDayOfMonth(start.lengthOfMonth());
|
||||||
|
return repository.findByUserAndDateBetween(user, start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getNumberOfEntriesForMonth(User user, int year, int month) {
|
||||||
|
return getEntriesForMonth(user, year, month).size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TimeEntry> getEntriesForQuarter(User user, int year, int quarter) {
|
||||||
|
int startMonth = (quarter - 1) * 3 + 1;
|
||||||
|
LocalDate start = LocalDate.of(year, startMonth, 1);
|
||||||
|
LocalDate end = start.plusMonths(2).withDayOfMonth(start.plusMonths(2).lengthOfMonth());
|
||||||
|
return repository.findByUserAndDateBetween(user, start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getNumberOfEntriesForQuarter(User user, int year, int month) {
|
||||||
|
return getEntriesForQuarter(user, year, month).size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TimeEntry> getEntriesForYear(User user, int year) {
|
||||||
|
LocalDate start = LocalDate.of(year, 1, 1);
|
||||||
|
LocalDate end = LocalDate.of(year, 12, 31);
|
||||||
|
return repository.findByUserAndDateBetween(user, start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getNumberOfEntriesForYear(User user, int year) {
|
||||||
|
return getEntriesForYear(user, year).size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<LocalDate> getEarliestEntryDate(User user) {
|
||||||
|
return repository.findFirstByUserOrderByDateAsc(user)
|
||||||
|
.map(TimeEntry::getDate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,20 @@
|
|||||||
package de.nilzbu.mytimetracker.service;
|
package de.nilzbu.mytimetracker.service;
|
||||||
|
|
||||||
|
import de.nilzbu.mytimetracker.model.User;
|
||||||
|
import de.nilzbu.mytimetracker.repository.UserRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
public class UserService {
|
public class UserService {
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public UserService(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<User> findByUsername(String username) {
|
||||||
|
return userRepository.findByUsername(username);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,148 @@
|
|||||||
package de.nilzbu.mytimetracker.ui.component;
|
package de.nilzbu.mytimetracker.ui.component;
|
||||||
|
|
||||||
public class ChartJsComponent {
|
import com.vaadin.flow.component.AttachEvent;
|
||||||
|
import com.vaadin.flow.component.Tag;
|
||||||
|
import com.vaadin.flow.component.dependency.JsModule;
|
||||||
|
import com.vaadin.flow.component.dependency.NpmPackage;
|
||||||
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import elemental.json.Json;
|
||||||
|
import elemental.json.JsonArray;
|
||||||
|
import elemental.json.JsonObject;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Tag("div")
|
||||||
|
@JsModule("https://cdn.jsdelivr.net/npm/chart.js")
|
||||||
|
@NpmPackage(value = "chart.js", version = "4.4.0")
|
||||||
|
public class ChartJsComponent extends Div {
|
||||||
|
|
||||||
|
private final JsonObject chartData;
|
||||||
|
private final String chartTitle;
|
||||||
|
|
||||||
|
public ChartJsComponent(JsonObject chartData, String chartTitle) {
|
||||||
|
this.chartData = chartData;
|
||||||
|
this.chartTitle = chartTitle;
|
||||||
|
setWidthFull();
|
||||||
|
setHeight("500px");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onAttach(AttachEvent attachEvent) {
|
||||||
|
renderChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderChart() {
|
||||||
|
getElement().executeJs(
|
||||||
|
"""
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.style.width = "100%";
|
||||||
|
canvas.style.height = "100%";
|
||||||
|
this.appendChild(canvas);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: $0,
|
||||||
|
data: $1,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: $2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
""",
|
||||||
|
detectChartType(chartData),
|
||||||
|
chartData,
|
||||||
|
chartTitle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestimmt den Diagrammtyp basierend auf dem Dataset.
|
||||||
|
*/
|
||||||
|
private String detectChartType(JsonObject data) {
|
||||||
|
try {
|
||||||
|
JsonArray datasets = data.getArray("datasets");
|
||||||
|
if (datasets.length() > 0) {
|
||||||
|
JsonObject firstDataset = datasets.getObject(0);
|
||||||
|
if (firstDataset.hasKey("fill") && !firstDataset.getBoolean("fill")) {
|
||||||
|
return "line";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
return "bar";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein Balkendiagramm für Kategoriezählungen.
|
||||||
|
*/
|
||||||
|
public static JsonObject generateBarChartData(Map<String, Long> categoryCounts) {
|
||||||
|
JsonObject data = Json.createObject();
|
||||||
|
JsonArray labels = Json.createArray();
|
||||||
|
JsonArray values = Json.createArray();
|
||||||
|
JsonArray colors = Json.createArray();
|
||||||
|
|
||||||
|
String[] defaultColors = {
|
||||||
|
"#1e7e34", "#138496", "#ffc107", "#dc3545", "#6f42c1", "#20c997"
|
||||||
|
};
|
||||||
|
|
||||||
|
int index = 0;
|
||||||
|
for (Map.Entry<String, Long> entry : categoryCounts.entrySet()) {
|
||||||
|
labels.set(index, Json.create(entry.getKey()));
|
||||||
|
values.set(index, Json.create(entry.getValue()));
|
||||||
|
colors.set(index, Json.create(defaultColors[index % defaultColors.length]));
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject dataset = Json.createObject();
|
||||||
|
dataset.put("label", "Entries by Category");
|
||||||
|
dataset.put("data", values);
|
||||||
|
dataset.put("backgroundColor", colors);
|
||||||
|
|
||||||
|
JsonArray datasets = Json.createArray();
|
||||||
|
datasets.set(0, dataset);
|
||||||
|
|
||||||
|
data.put("labels", labels);
|
||||||
|
data.put("datasets", datasets);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein Liniendiagramm zur Darstellung des Überzeit-Saldos.
|
||||||
|
*/
|
||||||
|
public static JsonObject generateLineChartData(List<LocalDate> dates, List<Double> balanceValues) {
|
||||||
|
JsonObject data = Json.createObject();
|
||||||
|
JsonArray labels = Json.createArray();
|
||||||
|
JsonArray values = Json.createArray();
|
||||||
|
|
||||||
|
for (int i = 0; i < dates.size(); i++) {
|
||||||
|
labels.set(i, Json.create(dates.get(i).toString()));
|
||||||
|
values.set(i, Json.create(balanceValues.get(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject dataset = Json.createObject();
|
||||||
|
dataset.put("label", "Overtime Balance (hours)");
|
||||||
|
dataset.put("data", values);
|
||||||
|
dataset.put("borderColor", "rgb(54, 162, 235)");
|
||||||
|
dataset.put("tension", 0.1);
|
||||||
|
dataset.put("fill", false);
|
||||||
|
|
||||||
|
JsonArray datasets = Json.createArray();
|
||||||
|
datasets.set(0, dataset);
|
||||||
|
|
||||||
|
data.put("labels", labels);
|
||||||
|
data.put("datasets", datasets);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
package de.nilzbu.mytimetracker.ui;
|
package de.nilzbu.mytimetracker.ui.layout;
|
||||||
|
|
||||||
import com.vaadin.flow.component.applayout.AppLayout;
|
import com.vaadin.flow.component.applayout.AppLayout;
|
||||||
|
import com.vaadin.flow.component.button.Button;
|
||||||
import com.vaadin.flow.component.html.H1;
|
import com.vaadin.flow.component.html.H1;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
||||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
import com.vaadin.flow.router.RouterLink;
|
import com.vaadin.flow.router.RouterLink;
|
||||||
import com.vaadin.flow.component.button.Button;
|
import com.vaadin.flow.server.VaadinServletRequest;
|
||||||
import com.vaadin.flow.server.auth.AccessAnnotationChecker;
|
import com.vaadin.flow.server.VaadinServletResponse;
|
||||||
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
import de.nilzbu.mytimetracker.ui.view.DashboardOverView;
|
||||||
import com.vaadin.flow.component.html.Span;
|
import de.nilzbu.mytimetracker.ui.view.timeentry.TimeEntryView;
|
||||||
import com.vaadin.flow.router.PageTitle;
|
import de.nilzbu.mytimetracker.ui.view.UserManagementView;
|
||||||
import jakarta.annotation.security.PermitAll;
|
import jakarta.annotation.security.PermitAll;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||||
|
|
||||||
@@ -17,32 +22,45 @@ import org.springframework.security.web.authentication.logout.SecurityContextLog
|
|||||||
public class MainLayout extends AppLayout {
|
public class MainLayout extends AppLayout {
|
||||||
|
|
||||||
public MainLayout() {
|
public MainLayout() {
|
||||||
createHeader();
|
buildHeader();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createHeader() {
|
private void buildHeader() {
|
||||||
H1 logo = new H1("My Time Tracker");
|
H1 title = new H1("Time Tracker");
|
||||||
logo.getStyle().set("font-size", "var(--lumo-font-size-l)").set("margin", "0");
|
title.getStyle()
|
||||||
|
.set("font-size", "var(--lumo-font-size-l)")
|
||||||
|
.set("margin", "0");
|
||||||
|
|
||||||
RouterLink homeLink = new RouterLink("Startseite", MainView.class);
|
RouterLink dashboardLink = new RouterLink("Dashboard", DashboardOverView.class);
|
||||||
homeLink.getStyle().set("margin-left", "2em");
|
RouterLink bookingsLink = new RouterLink("Bookings", TimeEntryView.class);
|
||||||
|
RouterLink adminLink = new RouterLink("Admin", UserManagementView.class);
|
||||||
|
|
||||||
Button logout = new Button("Logout", e -> {
|
dashboardLink.getStyle().set("margin-left", "2em");
|
||||||
SecurityContextLogoutHandler logoutHandler = new SecurityContextLogoutHandler();
|
bookingsLink.getStyle().set("margin-left", "2em");
|
||||||
logoutHandler.logout(
|
adminLink.getStyle().set("margin-left", "2em");
|
||||||
null,
|
|
||||||
SecurityContextHolder.getContext().getAuthentication(),
|
|
||||||
null
|
|
||||||
);
|
|
||||||
getUI().ifPresent(ui -> ui.getPage().setLocation("/login"));
|
|
||||||
});
|
|
||||||
|
|
||||||
HorizontalLayout header = new HorizontalLayout(logo, homeLink, logout);
|
Button logoutButton = new Button("Logout", event -> performLogout());
|
||||||
header.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER);
|
|
||||||
header.setWidthFull();
|
|
||||||
header.setPadding(true);
|
|
||||||
header.setSpacing(true);
|
|
||||||
|
|
||||||
addToNavbar(header);
|
HorizontalLayout headerLayout = new HorizontalLayout(
|
||||||
|
title, dashboardLink, bookingsLink, adminLink, logoutButton
|
||||||
|
);
|
||||||
|
headerLayout.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER);
|
||||||
|
headerLayout.setWidthFull();
|
||||||
|
headerLayout.setPadding(true);
|
||||||
|
headerLayout.setSpacing(true);
|
||||||
|
|
||||||
|
addToNavbar(headerLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void performLogout() {
|
||||||
|
HttpServletRequest request = VaadinServletRequest.getCurrent().getHttpServletRequest();
|
||||||
|
HttpServletResponse response = VaadinServletResponse.getCurrent().getHttpServletResponse();
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
|
if (authentication != null) {
|
||||||
|
new SecurityContextLogoutHandler().logout(request, response, authentication);
|
||||||
|
}
|
||||||
|
|
||||||
|
getUI().ifPresent(ui -> ui.getPage().setLocation("/login"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package de.nilzbu.mytimetracker.ui.view;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.Component;
|
||||||
|
import com.vaadin.flow.component.combobox.ComboBox;
|
||||||
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.html.H2;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
import com.vaadin.flow.component.tabs.Tab;
|
||||||
|
import com.vaadin.flow.component.tabs.Tabs;
|
||||||
|
import com.vaadin.flow.router.PageTitle;
|
||||||
|
import com.vaadin.flow.router.Route;
|
||||||
|
import de.nilzbu.mytimetracker.model.DayStatus;
|
||||||
|
import de.nilzbu.mytimetracker.model.TimeEntry;
|
||||||
|
import de.nilzbu.mytimetracker.model.User;
|
||||||
|
import de.nilzbu.mytimetracker.repository.UserRepository;
|
||||||
|
import de.nilzbu.mytimetracker.service.TimeEntryService;
|
||||||
|
import de.nilzbu.mytimetracker.ui.component.ChartJsComponent;
|
||||||
|
import de.nilzbu.mytimetracker.ui.layout.MainLayout;
|
||||||
|
import de.nilzbu.mytimetracker.ui.widget.KeyFigureWidget;
|
||||||
|
import jakarta.annotation.security.PermitAll;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.function.BiFunction;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
|
@PermitAll
|
||||||
|
@PageTitle("Dashboard")
|
||||||
|
@Route(value = "", layout = MainLayout.class)
|
||||||
|
public class DashboardOverView extends VerticalLayout {
|
||||||
|
|
||||||
|
private final TimeEntryService timeEntryService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private User currentUser;
|
||||||
|
|
||||||
|
private final ComboBox<Integer> yearSelector = new ComboBox<>("Year");
|
||||||
|
private final ComboBox<Integer> monthSelector = new ComboBox<>("Month");
|
||||||
|
private final ComboBox<Integer> quarterSelector = new ComboBox<>("Quarter");
|
||||||
|
|
||||||
|
private final Div filterContainer = new Div();
|
||||||
|
private final Div contentContainer = new Div();
|
||||||
|
|
||||||
|
private final BiFunction<List<TimeEntry>, DayStatus, String> calculateDaysWithDayStatus =
|
||||||
|
(scopedEntries, status) -> "%d".formatted(scopedEntries.stream().filter(entry -> entry.getStatus().equals(status)).count());
|
||||||
|
|
||||||
|
public DashboardOverView(TimeEntryService timeEntryService, UserRepository userRepository) {
|
||||||
|
this.timeEntryService = timeEntryService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
|
||||||
|
configureLayout();
|
||||||
|
initializeCurrentUser();
|
||||||
|
createFilterSelectors();
|
||||||
|
renderTabsAndContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureLayout() {
|
||||||
|
setPadding(false);
|
||||||
|
setSpacing(false);
|
||||||
|
setMargin(false);
|
||||||
|
|
||||||
|
contentContainer.setSizeFull();
|
||||||
|
setFlexGrow(1, contentContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initializeCurrentUser() {
|
||||||
|
String username = SecurityContextHolder.getContext().getAuthentication().getName();
|
||||||
|
this.currentUser = userRepository.findByUsername(username)
|
||||||
|
.orElseThrow(() -> new RuntimeException("User not found: " + username));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createFilterSelectors() {
|
||||||
|
int currentYear = LocalDate.now().getYear();
|
||||||
|
int currentMonth = LocalDate.now().getMonthValue();
|
||||||
|
int currentQuarter = (currentMonth - 1) / 3 + 1;
|
||||||
|
|
||||||
|
yearSelector.setItems(IntStream.rangeClosed(2020, currentYear).boxed().toList());
|
||||||
|
yearSelector.setValue(currentYear);
|
||||||
|
|
||||||
|
monthSelector.setItems(IntStream.rangeClosed(1, 12).boxed().toList());
|
||||||
|
monthSelector.setValue(currentMonth);
|
||||||
|
|
||||||
|
quarterSelector.setItems(1, 2, 3, 4);
|
||||||
|
quarterSelector.setValue(currentQuarter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderTabsAndContent() {
|
||||||
|
Tabs scopeTabs = new Tabs(
|
||||||
|
new Tab("All"),
|
||||||
|
new Tab("Year"),
|
||||||
|
new Tab("Quarter"),
|
||||||
|
new Tab("Month")
|
||||||
|
);
|
||||||
|
|
||||||
|
scopeTabs.addSelectedChangeListener(e ->
|
||||||
|
updateContent(scopeTabs.getSelectedTab().getLabel())
|
||||||
|
);
|
||||||
|
|
||||||
|
add(new H2("Dashboard"), scopeTabs, filterContainer, contentContainer);
|
||||||
|
updateContent("All");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateContent(String scope) {
|
||||||
|
contentContainer.removeAll();
|
||||||
|
filterContainer.removeAll();
|
||||||
|
|
||||||
|
yearSelector.setVisible(false);
|
||||||
|
monthSelector.setVisible(false);
|
||||||
|
quarterSelector.setVisible(false);
|
||||||
|
|
||||||
|
List<TimeEntry> entries = switch (scope) {
|
||||||
|
case "Month" -> {
|
||||||
|
configureFilterScope(yearSelector, monthSelector);
|
||||||
|
yield timeEntryService.getEntriesForMonth(currentUser, yearSelector.getValue(), monthSelector.getValue());
|
||||||
|
}
|
||||||
|
case "Quarter" -> {
|
||||||
|
configureFilterScope(yearSelector, quarterSelector);
|
||||||
|
yield timeEntryService.getEntriesForQuarter(currentUser, yearSelector.getValue(), quarterSelector.getValue());
|
||||||
|
}
|
||||||
|
case "Year" -> {
|
||||||
|
configureFilterScope(yearSelector, null);
|
||||||
|
yield timeEntryService.getEntriesForYear(currentUser, yearSelector.getValue());
|
||||||
|
}
|
||||||
|
default -> timeEntryService.getEntriesForUser(currentUser);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderKeyFigures(entries);
|
||||||
|
renderCharts(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureFilterScope(ComboBox<?>... selectors) {
|
||||||
|
for (ComboBox<?> selector : selectors) {
|
||||||
|
if (selector == null) continue;
|
||||||
|
filterContainer.add(selector);
|
||||||
|
selector.setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
yearSelector.addValueChangeListener(e -> updateContent("Year"));
|
||||||
|
monthSelector.addValueChangeListener(e -> updateContent("Month"));
|
||||||
|
quarterSelector.addValueChangeListener(e -> updateContent("Quarter"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderCharts(List<TimeEntry> scopedEntries) {
|
||||||
|
List<TimeEntry> allEntries = timeEntryService.getEntriesForUser(currentUser);
|
||||||
|
|
||||||
|
HorizontalLayout chartLayout = new HorizontalLayout();
|
||||||
|
chartLayout.setPadding(false);
|
||||||
|
chartLayout.setSpacing(true);
|
||||||
|
|
||||||
|
Component categoryChart = createCategoryBarChart(scopedEntries);
|
||||||
|
categoryChart.getStyle().setBackgroundColor("lightgray");
|
||||||
|
categoryChart.getStyle().setBorder("1px solid black" );
|
||||||
|
categoryChart.getStyle().setMargin("10px");
|
||||||
|
|
||||||
|
Component overtimeChart = createOvertimeLineChart(allEntries, scopedEntries);
|
||||||
|
overtimeChart.getStyle().setBackgroundColor("lightgray");
|
||||||
|
overtimeChart.getStyle().setBorder("1px solid black" );
|
||||||
|
overtimeChart.getStyle().setMargin("10px");
|
||||||
|
|
||||||
|
chartLayout.add(categoryChart, overtimeChart);
|
||||||
|
chartLayout.setFlexGrow(1, categoryChart);
|
||||||
|
chartLayout.setFlexGrow(1, overtimeChart);
|
||||||
|
|
||||||
|
contentContainer.add(chartLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderKeyFigures(List<TimeEntry> scopedEntries) {
|
||||||
|
HorizontalLayout keyFigureLayout = new HorizontalLayout();
|
||||||
|
|
||||||
|
KeyFigureWidget workingDays = new KeyFigureWidget("Working Days", "" + scopedEntries.size());
|
||||||
|
|
||||||
|
KeyFigureWidget remoteDays = new KeyFigureWidget(
|
||||||
|
"Remote Days", calculateDaysWithDayStatus.apply(scopedEntries, DayStatus.REMOTE)
|
||||||
|
);
|
||||||
|
|
||||||
|
KeyFigureWidget officeDays = new KeyFigureWidget(
|
||||||
|
"Office Days",
|
||||||
|
calculateDaysWithDayStatus.apply(scopedEntries, DayStatus.OFFICE)
|
||||||
|
);
|
||||||
|
|
||||||
|
KeyFigureWidget vacationDays = new KeyFigureWidget(
|
||||||
|
"Vacation Days",
|
||||||
|
calculateDaysWithDayStatus.apply(scopedEntries, DayStatus.VACATION)
|
||||||
|
);
|
||||||
|
|
||||||
|
KeyFigureWidget sickDays = new KeyFigureWidget(
|
||||||
|
"Sick Days",
|
||||||
|
calculateDaysWithDayStatus.apply(scopedEntries, DayStatus.SICK)
|
||||||
|
);
|
||||||
|
|
||||||
|
KeyFigureWidget timeBalance = new KeyFigureWidget(
|
||||||
|
"Time Balance",
|
||||||
|
getTimeBalance(scopedEntries)
|
||||||
|
);
|
||||||
|
|
||||||
|
keyFigureLayout.getStyle().setBackgroundColor("lightgray");
|
||||||
|
keyFigureLayout.getStyle().setBorder("1px solid black" );
|
||||||
|
keyFigureLayout.getStyle().setMargin("10px");
|
||||||
|
|
||||||
|
keyFigureLayout.add(timeBalance, workingDays, remoteDays, officeDays, vacationDays, sickDays);
|
||||||
|
|
||||||
|
contentContainer.add(keyFigureLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private String getTimeBalance(List<TimeEntry> scopedEntries) {
|
||||||
|
Integer minutes = scopedEntries.stream()
|
||||||
|
.map(entry -> timeEntryService.calculateNetWorkMinutes(entry) - entry.getTargetMinutes())
|
||||||
|
.reduce(0, Integer::sum);
|
||||||
|
|
||||||
|
double hours = minutes / 60.0;
|
||||||
|
return "%.2f h".formatted(hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component createCategoryBarChart(List<TimeEntry> entries) {
|
||||||
|
Map<String, Long> statusCount = entries.stream()
|
||||||
|
.collect(Collectors.groupingBy(e -> e.getStatus().name(), Collectors.counting()));
|
||||||
|
|
||||||
|
return new ChartJsComponent(
|
||||||
|
ChartJsComponent.generateBarChartData(statusCount),
|
||||||
|
"Entries by Category");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component createOvertimeLineChart(List<TimeEntry> allEntries, List<TimeEntry> scopedEntries) {
|
||||||
|
Map<LocalDate, Double> dailySaldo = new TreeMap<>();
|
||||||
|
int accumulatedMinutes = 0;
|
||||||
|
|
||||||
|
List<TimeEntry> sortedEntries = allEntries.stream()
|
||||||
|
.sorted(Comparator.comparing(TimeEntry::getDate))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (TimeEntry entry : sortedEntries) {
|
||||||
|
accumulatedMinutes += timeEntryService.calculateNetWorkMinutes(entry) - entry.getTargetMinutes();
|
||||||
|
dailySaldo.put(entry.getDate(), accumulatedMinutes / 60.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LocalDate> scopedDates = scopedEntries.stream()
|
||||||
|
.map(TimeEntry::getDate)
|
||||||
|
.sorted()
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<Double> saldoValues = scopedDates.stream()
|
||||||
|
.map(date -> dailySaldo.getOrDefault(date, 0.0))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new ChartJsComponent(
|
||||||
|
ChartJsComponent.generateLineChartData(scopedDates, saldoValues),
|
||||||
|
"Overtime Balance Over Time (in hours)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
package de.nilzbu.mytimetracker.ui;
|
package de.nilzbu.mytimetracker.ui.view;
|
||||||
|
|
||||||
import com.vaadin.flow.component.html.H2;
|
import com.vaadin.flow.component.html.H2;
|
||||||
import com.vaadin.flow.component.login.LoginForm;
|
import com.vaadin.flow.component.login.LoginForm;
|
||||||
import com.vaadin.flow.component.login.LoginI18n;
|
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
|
|
||||||
@@ -17,6 +16,6 @@ public class LoginView extends VerticalLayout {
|
|||||||
LoginForm login = new LoginForm();
|
LoginForm login = new LoginForm();
|
||||||
login.setAction("login"); // wichtig für Spring Security
|
login.setAction("login"); // wichtig für Spring Security
|
||||||
|
|
||||||
add(new H2("My Time Tracker – Anmeldung"), login);
|
add(new H2("Time Tracker Login"), login);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package de.nilzbu.mytimetracker.ui;
|
|
||||||
|
|
||||||
import com.vaadin.flow.component.html.H1;
|
|
||||||
import com.vaadin.flow.component.html.Paragraph;
|
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
|
||||||
import com.vaadin.flow.router.Route;
|
|
||||||
import jakarta.annotation.security.PermitAll;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
|
|
||||||
@Route("")
|
|
||||||
@PermitAll
|
|
||||||
public class MainView extends VerticalLayout {
|
|
||||||
|
|
||||||
public MainView() {
|
|
||||||
setSizeFull();
|
|
||||||
setPadding(true);
|
|
||||||
setSpacing(true);
|
|
||||||
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
String username = (auth != null) ? auth.getName() : "unbekannt";
|
|
||||||
|
|
||||||
add(
|
|
||||||
new H1("Willkommen, " + username + "!"),
|
|
||||||
new Paragraph("Dies ist Ihr Arbeitszeit-Dashboard."),
|
|
||||||
new Paragraph("Fügen Sie hier später Navigation, Statistiken oder Schnellzugriffe hinzu.")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
package de.nilzbu.mytimetracker.ui.view;
|
|
||||||
|
|
||||||
public class TimeEntryView {
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
package de.nilzbu.mytimetracker.ui.view;
|
|
||||||
|
|
||||||
public class UserAdminView {
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package de.nilzbu.mytimetracker.ui.view;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.button.Button;
|
||||||
|
import com.vaadin.flow.component.combobox.MultiSelectComboBox;
|
||||||
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
|
import com.vaadin.flow.component.html.H2;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
import com.vaadin.flow.component.textfield.PasswordField;
|
||||||
|
import com.vaadin.flow.component.textfield.TextField;
|
||||||
|
import com.vaadin.flow.router.PageTitle;
|
||||||
|
import com.vaadin.flow.router.Route;
|
||||||
|
import de.nilzbu.mytimetracker.model.User;
|
||||||
|
import de.nilzbu.mytimetracker.repository.UserRepository;
|
||||||
|
import de.nilzbu.mytimetracker.ui.layout.MainLayout;
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Route(value = "admin/user-management", layout = MainLayout.class)
|
||||||
|
@PageTitle("User Management")
|
||||||
|
@RolesAllowed("ROLE_ADMIN")
|
||||||
|
public class UserManagementView extends VerticalLayout {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
private final Grid<User> userGrid = new Grid<>(User.class, false);
|
||||||
|
private final TextField usernameField = new TextField("Username");
|
||||||
|
private final PasswordField passwordField = new PasswordField("Password");
|
||||||
|
private final MultiSelectComboBox<String> rolesField = new MultiSelectComboBox<>("Roles");
|
||||||
|
|
||||||
|
private final Button saveButton = new Button("Save");
|
||||||
|
private final Button deleteButton = new Button("Delete");
|
||||||
|
|
||||||
|
private User selectedUser;
|
||||||
|
|
||||||
|
public UserManagementView(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
|
||||||
|
add(new H2("User Management"));
|
||||||
|
setupUserGrid();
|
||||||
|
setupFormLayout();
|
||||||
|
loadUsersToGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setupUserGrid() {
|
||||||
|
userGrid.addColumn(User::getUsername).setHeader("Username");
|
||||||
|
userGrid.addColumn(user -> String.join(", ", user.getRoles())).setHeader("Roles");
|
||||||
|
userGrid.setSelectionMode(Grid.SelectionMode.SINGLE);
|
||||||
|
userGrid.setWidthFull();
|
||||||
|
|
||||||
|
userGrid.asSingleSelect().addValueChangeListener(e -> {
|
||||||
|
selectedUser = e.getValue();
|
||||||
|
if (selectedUser != null) {
|
||||||
|
usernameField.setValue(selectedUser.getUsername());
|
||||||
|
rolesField.setValue(selectedUser.getRoles());
|
||||||
|
passwordField.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
add(userGrid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setupFormLayout() {
|
||||||
|
rolesField.setItems("ROLE_USER", "ROLE_ADMIN");
|
||||||
|
|
||||||
|
saveButton.addClickListener(e -> saveOrUpdateUser());
|
||||||
|
deleteButton.addClickListener(e -> deleteUser());
|
||||||
|
|
||||||
|
HorizontalLayout formLayout = new HorizontalLayout(usernameField, passwordField, rolesField, saveButton, deleteButton);
|
||||||
|
formLayout.setWidthFull();
|
||||||
|
add(formLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveOrUpdateUser() {
|
||||||
|
if (selectedUser != null) {
|
||||||
|
selectedUser.setUsername(usernameField.getValue());
|
||||||
|
selectedUser.setRoles(rolesField.getValue());
|
||||||
|
if (!passwordField.isEmpty()) {
|
||||||
|
selectedUser.setPassword(passwordEncoder.encode(passwordField.getValue()));
|
||||||
|
}
|
||||||
|
userRepository.save(selectedUser);
|
||||||
|
} else {
|
||||||
|
User newUser = User.builder()
|
||||||
|
.username(usernameField.getValue())
|
||||||
|
.roles(rolesField.getValue())
|
||||||
|
.enabled(true)
|
||||||
|
.locked(false)
|
||||||
|
.password(passwordEncoder.encode(passwordField.getValue()))
|
||||||
|
.build();
|
||||||
|
userRepository.save(newUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFormFields();
|
||||||
|
loadUsersToGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteUser() {
|
||||||
|
if (selectedUser != null) {
|
||||||
|
userRepository.delete(selectedUser);
|
||||||
|
resetFormFields();
|
||||||
|
loadUsersToGrid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadUsersToGrid() {
|
||||||
|
List<User> users = userRepository.findAll();
|
||||||
|
userGrid.setItems(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resetFormFields() {
|
||||||
|
selectedUser = null;
|
||||||
|
usernameField.clear();
|
||||||
|
passwordField.clear();
|
||||||
|
rolesField.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package de.nilzbu.mytimetracker.ui.view.timeentry;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.combobox.ComboBox;
|
||||||
|
import com.vaadin.flow.component.datepicker.DatePicker;
|
||||||
|
import com.vaadin.flow.component.textfield.IntegerField;
|
||||||
|
import com.vaadin.flow.component.textfield.TextArea;
|
||||||
|
import com.vaadin.flow.component.timepicker.TimePicker;
|
||||||
|
import com.vaadin.flow.function.SerializableSupplier;
|
||||||
|
import de.nilzbu.mytimetracker.model.DayStatus;
|
||||||
|
import de.nilzbu.mytimetracker.model.TimeEntry;
|
||||||
|
import org.vaadin.crudui.crud.CrudOperation;
|
||||||
|
import org.vaadin.crudui.crud.impl.GridCrud;
|
||||||
|
import org.vaadin.crudui.form.CrudFormFactory;
|
||||||
|
import org.vaadin.crudui.form.impl.form.factory.DefaultCrudFormFactory;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
|
||||||
|
final class TimeEntryFormConfigurer {
|
||||||
|
|
||||||
|
private TimeEntryFormConfigurer() {}
|
||||||
|
|
||||||
|
static void configure(GridCrud<TimeEntry> crud, SerializableSupplier<TimeEntry> newInstanceSupplier) {
|
||||||
|
CrudFormFactory<TimeEntry> formFactory = new DefaultCrudFormFactory<>(TimeEntry.class);
|
||||||
|
|
||||||
|
formFactory.setVisibleProperties(
|
||||||
|
CrudOperation.ADD, "date", "startTime", "endTime", "pauseMinutes", "targetMinutes", "status", "comment"
|
||||||
|
);
|
||||||
|
formFactory.setVisibleProperties(
|
||||||
|
CrudOperation.UPDATE, "date", "startTime", "endTime", "pauseMinutes", "targetMinutes", "status", "comment"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Date
|
||||||
|
formFactory.setFieldProvider("date", (TimeEntry bean) -> {
|
||||||
|
DatePicker dp = new DatePicker("Date");
|
||||||
|
dp.setRequiredIndicatorVisible(true);
|
||||||
|
if (bean == null || bean.getDate() == null) {
|
||||||
|
dp.setValue(LocalDate.now());
|
||||||
|
}
|
||||||
|
return dp;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start/End
|
||||||
|
formFactory.setFieldProvider("startTime", (TimeEntry bean) -> {
|
||||||
|
TimePicker tp = new TimePicker("Start");
|
||||||
|
tp.setRequiredIndicatorVisible(true);
|
||||||
|
tp.setMin(LocalTime.of(5, 0));
|
||||||
|
tp.setMax(LocalTime.of(23, 59));
|
||||||
|
return tp;
|
||||||
|
});
|
||||||
|
formFactory.setFieldProvider("endTime", (TimeEntry bean) -> {
|
||||||
|
TimePicker tp = new TimePicker("End");
|
||||||
|
tp.setRequiredIndicatorVisible(true);
|
||||||
|
tp.setMin(LocalTime.of(5, 0));
|
||||||
|
tp.setMax(LocalTime.of(23, 59));
|
||||||
|
return tp;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Integers (no null-checks on primitives; defaults are provided by newInstanceSupplier)
|
||||||
|
formFactory.setFieldProvider("pauseMinutes", (TimeEntry bean) -> {
|
||||||
|
IntegerField f = new IntegerField("Break (min)");
|
||||||
|
f.setMin(0);
|
||||||
|
f.setStep(1); // allow any integer (e.g., 44)
|
||||||
|
f.setStepButtonsVisible(true);
|
||||||
|
f.setRequiredIndicatorVisible(true);
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
|
||||||
|
formFactory.setFieldProvider("targetMinutes", (TimeEntry bean) -> {
|
||||||
|
IntegerField f = new IntegerField("Target (min)");
|
||||||
|
f.setMin(0);
|
||||||
|
f.setStep(1);
|
||||||
|
f.setStepButtonsVisible(true);
|
||||||
|
f.setRequiredIndicatorVisible(true);
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status
|
||||||
|
formFactory.setFieldProvider("status", (TimeEntry bean) -> {
|
||||||
|
ComboBox<DayStatus> cb = new ComboBox<>("Status");
|
||||||
|
cb.setItems(DayStatus.values());
|
||||||
|
cb.setItemLabelGenerator(Enum::name);
|
||||||
|
cb.setRequiredIndicatorVisible(true);
|
||||||
|
if (bean == null || bean.getStatus() == null) {
|
||||||
|
cb.setValue(DayStatus.REMOTE);
|
||||||
|
}
|
||||||
|
return cb;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Comment
|
||||||
|
formFactory.setFieldProvider("comment", (TimeEntry bean) -> {
|
||||||
|
TextArea ta = new TextArea("Comment");
|
||||||
|
ta.setMaxLength(500);
|
||||||
|
ta.setWidthFull();
|
||||||
|
return ta;
|
||||||
|
});
|
||||||
|
|
||||||
|
formFactory.setNewInstanceSupplier(newInstanceSupplier);
|
||||||
|
formFactory.setUseBeanValidation(true);
|
||||||
|
|
||||||
|
crud.setCrudFormFactory(formFactory);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package de.nilzbu.mytimetracker.ui.view.timeentry;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.checkbox.Checkbox;
|
||||||
|
import com.vaadin.flow.component.combobox.ComboBox;
|
||||||
|
import com.vaadin.flow.component.datepicker.DatePicker;
|
||||||
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
|
import com.vaadin.flow.component.grid.HeaderRow;
|
||||||
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
|
import com.vaadin.flow.component.textfield.TextField;
|
||||||
|
import de.nilzbu.mytimetracker.model.DayStatus;
|
||||||
|
import de.nilzbu.mytimetracker.model.TimeEntry;
|
||||||
|
import de.nilzbu.mytimetracker.service.TimeEntryService;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
final class TimeEntryHeaderFilters {
|
||||||
|
|
||||||
|
private final TimeEntryService service; // needed for deviation/actual calculations
|
||||||
|
|
||||||
|
TimeEntryHeaderFilters(TimeEntryService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
// instance-held controls
|
||||||
|
private final DatePicker fromDate = new DatePicker("From");
|
||||||
|
private final DatePicker toDate = new DatePicker("To");
|
||||||
|
private final ComboBox<DayStatus> statusFilter = new ComboBox<>("Status");
|
||||||
|
private final TextField searchField = new TextField("Search");
|
||||||
|
private final Checkbox onlyWithDeviation = new Checkbox("≠ 0");
|
||||||
|
|
||||||
|
void configureGridAndFilters(Grid<TimeEntry> grid, Runnable onFilterChanged) {
|
||||||
|
grid.removeAllColumns();
|
||||||
|
|
||||||
|
var dateCol = grid.addColumn(TimeEntry::getDate).setHeader("Date").setSortable(true).setAutoWidth(true);
|
||||||
|
grid.addColumn(TimeEntry::getStartTime).setHeader("Start").setSortable(true).setAutoWidth(true);
|
||||||
|
grid.addColumn(TimeEntry::getEndTime).setHeader("End").setSortable(true).setAutoWidth(true);
|
||||||
|
grid.addColumn(TimeEntry::getPauseMinutes).setHeader("Break (min)").setSortable(true).setAutoWidth(true);
|
||||||
|
grid.addColumn(TimeEntry::getTargetMinutes).setHeader("Target (min)").setSortable(true).setAutoWidth(true);
|
||||||
|
grid.addColumn(service::calculateNetWorkMinutes).setHeader("Actual (min)").setSortable(true).setAutoWidth(true);
|
||||||
|
var statusCol = grid.addColumn(TimeEntry::getStatus).setHeader("Status").setSortable(true).setAutoWidth(true);
|
||||||
|
var commentCol = grid.addColumn(TimeEntry::getComment).setHeader("Comment").setSortable(true).setAutoWidth(true);
|
||||||
|
var devCol = grid.addColumn(service::calculateDeviation).setHeader("Δ (min)").setSortable(true).setAutoWidth(true);
|
||||||
|
|
||||||
|
grid.setSelectionMode(Grid.SelectionMode.SINGLE);
|
||||||
|
|
||||||
|
HeaderRow filterRow = grid.appendHeaderRow();
|
||||||
|
|
||||||
|
// Date range (From/To)
|
||||||
|
initDatePicker(fromDate);
|
||||||
|
initDatePicker(toDate);
|
||||||
|
HorizontalLayout dateRange = new HorizontalLayout(fromDate, toDate);
|
||||||
|
dateRange.setSpacing(true);
|
||||||
|
dateRange.setPadding(false);
|
||||||
|
dateRange.setAlignItems(HorizontalLayout.Alignment.BASELINE);
|
||||||
|
dateRange.getStyle().set("gap", "0.25rem");
|
||||||
|
filterRow.getCell(dateCol).setComponent(wrap(dateRange));
|
||||||
|
|
||||||
|
// Status
|
||||||
|
statusFilter.setItems(DayStatus.values());
|
||||||
|
statusFilter.setItemLabelGenerator(Enum::name);
|
||||||
|
statusFilter.setPlaceholder("Any");
|
||||||
|
statusFilter.setClearButtonVisible(true);
|
||||||
|
themeSmall(statusFilter);
|
||||||
|
filterRow.getCell(statusCol).setComponent(wrap(statusFilter));
|
||||||
|
|
||||||
|
// Comment search
|
||||||
|
searchField.setPlaceholder("Search…");
|
||||||
|
searchField.setClearButtonVisible(true);
|
||||||
|
themeSmall(searchField);
|
||||||
|
filterRow.getCell(commentCol).setComponent(wrap(searchField));
|
||||||
|
|
||||||
|
// Deviation ≠ 0
|
||||||
|
themeSmall(onlyWithDeviation);
|
||||||
|
filterRow.getCell(devCol).setComponent(wrap(onlyWithDeviation));
|
||||||
|
|
||||||
|
// Auto-apply
|
||||||
|
fromDate.addValueChangeListener(e -> onFilterChanged.run());
|
||||||
|
toDate.addValueChangeListener(e -> onFilterChanged.run());
|
||||||
|
statusFilter.addValueChangeListener(e -> onFilterChanged.run());
|
||||||
|
searchField.addValueChangeListener(e -> onFilterChanged.run());
|
||||||
|
onlyWithDeviation.addValueChangeListener(e -> onFilterChanged.run());
|
||||||
|
|
||||||
|
// Optional widths (valid Column API)
|
||||||
|
dateCol.setFlexGrow(0).setWidth("240px");
|
||||||
|
statusCol.setFlexGrow(0).setWidth("160px");
|
||||||
|
commentCol.setAutoWidth(true); // no setMinWidth in API
|
||||||
|
devCol.setFlexGrow(0).setWidth("120px");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TimeEntry> applyFilters(List<TimeEntry> source) {
|
||||||
|
LocalDate from = fromDate.getValue();
|
||||||
|
LocalDate to = toDate.getValue();
|
||||||
|
|
||||||
|
boolean invalidRange = from != null && to != null && from.isAfter(to);
|
||||||
|
fromDate.setInvalid(invalidRange);
|
||||||
|
toDate.setInvalid(invalidRange);
|
||||||
|
fromDate.setErrorMessage(invalidRange ? "'From' must not be after 'To'" : null);
|
||||||
|
toDate.setErrorMessage(invalidRange ? "'To' must not be before 'From'" : null);
|
||||||
|
if (invalidRange) return List.of();
|
||||||
|
|
||||||
|
if (from != null) {
|
||||||
|
source = source.stream().filter(e -> !e.getDate().isBefore(from)).toList();
|
||||||
|
}
|
||||||
|
if (to != null) {
|
||||||
|
source = source.stream().filter(e -> !e.getDate().isAfter(to)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
DayStatus s = statusFilter.getValue();
|
||||||
|
if (s != null) {
|
||||||
|
source = source.stream().filter(e -> e.getStatus() == s).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Boolean.TRUE.equals(onlyWithDeviation.getValue())) {
|
||||||
|
source = source.stream()
|
||||||
|
.filter(e -> service.calculateDeviation(e) != 0)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String q = searchField.getValue();
|
||||||
|
if (q != null && !q.isBlank()) {
|
||||||
|
String needle = q.trim().toLowerCase();
|
||||||
|
source = source.stream().filter(e ->
|
||||||
|
(e.getComment() != null && e.getComment().toLowerCase().contains(needle))
|
||||||
|
|| (e.getStatus() != null && e.getStatus().name().toLowerCase().contains(needle))
|
||||||
|
).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- helpers ---- */
|
||||||
|
|
||||||
|
private static void initDatePicker(DatePicker dp) {
|
||||||
|
dp.setClearButtonVisible(true);
|
||||||
|
dp.setPlaceholder(dp.getLabel());
|
||||||
|
themeSmall(dp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T extends com.vaadin.flow.component.Component> T themeSmall(T c) {
|
||||||
|
c.getElement().getThemeList().add("small");
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Div wrap(com.vaadin.flow.component.Component c) {
|
||||||
|
Div w = new Div(c);
|
||||||
|
w.addClassName("filter-cell");
|
||||||
|
w.getStyle().set("width", "100%");
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.nilzbu.mytimetracker.ui.view.timeentry;
|
||||||
|
|
||||||
|
import de.nilzbu.mytimetracker.model.TimeEntry;
|
||||||
|
import de.nilzbu.mytimetracker.model.User;
|
||||||
|
import de.nilzbu.mytimetracker.service.TimeEntryService;
|
||||||
|
|
||||||
|
import java.time.LocalTime;
|
||||||
|
|
||||||
|
final class TimeEntryValidators {
|
||||||
|
|
||||||
|
private TimeEntryValidators() {}
|
||||||
|
|
||||||
|
static void validate(TimeEntry entry, TimeEntryService service, User currentUser) {
|
||||||
|
requireEndAfterStart(entry);
|
||||||
|
requireUniqueDate(entry, service, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireEndAfterStart(TimeEntry entry) {
|
||||||
|
LocalTime s = entry.getStartTime();
|
||||||
|
LocalTime e = entry.getEndTime();
|
||||||
|
if (s != null && e != null && e.isBefore(s)) {
|
||||||
|
throw new IllegalArgumentException("End time must not be before start time.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireUniqueDate(TimeEntry candidate, TimeEntryService service, User currentUser) {
|
||||||
|
boolean existsSameDate = service.getEntriesForUser(currentUser).stream()
|
||||||
|
.filter(e -> e.getDate() != null && e.getDate().equals(candidate.getDate()))
|
||||||
|
.anyMatch(e -> candidate.getId() == null || !e.getId().equals(candidate.getId()));
|
||||||
|
if (existsSameDate) {
|
||||||
|
throw new IllegalArgumentException("An entry already exists for this date.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package de.nilzbu.mytimetracker.ui.view.timeentry;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.html.H2;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
import com.vaadin.flow.router.PageTitle;
|
||||||
|
import com.vaadin.flow.router.Route;
|
||||||
|
import de.nilzbu.mytimetracker.model.TimeEntry;
|
||||||
|
import de.nilzbu.mytimetracker.model.User;
|
||||||
|
import de.nilzbu.mytimetracker.repository.UserRepository;
|
||||||
|
import de.nilzbu.mytimetracker.service.TimeEntryService;
|
||||||
|
import de.nilzbu.mytimetracker.ui.layout.MainLayout;
|
||||||
|
import jakarta.annotation.security.PermitAll;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.vaadin.crudui.crud.impl.GridCrud;
|
||||||
|
import org.vaadin.crudui.layout.impl.VerticalCrudLayout;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@Route(value = "time-entry", layout = MainLayout.class)
|
||||||
|
@PageTitle("Time Entries")
|
||||||
|
@PermitAll
|
||||||
|
public class TimeEntryView extends VerticalLayout {
|
||||||
|
|
||||||
|
private final TimeEntryService timeEntryService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private User currentUser;
|
||||||
|
|
||||||
|
private final GridCrud<TimeEntry> crud;
|
||||||
|
|
||||||
|
public TimeEntryView(TimeEntryService timeEntryService, UserRepository userRepository) {
|
||||||
|
this.timeEntryService = timeEntryService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
|
||||||
|
setSizeFull();
|
||||||
|
setPadding(true);
|
||||||
|
setSpacing(true);
|
||||||
|
|
||||||
|
initCurrentUser();
|
||||||
|
|
||||||
|
this.crud = new GridCrud<>(TimeEntry.class, new VerticalCrudLayout());
|
||||||
|
crud.setSizeFull();
|
||||||
|
|
||||||
|
// Header filters need the service (for deviation/actual calculations)
|
||||||
|
TimeEntryHeaderFilters headerFilters = new TimeEntryHeaderFilters(timeEntryService);
|
||||||
|
headerFilters.configureGridAndFilters(crud.getGrid(), this::refresh);
|
||||||
|
|
||||||
|
TimeEntryFormConfigurer.configure(crud, this::defaultNewEntry);
|
||||||
|
|
||||||
|
configureOperations(headerFilters);
|
||||||
|
|
||||||
|
add(new H2("Time Entries"), crud);
|
||||||
|
setAlignItems(FlexComponent.Alignment.STRETCH);
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initCurrentUser() {
|
||||||
|
String username = SecurityContextHolder.getContext().getAuthentication().getName();
|
||||||
|
this.currentUser = userRepository.findByUsername(username)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("User not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeEntry defaultNewEntry() {
|
||||||
|
TimeEntry t = new TimeEntry();
|
||||||
|
t.setUser(currentUser);
|
||||||
|
t.setDate(LocalDate.now());
|
||||||
|
// sensible defaults
|
||||||
|
t.setStatus(de.nilzbu.mytimetracker.model.DayStatus.REMOTE);
|
||||||
|
t.setPauseMinutes(30);
|
||||||
|
t.setTargetMinutes(480);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureOperations(TimeEntryHeaderFilters headerFilters) {
|
||||||
|
// READ uses header filters
|
||||||
|
crud.setFindAllOperation(() ->
|
||||||
|
headerFilters.applyFilters(
|
||||||
|
timeEntryService.getEntriesForUser(currentUser))
|
||||||
|
);
|
||||||
|
|
||||||
|
// CREATE
|
||||||
|
crud.setAddOperation(entry -> {
|
||||||
|
entry.setUser(currentUser);
|
||||||
|
TimeEntryValidators.validate(entry, timeEntryService, currentUser);
|
||||||
|
return timeEntryService.save(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
// UPDATE
|
||||||
|
crud.setUpdateOperation(entry -> {
|
||||||
|
entry.setUser(currentUser);
|
||||||
|
TimeEntryValidators.validate(entry, timeEntryService, currentUser);
|
||||||
|
return timeEntryService.save(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
crud.setDeleteOperation(timeEntryService::delete);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refresh() {
|
||||||
|
crud.refreshGrid();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.nilzbu.mytimetracker.ui.widget;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.html.NativeLabel;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
|
||||||
|
public class KeyFigureWidget extends VerticalLayout {
|
||||||
|
|
||||||
|
public KeyFigureWidget(String name, String value) {
|
||||||
|
NativeLabel nameLabel = new NativeLabel(name + ":");
|
||||||
|
NativeLabel valueLabel = new NativeLabel(value);
|
||||||
|
valueLabel.getStyle().set("font-weight", "bold");
|
||||||
|
add(nameLabel, valueLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# ------------------------------------------------------------
|
||||||
|
# Anwendungskonfiguration
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
spring.application.name=MyTimeTracker
|
||||||
|
vaadin.launch-browser=false
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Datenbank (MySQL im Docker)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
spring.datasource.url=jdbc:mysql://db:3306/mytimetracker?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||||
|
spring.datasource.username=appuser
|
||||||
|
spring.datasource.password=apppass
|
||||||
|
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Hibernate (JPA)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
|
||||||
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
|
spring.jpa.show-sql=false
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# H2-Konsole deaktivieren
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
spring.h2.console.enabled=false
|
||||||
@@ -1,2 +1,33 @@
|
|||||||
vaadin.launch-browser=true
|
# ------------------------------------------------------------
|
||||||
|
# Anwendungskonfiguration
|
||||||
|
# ------------------------------------------------------------
|
||||||
spring.application.name=MyTimeTracker
|
spring.application.name=MyTimeTracker
|
||||||
|
vaadin.launch-browser=true
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Datenbank (lokal: H2 im Datei-Modus)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
spring.datasource.url=jdbc:h2:file:./data/zeiterfassungdb
|
||||||
|
spring.datasource.driver-class-name=org.h2.Driver
|
||||||
|
spring.datasource.username=sa
|
||||||
|
spring.datasource.password=
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Hibernate (JPA)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
|
||||||
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
|
spring.jpa.show-sql=true
|
||||||
|
spring.jpa.properties.hibernate.format_sql=true
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# H2-Konsole (optional, für lokale Entwicklung)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
spring.h2.console.enabled=true
|
||||||
|
spring.h2.console.path=/h2-console
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Logging (optional)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
logging.level.org.hibernate.SQL=DEBUG
|
||||||
|
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
|
||||||
39
tsconfig.json
Normal file
39
tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// This TypeScript configuration file is generated by vaadin-maven-plugin.
|
||||||
|
// This is needed for TypeScript compiler to compile your TypeScript code in the project.
|
||||||
|
// It is recommended to commit this file to the VCS.
|
||||||
|
// You might want to change the configurations to fit your preferences
|
||||||
|
// For more information about the configurations, please refer to http://www.typescriptlang.org/docs/handbook/tsconfig-json.html
|
||||||
|
{
|
||||||
|
"_version": "9.1",
|
||||||
|
"compilerOptions": {
|
||||||
|
"sourceMap": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"inlineSources": true,
|
||||||
|
"module": "esNext",
|
||||||
|
"target": "es2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"baseUrl": "src/main/frontend",
|
||||||
|
"paths": {
|
||||||
|
"@vaadin/flow-frontend": ["generated/jar-resources"],
|
||||||
|
"@vaadin/flow-frontend/*": ["generated/jar-resources/*"],
|
||||||
|
"Frontend/*": ["*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/main/frontend/**/*",
|
||||||
|
"types.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/main/frontend/generated/jar-resources/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
9
vite.config.ts
Normal file
9
vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { UserConfigFn } from 'vite';
|
||||||
|
import { overrideVaadinConfig } from './vite.generated';
|
||||||
|
|
||||||
|
const customConfig: UserConfigFn = (env) => ({
|
||||||
|
// Here you can add custom Vite parameters
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
});
|
||||||
|
|
||||||
|
export default overrideVaadinConfig(customConfig);
|
||||||
0
zeiterfassung.db
Normal file
0
zeiterfassung.db
Normal file
Reference in New Issue
Block a user