60 Commits

Author SHA1 Message Date
Nils Burghardt
2126efbc8d update #TT-11 In Progress
Signed-off-by: Nils Burghardt <nilzbu@gmail.com>
2025-08-13 13:52:47 +02:00
Nils Burghardt
976d979f5c TT-11 State In Progress
Signed-off-by: Nils Burghardt <nilzbu@gmail.com>
2025-08-13 13:45:19 +02:00
Nils Burghardt
17e8998248 TT-11 State In Progress
Signed-off-by: Nils Burghardt <nilzbu@gmail.com>
2025-08-13 13:34:42 +02:00
Nils Burghardt
1cd4728eb4 TT-11 State In Progress 2025-08-13 13:29:32 +02:00
Nils Burghardt
3e0a0532c0 TT-11 State In Progress 2025-08-13 13:27:49 +02:00
Nils Burghardt
c9163753ac TT-11 state In Progress 2025-08-13 13:27:22 +02:00
Nils Burghardt
d05031f35e TT-11 state In Progress 2025-08-13 13:23:12 +02:00
Nils Burghardt
71b35a44a7 TT-11 Set state In Progress 2025-08-13 13:22:17 +02:00
Nils Burghardt
a58f9d9635 TT-11 Set State In Progress 2025-08-13 13:14:56 +02:00
Nils Burghardt
736630adec TT-11 Set State In Progress 2025-08-13 13:02:22 +02:00
Nils Burghardt
030f2d2155 TT-11 State In Progress 2025-08-13 12:58:16 +02:00
nilzbu
0d2334da48 pipeline
All checks were successful
Build-und-Deploy / build (push) Successful in 1m37s
2025-08-09 19:24:25 +02:00
nilzbu
f24fef9b16 pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 1m34s
2025-08-09 19:21:01 +02:00
nilzbu
13d8970b7a pipeline
All checks were successful
Build-und-Deploy / build (push) Successful in 1m36s
2025-08-09 18:57:36 +02:00
nilzbu
eed815c646 pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 33s
2025-08-09 18:47:35 +02:00
nilzbu
5bacd33ea0 pipeline
Some checks failed
Probe-Mounts / build (push) Failing after 29s
2025-08-09 18:39:32 +02:00
nilzbu
9bf604719a pipeline
Some checks failed
Probe-Mounts / build (push) Failing after 34s
2025-08-09 18:33:48 +02:00
nilzbu
8dcd02c593 pipeline
Some checks failed
Probe-Mounts / build (push) Failing after 30s
2025-08-09 18:26:31 +02:00
nilzbu
9efdacea9e pipeline
Some checks failed
Probe-Mounts / build (push) Failing after 2s
2025-08-09 18:24:04 +02:00
nilzbu
587e2dbdf2 pipeline
Some checks failed
Probe-Mounts / build (push) Failing after 23s
2025-08-09 18:18:31 +02:00
nilzbu
4f3a5d4d66 pipeline
Some checks failed
Build-und-Deploy (Probe) / build (push) Failing after 28s
2025-08-09 18:15:58 +02:00
nilzbu
590babea9e pipeline
Some checks failed
Build-und-Deploy (Probe) / build (push) Failing after 28s
2025-08-09 18:11:41 +02:00
nilzbu
4e9fd80296 pipeline
Some checks failed
Build-und-Deploy (Probe) / build (push) Failing after 28s
2025-08-09 18:07:00 +02:00
nilzbu
eb60e5af16 pipeline
Some checks failed
Build-und-Deploy (Probe) / build (push) Failing after 35s
2025-08-09 17:56:55 +02:00
nilzbu
eb02ef4490 pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 30s
2025-08-09 17:50:49 +02:00
nilzbu
3c705b6ad8 pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 29s
2025-08-09 17:44:22 +02:00
nilzbu
464d644c4f pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 31s
2025-08-09 17:39:47 +02:00
nilzbu
c04e4f85fc pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 35s
2025-08-09 17:07:37 +02:00
nilzbu
d3c41b3e6c pipeline
Some checks are pending
Build-und-Deploy / build (push) Waiting to run
2025-08-09 16:57:29 +02:00
nilzbu
b1202577a8 pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 42s
2025-08-09 16:43:59 +02:00
nilzbu
39af511bbd pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 1m20s
2025-08-09 16:33:33 +02:00
nilzbu
32dfb0d6b4 pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 1m6s
2025-08-09 16:17:44 +02:00
nilzbu
e70a559f1d pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 1m6s
2025-08-09 16:14:21 +02:00
nilzbu
bbf3e4a20e pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 1m7s
2025-08-09 16:09:39 +02:00
nilzbu
64abe911ee pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 1m2s
2025-08-09 16:04:38 +02:00
nilzbu
d3265d364c pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 1m20s
2025-08-09 15:59:30 +02:00
nilzbu
8be2920952 update pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 9s
2025-08-09 14:38:52 +02:00
nilzbu
98db0678b4 update pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 3s
2025-08-09 14:29:39 +02:00
nilzbu
f88274be3d update pipeline
Some checks failed
Build-und-Deploy / build (push) Failing after 1m0s
2025-08-09 14:26:58 +02:00
nilzbu
6d56458bb9 Merge remote-tracking branch 'origin/master'
Some checks failed
Build-und-Deploy / build (push) Failing after 17s
2025-08-09 14:15:06 +02:00
nilzbu
daf43b7ce2 gradle settings 2025-08-09 14:14:35 +02:00
6b72da2d1e Merge pull request 'TT-1 Update Input View' (#15) from feature/TT-1_overwork_input_view into master
Some checks failed
Build-und-Deploy / build (push) Failing after 1m13s
Reviewed-on: #15
2025-08-09 12:02:09 +00:00
nilzbu
0361bd7bed input view refactoring 2025-08-09 13:50:53 +02:00
8435b976d6 feature/2_adjust_dashboard (#14)
All checks were successful
Build-und-Deploy / build (push) Successful in 12s
Co-authored-by: Nils Burghardt <nilzbu@gmail.com>
Reviewed-on: #14
2025-07-21 12:53:03 +00:00
ac3993545e .gitea/workflows/restart.yaml aktualisiert
All checks were successful
Build-und-Deploy / build (push) Successful in 11s
2025-07-21 12:21:10 +00:00
c62127e24d .gitea/workflows/restart.yaml aktualisiert
All checks were successful
Build-und-Deploy / build (push) Successful in 12s
2025-07-21 12:19:09 +00:00
f36fd63a04 addRestartAction (#12)
All checks were successful
Build-und-Deploy / build (push) Successful in 12s
Co-authored-by: Nils Burghardt <nilzbu@gmail.com>
Reviewed-on: #12
2025-07-21 12:09:45 +00:00
75ddbecc79 Enhance Dashboard (#11)
All checks were successful
Build-und-Deploy / build (push) Successful in 23s
Co-authored-by: Nils Burghardt <nilzbu@gmail.com>
Reviewed-on: #11
2025-07-21 10:53:08 +00:00
7de22a723e fix main view charts (#8)
All checks were successful
Build-und-Deploy / build (push) Successful in 21s
Co-authored-by: Nils Burghardt <nilzbu@gmail.com>
Reviewed-on: #8
2025-07-14 09:49:20 +00:00
a64d56bbd9 Feature 5 enhance table with sort and filter functionality (#6)
All checks were successful
Build-und-Deploy / build (push) Successful in 11s
Co-authored-by: Nils Burghardt <nilzbu@gmail.com>
Reviewed-on: #6
2025-07-13 17:14:00 +00:00
7446839a7b Merge pull request 'Clean code update 1' (#1) from cq_1 into master
All checks were successful
Build-und-Deploy / build (push) Successful in 12s
Reviewed-on: #1
2025-07-13 16:42:27 +00:00
Nils Burghardt
2c7dd8c8f2 upgrade clean code DayStatus SecurityConfig 2025-07-13 18:41:53 +02:00
Nils Burghardt
44672876be upgrade clean code ChartJsComponent 2025-07-13 18:35:03 +02:00
Nils Burghardt
e828e83991 upgrade clean code MainLayout 2025-07-13 18:34:31 +02:00
Nils Burghardt
6b57a0c5e5 upgrade clean code UserManagementView 2025-07-13 18:30:20 +02:00
Nils Burghardt
0f005ca130 Remove DB file from Git and add to .gitignore 2025-07-13 18:28:10 +02:00
Nils Burghardt
acb96dc1c6 upgrade clean code TimeEntryView 2025-07-13 18:27:21 +02:00
Nils Burghardt
171f71209b applay new gitignore 2025-07-13 18:23:37 +02:00
Nils Burghardt
94931bd7e0 extend gitignore 2025-07-13 18:20:34 +02:00
Nils Burghardt
9e4b0de915 version 1.0
All checks were successful
Build-und-Deploy / build (push) Successful in 11s
minor change for pipeline test

minor change for pipeline test

minor change for pipeline test

minor change for pipeline test

minor change for pipeline test

minor change for pipeline test

minor change for pipeline test

minor change for pipeline test

minor change for pipeline test

minor change for pipeline test

minor change for pipeline test

minor change for pipeline test

Add Gitea Actions build workflow

Add CI/CD pipeline

Test Build & Deploy

add pipeline

add pipeline2

add pipeline3

add pipeline4

add pipeline 5

add pipeline 6

add pipeline 7

add pipeline 8

add pipeline 9

add pipeline 10

add pipeline 11

add pipeline 12

add pipeline 13

add pipeline 14

add pipeline 15

add pipeline 16

add pipeline 17

add pipeline 18

add pipeline 19

add pipeline 20

add pipeline 21

add pipeline 22

add pipeline 23

add pipeline 24

1

2

3
2025-07-13 18:07:04 +02:00
48 changed files with 4379 additions and 129 deletions

View 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

View 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
View File

@@ -91,4 +91,6 @@ coverage/
*.swp
*.swo
*.bak
*.tmp
*.tmpdata/*.mv.db
src/main/frontend/generated/
/data/*.mv.db

View File

@@ -1,4 +1,5 @@
FROM ubuntu:latest
LABEL authors="me"
ENTRYPOINT ["top", "-b"]
FROM eclipse-temurin:17-jdk-alpine
WORKDIR /app
COPY --chown=appuser:appuser build/libs/MyTimeTracker-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

46
HELP.md Normal file
View 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.

View File

@@ -10,7 +10,7 @@ version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
languageVersion = JavaLanguageVersion.of(21)
}
}
@@ -22,6 +22,7 @@ configurations {
repositories {
mavenCentral()
maven { url 'https://maven.vaadin.com/vaadin-addons' }
}
ext {
@@ -32,10 +33,16 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
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'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
annotationProcessor 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
@@ -50,3 +57,7 @@ dependencyManagement {
tasks.named('test') {
useJUnitPlatform()
}
vaadin {
productionMode = true
}

View File

@@ -1,31 +1,51 @@
version: "3.9"
services:
db:
image: mysql:8
restart: always
container_name: mytimetracker-db
restart: always
environment:
MYSQL_DATABASE: mytimetracker
MYSQL_ROOT_PASSWORD: root
MYSQL_USER: appuser
MYSQL_PASSWORD: apppass
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
expose:
- "3306"
networks:
- traefik
app:
build: .
image: eclipse-temurin:17-jdk-alpine
container_name: mytimetracker-app
depends_on:
- db
ports:
- "8080:8080"
working_dir: /app
command: java -jar /app/MyTimeTracker-0.0.1-SNAPSHOT.jar
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/mytimetracker?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
SPRING_DATASOURCE_USERNAME: appuser
SPRING_DATASOURCE_PASSWORD: apppass
SPRING_JPA_HIBERNATE_DDL_AUTO: update
SPRING_PROFILES_ACTIVE: docker
volumes:
- shared_jar_data:/app:ro # ⬅️ Direkt ins /app mounten
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
volumes:
db_data:
shared_jar_data:
external: true
networks:
traefik:
external: true

File diff suppressed because it is too large Load Diff

108
package.json Normal file
View 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
probe.txt Normal file
View File

@@ -0,0 +1 @@
Test 11

View File

@@ -1 +1,5 @@
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.9.0'
}
rootProject.name = 'MyTimeTracker'

View File

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

BIN
src/main/bundles/dev.bundle Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -1 +1,2 @@
src/main/frontend/chart-helper.js
import Chart from 'chart.js/auto';
window.Chart = Chart;

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<!--
This file is auto-generated by Vaadin.
-->
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, 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>

View File

@@ -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();
}
});

View File

@@ -1,32 +1,69 @@
:root {
/* Hintergrund- und Textfarben */
--lumo-base-color: hsl(0, 0%, 8%); /* Sehr dunkles Grau (fast schwarz) */
--lumo-body-text-color: hsl(150, 30%, 90%); /* Hellgrünliches Weiß für Text */
--lumo-font-size: 1rem;
--lumo-font-size-xxxl: 3rem;
--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-color: hsl(145, 80%, 40%);
--lumo-primary-color-50pct: hsla(145, 80%, 40%, 0.5);
--lumo-primary-color-10pct: hsla(145, 80%, 40%, 0.1);
--lumo-primary-text-color: rgb(9, 134, 24);
--lumo-primary-color-50pct: rgba(9, 134, 24, 0.5);
--lumo-primary-color-10pct: rgba(9, 134, 24, 0.1);
--lumo-primary-color: hsl(127, 87%, 28%);
/* Sekundärfarben / Akzente */
--lumo-success-color: hsl(145, 80%, 35%);
--lumo-error-color: hsl(0, 80%, 60%);
--lumo-border-radius: 6px;
--lumo-base-color: #dadcc7;
--lumo-tint-5pct: rgba(101, 105, 63, 0.05);
--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 */
[theme~="primary"] {
background-color: var(--lumo-primary-color);
color: white;
/* Deckender Hintergrund + Rahmen für die Filter-Wrapper im Header */
.filter-cell {
background: var(--lumo-base-color, #fff);
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 {
border-radius: var(--lumo-border-radius);
vaadin-grid::part(header-row),
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 */
vaadin-grid {
border-radius: var(--lumo-border-radius);
background-color: var(--lumo-base-color);
color: var(--lumo-body-text-color);
.filter-cell :is(vaadin-date-picker,
vaadin-combo-box,
vaadin-text-field,
vaadin-checkbox,
vaadin-button) {
margin: 0;
width: 100%;
}

View File

@@ -2,7 +2,9 @@ package de.nilzbu.mytimetracker.config;
import com.vaadin.flow.component.page.AppShellConfigurator;
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 {
}

View File

@@ -1,4 +1,31 @@
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 {
@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");
}
};
}
}

View File

@@ -1,4 +1,86 @@
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 {
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:0009:30
int workMinutes = 450 + random.nextInt(61); // 450510
int pause = 30 + random.nextInt(31); // 3060 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 + "]");
}
}

View File

@@ -1,4 +1,18 @@
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;
}
}

View File

@@ -1,4 +1,56 @@
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 {
@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;
}

View File

@@ -1,4 +1,46 @@
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 {
@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;
}

View File

@@ -1,4 +1,21 @@
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);
}

View File

@@ -1,4 +1,10 @@
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);
}

View File

@@ -1,4 +1,23 @@
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();
}
}

View File

@@ -1,4 +1,38 @@
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()
);
}
}

View File

@@ -1,4 +1,85 @@
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 {
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);
}
}

View File

@@ -1,4 +1,20 @@
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 {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public Optional<User> findByUsername(String username) {
return userRepository.findByUsername(username);
}
}

View File

@@ -1,4 +1,148 @@
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;
}
}

View File

@@ -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.button.Button;
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.router.RouterLink;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.server.auth.AccessAnnotationChecker;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.server.VaadinServletRequest;
import com.vaadin.flow.server.VaadinServletResponse;
import de.nilzbu.mytimetracker.ui.view.DashboardOverView;
import de.nilzbu.mytimetracker.ui.view.timeentry.TimeEntryView;
import de.nilzbu.mytimetracker.ui.view.UserManagementView;
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.web.authentication.logout.SecurityContextLogoutHandler;
@@ -17,32 +22,45 @@ import org.springframework.security.web.authentication.logout.SecurityContextLog
public class MainLayout extends AppLayout {
public MainLayout() {
createHeader();
buildHeader();
}
private void createHeader() {
H1 logo = new H1("My Time Tracker");
logo.getStyle().set("font-size", "var(--lumo-font-size-l)").set("margin", "0");
private void buildHeader() {
H1 title = new H1("Time Tracker");
title.getStyle()
.set("font-size", "var(--lumo-font-size-l)")
.set("margin", "0");
RouterLink homeLink = new RouterLink("Startseite", MainView.class);
homeLink.getStyle().set("margin-left", "2em");
RouterLink dashboardLink = new RouterLink("Dashboard", DashboardOverView.class);
RouterLink bookingsLink = new RouterLink("Bookings", TimeEntryView.class);
RouterLink adminLink = new RouterLink("Admin", UserManagementView.class);
Button logout = new Button("Logout", e -> {
SecurityContextLogoutHandler logoutHandler = new SecurityContextLogoutHandler();
logoutHandler.logout(
null,
SecurityContextHolder.getContext().getAuthentication(),
null
);
getUI().ifPresent(ui -> ui.getPage().setLocation("/login"));
});
dashboardLink.getStyle().set("margin-left", "2em");
bookingsLink.getStyle().set("margin-left", "2em");
adminLink.getStyle().set("margin-left", "2em");
HorizontalLayout header = new HorizontalLayout(logo, homeLink, logout);
header.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER);
header.setWidthFull();
header.setPadding(true);
header.setSpacing(true);
Button logoutButton = new Button("Logout", event -> performLogout());
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"));
}
}

View File

@@ -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)"
);
}
}

View File

@@ -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.login.LoginForm;
import com.vaadin.flow.component.login.LoginI18n;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
@@ -17,6 +16,6 @@ public class LoginView extends VerticalLayout {
LoginForm login = new LoginForm();
login.setAction("login"); // wichtig für Spring Security
add(new H2("My Time Tracker Anmeldung"), login);
add(new H2("Time Tracker Login"), login);
}
}

View File

@@ -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.")
);
}
}

View File

@@ -1,4 +0,0 @@
package de.nilzbu.mytimetracker.ui.view;
public class TimeEntryView {
}

View File

@@ -1,4 +0,0 @@
package de.nilzbu.mytimetracker.ui.view;
public class UserAdminView {
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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.");
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -1,2 +1,33 @@
vaadin.launch-browser=true
# ------------------------------------------------------------
# Anwendungskonfiguration
# ------------------------------------------------------------
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
View 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
View 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
View File