43 Commits

Author SHA1 Message Date
Nils Burghardt
29a0336c39 TT-9 State In Progress 2025-08-13 12:22:46 +02:00
Nils Burghardt
78f548eb28 TT-9 State In Progress 2025-08-13 12:18:59 +02:00
Nils Burghardt
01fa55553b TT-9 State In Progress 2025-08-13 12:16:56 +02:00
Nils Burghardt
804bb9dc0a TT-9 State In Progress 2025-08-13 12:11:23 +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
20 changed files with 650 additions and 293 deletions

View File

@@ -2,37 +2,60 @@ name: Build-und-Deploy
on: on:
push: push:
branches: branches: [ master ]
- master
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: ci-java21-node20:latest
steps: steps:
- name: 📥 Repository auschecken - name: Map gitea into /etc/hosts (ohne ip)
uses: actions/checkout@v3 shell: bash
- name: 🧼 IP-Adresse von Gitea zu /etc/hosts hinzufügen
run: echo "172.26.0.2 gitea" >> /etc/hosts
- name: 🛠 Baue das Projekt (ohne Tests)
run: ./gradlew clean build -x test
- name: 📦 JAR ins Shared Volume kopieren und archivieren
run: | 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 mkdir -p /shared_jar_data /shared_jar_data/archive
SRC="$(ls -1 build/libs/*.jar | head -n1)"
# Zeitstempel im Format YYYYMMDD-HHMMSS # WICHTIG: Fester Name, den docker-compose startet
timestamp=$(date +"%Y%m%d-%H%M%S") cp -f "$SRC" "/shared_jar_data/MyTimeTracker-0.0.1-SNAPSHOT.jar"
# Zusätzlich archivieren
# JAR-Datei kopieren (aktuelle Version) cp -f "$SRC" "/shared_jar_data/archive/MyTimeTracker-${ts}.jar"
cp build/libs/MyTimeTracker-0.0.1-SNAPSHOT.jar /shared_jar_data/MyTimeTracker-0.0.1-SNAPSHOT.jar ls -al /shared_jar_data | tail -n +1
# JAR-Datei archivieren
cp build/libs/MyTimeTracker-0.0.1-SNAPSHOT.jar /shared_jar_data/archive/MyTimeTracker-$timestamp.jar
- name: 🚦 Neustart der Anwendung triggern
run: |
mkdir -p /shared_trigger_dir
touch /shared_trigger_dir/restart-requested

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

View File

@@ -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,6 +33,7 @@ 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' runtimeOnly 'com.mysql:mysql-connector-j:8.3.0'

View File

@@ -78,7 +78,7 @@ insert into time_entries (comment,date,end_time,pause_minutes,start_time,status,
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223) at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223)
at jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source) at jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source)
at de.nilzbu.mytimetracker.service.TimeEntryService.save(TimeEntryService.java:31) at de.nilzbu.mytimetracker.service.TimeEntryService.save(TimeEntryService.java:31)
at de.nilzbu.mytimetracker.ui.view.TimeEntryView.lambda$configureButtons$9b1b5227$1(TimeEntryView.java:141) at de.nilzbu.mytimetracker.ui.view.timeentry.TimeEntryView.lambda$configureButtons$9b1b5227$1(TimeEntryView.java:141)
at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:244) at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:244)
at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:501) at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:501)
at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:303) at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:303)
@@ -280,7 +280,7 @@ insert into time_entries (comment,date,end_time,pause_minutes,start_time,status,
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223) at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223)
at jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source) at jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source)
at de.nilzbu.mytimetracker.service.TimeEntryService.save(TimeEntryService.java:31) at de.nilzbu.mytimetracker.service.TimeEntryService.save(TimeEntryService.java:31)
at de.nilzbu.mytimetracker.ui.view.TimeEntryView.lambda$configureButtons$9b1b5227$1(TimeEntryView.java:141) at de.nilzbu.mytimetracker.ui.view.timeentry.TimeEntryView.lambda$configureButtons$9b1b5227$1(TimeEntryView.java:141)
at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:244) at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:244)
at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:501) at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:501)
at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:303) at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:303)
@@ -482,7 +482,7 @@ insert into time_entries (comment,date,end_time,pause_minutes,start_time,status,
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223) at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223)
at jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source) at jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source)
at de.nilzbu.mytimetracker.service.TimeEntryService.save(TimeEntryService.java:31) at de.nilzbu.mytimetracker.service.TimeEntryService.save(TimeEntryService.java:31)
at de.nilzbu.mytimetracker.ui.view.TimeEntryView.lambda$configureButtons$9b1b5227$1(TimeEntryView.java:141) at de.nilzbu.mytimetracker.ui.view.timeentry.TimeEntryView.lambda$configureButtons$9b1b5227$1(TimeEntryView.java:141)
at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:244) at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:244)
at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:501) at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:501)
at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:303) at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:303)
@@ -684,7 +684,7 @@ insert into time_entries (comment,date,end_time,pause_minutes,start_time,status,
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223) at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223)
at jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source) at jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source)
at de.nilzbu.mytimetracker.service.TimeEntryService.save(TimeEntryService.java:31) at de.nilzbu.mytimetracker.service.TimeEntryService.save(TimeEntryService.java:31)
at de.nilzbu.mytimetracker.ui.view.TimeEntryView.lambda$configureButtons$9b1b5227$1(TimeEntryView.java:141) at de.nilzbu.mytimetracker.ui.view.timeentry.TimeEntryView.lambda$configureButtons$9b1b5227$1(TimeEntryView.java:141)
at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:244) at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:244)
at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:501) at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:501)
at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:303) at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:303)
@@ -886,7 +886,7 @@ insert into time_entries (comment,date,end_time,pause_minutes,start_time,status,
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223) at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223)
at jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source) at jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source)
at de.nilzbu.mytimetracker.service.TimeEntryService.save(TimeEntryService.java:31) at de.nilzbu.mytimetracker.service.TimeEntryService.save(TimeEntryService.java:31)
at de.nilzbu.mytimetracker.ui.view.TimeEntryView.lambda$configureButtons$9b1b5227$1(TimeEntryView.java:141) at de.nilzbu.mytimetracker.ui.view.timeentry.TimeEntryView.lambda$configureButtons$9b1b5227$1(TimeEntryView.java:141)
at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:244) at com.vaadin.flow.component.ComponentEventBus.fireEventForListener(ComponentEventBus.java:244)
at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:501) at com.vaadin.flow.component.ComponentEventBus.handleDomEvent(ComponentEventBus.java:501)
at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:303) at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:303)

1
probe.txt Normal file
View File

@@ -0,0 +1 @@
test 3

View File

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

View File

@@ -1,5 +1,4 @@
:root {
:root {
--lumo-font-size: 1rem; --lumo-font-size: 1rem;
--lumo-font-size-xxxl: 3rem; --lumo-font-size-xxxl: 3rem;
--lumo-font-size-xxl: 2.25rem; --lumo-font-size-xxl: 2.25rem;
@@ -9,11 +8,14 @@
--lumo-font-size-s: 1rem; --lumo-font-size-s: 1rem;
--lumo-font-size-xs: 0.875rem; --lumo-font-size-xs: 0.875rem;
--lumo-font-size-xxs: 0.8125rem; --lumo-font-size-xxs: 0.8125rem;
--lumo-primary-text-color: rgb(9, 134, 24); --lumo-primary-text-color: rgb(9, 134, 24);
--lumo-primary-color-50pct: rgba(9, 134, 24, 0.5); --lumo-primary-color-50pct: rgba(9, 134, 24, 0.5);
--lumo-primary-color-10pct: rgba(9, 134, 24, 0.1); --lumo-primary-color-10pct: rgba(9, 134, 24, 0.1);
--lumo-primary-color: hsl(127, 87%, 28%); --lumo-primary-color: hsl(127, 87%, 28%);
--lumo-base-color: #dadcc79e;
--lumo-base-color: #dadcc7;
--lumo-tint-5pct: rgba(101, 105, 63, 0.05); --lumo-tint-5pct: rgba(101, 105, 63, 0.05);
--lumo-tint-10pct: rgba(101, 105, 63, 0.1); --lumo-tint-10pct: rgba(101, 105, 63, 0.1);
--lumo-tint-20pct: rgba(101, 105, 63, 0.2); --lumo-tint-20pct: rgba(101, 105, 63, 0.2);
@@ -24,9 +26,44 @@
--lumo-tint-70pct: rgba(101, 105, 63, 0.7); --lumo-tint-70pct: rgba(101, 105, 63, 0.7);
--lumo-tint-80pct: rgba(101, 105, 63, 0.8); --lumo-tint-80pct: rgba(101, 105, 63, 0.8);
--lumo-tint-90pct: rgba(101, 105, 63, 0.9); --lumo-tint-90pct: rgba(101, 105, 63, 0.9);
--lumo-tint: #65693fed; --lumo-tint: #65693f;
--lumo-success-text-color: rgb(56, 204, 36); --lumo-success-text-color: rgb(56, 204, 36);
--lumo-success-color-50pct: rgba(56, 204, 36, 0.5); --lumo-success-color-50pct: rgba(56, 204, 36, 0.5);
--lumo-success-color-10pct: rgba(56, 204, 36, 0.1); --lumo-success-color-10pct: rgba(56, 204, 36, 0.1);
--lumo-success-color: hsl(113, 70%, 47%); --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;
}
/* 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-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 */
}
.filter-cell :is(vaadin-date-picker,
vaadin-combo-box,
vaadin-text-field,
vaadin-checkbox,
vaadin-button) {
margin: 0;
width: 100%;
}

View File

@@ -49,6 +49,7 @@ public class TimeEntry {
*/ */
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false) @Column(nullable = false)
@Builder.Default
private DayStatus status = DayStatus.REMOTE; private DayStatus status = DayStatus.REMOTE;
private String comment; private String comment;

View File

@@ -34,9 +34,11 @@ public class User {
private Set<String> roles; // z.B. ROLE_USER, ROLE_ADMIN private Set<String> roles; // z.B. ROLE_USER, ROLE_ADMIN
@Column(nullable = false) @Column(nullable = false)
@Builder.Default
private boolean enabled = true; private boolean enabled = true;
@Column(nullable = false) @Column(nullable = false)
@Builder.Default
private boolean locked = false; private boolean locked = false;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)

View File

@@ -12,10 +12,6 @@ public interface TimeEntryRepository extends JpaRepository<TimeEntry, Long> {
List<TimeEntry> findAllByUserOrderByDateDesc(User user); List<TimeEntry> findAllByUserOrderByDateDesc(User user);
Optional<TimeEntry> findTopByUserOrderByDateDesc(User user);
boolean existsByUserAndDate(User user, LocalDate date);
Optional<TimeEntry> findByUserAndDate(User user, LocalDate date); Optional<TimeEntry> findByUserAndDate(User user, LocalDate date);
List<TimeEntry> findByUserAndDateBetween(User user, LocalDate start, LocalDate end); List<TimeEntry> findByUserAndDateBetween(User user, LocalDate start, LocalDate end);

View File

@@ -23,6 +23,10 @@ public class TimeEntryService {
return repository.findAllByUserOrderByDateDesc(user); return repository.findAllByUserOrderByDateDesc(user);
} }
public int getNumberOfEntriesForUser(User user) {
return getEntriesForUser(user).size();
}
public TimeEntry save(TimeEntry entry) { public TimeEntry save(TimeEntry entry) {
return repository.save(entry); return repository.save(entry);
} }
@@ -32,7 +36,7 @@ public class TimeEntryService {
return 0; return 0;
} }
long total = Duration.between(entry.getStartTime(), entry.getEndTime()).toMinutes(); long total = Duration.between(entry.getStartTime(), entry.getEndTime()).toMinutes();
return (int)(total - entry.getPauseMinutes()); return (int) (total - entry.getPauseMinutes());
} }
public long calculateDeviation(TimeEntry entry) { public long calculateDeviation(TimeEntry entry) {
@@ -49,6 +53,10 @@ public class TimeEntryService {
return repository.findByUserAndDateBetween(user, start, end); 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) { public List<TimeEntry> getEntriesForQuarter(User user, int year, int quarter) {
int startMonth = (quarter - 1) * 3 + 1; int startMonth = (quarter - 1) * 3 + 1;
LocalDate start = LocalDate.of(year, startMonth, 1); LocalDate start = LocalDate.of(year, startMonth, 1);
@@ -56,12 +64,20 @@ public class TimeEntryService {
return repository.findByUserAndDateBetween(user, start, end); 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) { public List<TimeEntry> getEntriesForYear(User user, int year) {
LocalDate start = LocalDate.of(year, 1, 1); LocalDate start = LocalDate.of(year, 1, 1);
LocalDate end = LocalDate.of(year, 12, 31); LocalDate end = LocalDate.of(year, 12, 31);
return repository.findByUserAndDateBetween(user, start, end); return repository.findByUserAndDateBetween(user, start, end);
} }
public int getNumberOfEntriesForYear(User user, int year) {
return getEntriesForYear(user, year).size();
}
public Optional<LocalDate> getEarliestEntryDate(User user) { public Optional<LocalDate> getEarliestEntryDate(User user) {
return repository.findFirstByUserOrderByDateAsc(user) return repository.findFirstByUserOrderByDateAsc(user)
.map(TimeEntry::getDate); .map(TimeEntry::getDate);

View File

@@ -37,7 +37,9 @@ public class ChartJsComponent extends Div {
getElement().executeJs( getElement().executeJs(
""" """
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
appendChild(canvas); canvas.style.width = "100%";
canvas.style.height = "100%";
this.appendChild(canvas);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
new Chart(ctx, { new Chart(ctx, {
type: $0, type: $0,
@@ -64,8 +66,7 @@ public class ChartJsComponent extends Div {
} }
/** /**
* Detects chart type based on chart data. * Bestimmt den Diagrammtyp basierend auf dem Dataset.
* If no definitive type can be determined, defaults to "bar".
*/ */
private String detectChartType(JsonObject data) { private String detectChartType(JsonObject data) {
try { try {
@@ -82,7 +83,7 @@ public class ChartJsComponent extends Div {
} }
/** /**
* Creates JSON chart data for a bar chart based on category counts. * Erstellt ein Balkendiagramm für Kategoriezählungen.
*/ */
public static JsonObject generateBarChartData(Map<String, Long> categoryCounts) { public static JsonObject generateBarChartData(Map<String, Long> categoryCounts) {
JsonObject data = Json.createObject(); JsonObject data = Json.createObject();
@@ -117,7 +118,7 @@ public class ChartJsComponent extends Div {
} }
/** /**
* Creates JSON chart data for a line chart showing overtime balance. * Erstellt ein Liniendiagramm zur Darstellung des Überzeit-Saldos.
*/ */
public static JsonObject generateLineChartData(List<LocalDate> dates, List<Double> balanceValues) { public static JsonObject generateLineChartData(List<LocalDate> dates, List<Double> balanceValues) {
JsonObject data = Json.createObject(); JsonObject data = Json.createObject();

View File

@@ -8,8 +8,8 @@ import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.router.RouterLink; import com.vaadin.flow.router.RouterLink;
import com.vaadin.flow.server.VaadinServletRequest; import com.vaadin.flow.server.VaadinServletRequest;
import com.vaadin.flow.server.VaadinServletResponse; import com.vaadin.flow.server.VaadinServletResponse;
import de.nilzbu.mytimetracker.ui.view.MainView; import de.nilzbu.mytimetracker.ui.view.DashboardOverView;
import de.nilzbu.mytimetracker.ui.view.TimeEntryView; import de.nilzbu.mytimetracker.ui.view.timeentry.TimeEntryView;
import de.nilzbu.mytimetracker.ui.view.UserManagementView; 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.HttpServletRequest;
@@ -31,7 +31,7 @@ public class MainLayout extends AppLayout {
.set("font-size", "var(--lumo-font-size-l)") .set("font-size", "var(--lumo-font-size-l)")
.set("margin", "0"); .set("margin", "0");
RouterLink dashboardLink = new RouterLink("Dashboard", MainView.class); RouterLink dashboardLink = new RouterLink("Dashboard", DashboardOverView.class);
RouterLink bookingsLink = new RouterLink("Bookings", TimeEntryView.class); RouterLink bookingsLink = new RouterLink("Bookings", TimeEntryView.class);
RouterLink adminLink = new RouterLink("Admin", UserManagementView.class); RouterLink adminLink = new RouterLink("Admin", UserManagementView.class);

View File

@@ -4,29 +4,34 @@ import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H2; 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.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.tabs.Tab; import com.vaadin.flow.component.tabs.Tab;
import com.vaadin.flow.component.tabs.Tabs; import com.vaadin.flow.component.tabs.Tabs;
import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import de.nilzbu.mytimetracker.model.DayStatus;
import de.nilzbu.mytimetracker.model.TimeEntry; import de.nilzbu.mytimetracker.model.TimeEntry;
import de.nilzbu.mytimetracker.model.User; import de.nilzbu.mytimetracker.model.User;
import de.nilzbu.mytimetracker.repository.UserRepository; import de.nilzbu.mytimetracker.repository.UserRepository;
import de.nilzbu.mytimetracker.service.TimeEntryService; import de.nilzbu.mytimetracker.service.TimeEntryService;
import de.nilzbu.mytimetracker.ui.component.ChartJsComponent; import de.nilzbu.mytimetracker.ui.component.ChartJsComponent;
import de.nilzbu.mytimetracker.ui.layout.MainLayout; import de.nilzbu.mytimetracker.ui.layout.MainLayout;
import de.nilzbu.mytimetracker.ui.widget.KeyFigureWidget;
import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.PermitAll;
import org.jetbrains.annotations.NotNull;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.*; import java.util.*;
import java.util.function.BiFunction;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.IntStream; import java.util.stream.IntStream;
@PermitAll @PermitAll
@PageTitle("Dashboard") @PageTitle("Dashboard")
@Route(value = "", layout = MainLayout.class) @Route(value = "", layout = MainLayout.class)
public class MainView extends VerticalLayout { public class DashboardOverView extends VerticalLayout {
private final TimeEntryService timeEntryService; private final TimeEntryService timeEntryService;
private final UserRepository userRepository; private final UserRepository userRepository;
@@ -39,7 +44,10 @@ public class MainView extends VerticalLayout {
private final Div filterContainer = new Div(); private final Div filterContainer = new Div();
private final Div contentContainer = new Div(); private final Div contentContainer = new Div();
public MainView(TimeEntryService timeEntryService, UserRepository userRepository) { 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.timeEntryService = timeEntryService;
this.userRepository = userRepository; this.userRepository = userRepository;
@@ -50,13 +58,11 @@ public class MainView extends VerticalLayout {
} }
private void configureLayout() { private void configureLayout() {
setSizeFull();
setPadding(false); setPadding(false);
setSpacing(false); setSpacing(false);
setMargin(false); setMargin(false);
contentContainer.setSizeFull(); contentContainer.setSizeFull();
contentContainer.getStyle().set("overflow", "auto");
setFlexGrow(1, contentContainer); setFlexGrow(1, contentContainer);
} }
@@ -107,7 +113,7 @@ public class MainView extends VerticalLayout {
List<TimeEntry> entries = switch (scope) { List<TimeEntry> entries = switch (scope) {
case "Month" -> { case "Month" -> {
configureFilterScope(yearSelector, monthSelector ); configureFilterScope(yearSelector, monthSelector);
yield timeEntryService.getEntriesForMonth(currentUser, yearSelector.getValue(), monthSelector.getValue()); yield timeEntryService.getEntriesForMonth(currentUser, yearSelector.getValue(), monthSelector.getValue());
} }
case "Quarter" -> { case "Quarter" -> {
@@ -121,6 +127,7 @@ public class MainView extends VerticalLayout {
default -> timeEntryService.getEntriesForUser(currentUser); default -> timeEntryService.getEntriesForUser(currentUser);
}; };
renderKeyFigures(entries);
renderCharts(entries); renderCharts(entries);
} }
@@ -139,16 +146,19 @@ public class MainView extends VerticalLayout {
private void renderCharts(List<TimeEntry> scopedEntries) { private void renderCharts(List<TimeEntry> scopedEntries) {
List<TimeEntry> allEntries = timeEntryService.getEntriesForUser(currentUser); List<TimeEntry> allEntries = timeEntryService.getEntriesForUser(currentUser);
VerticalLayout chartLayout = new VerticalLayout(); HorizontalLayout chartLayout = new HorizontalLayout();
chartLayout.setSizeFull();
chartLayout.setPadding(false); chartLayout.setPadding(false);
chartLayout.setSpacing(true); chartLayout.setSpacing(true);
Component categoryChart = createCategoryBarChart(scopedEntries); Component categoryChart = createCategoryBarChart(scopedEntries);
Component overtimeChart = createOvertimeLineChart(allEntries, scopedEntries); categoryChart.getStyle().setBackgroundColor("lightgray");
categoryChart.getStyle().setBorder("1px solid black" );
categoryChart.getStyle().setMargin("10px");
categoryChart.getStyle().set("minHeight", "45vh"); Component overtimeChart = createOvertimeLineChart(allEntries, scopedEntries);
overtimeChart.getStyle().set("minHeight", "50vh"); overtimeChart.getStyle().setBackgroundColor("lightgray");
overtimeChart.getStyle().setBorder("1px solid black" );
overtimeChart.getStyle().setMargin("10px");
chartLayout.add(categoryChart, overtimeChart); chartLayout.add(categoryChart, overtimeChart);
chartLayout.setFlexGrow(1, categoryChart); chartLayout.setFlexGrow(1, categoryChart);
@@ -157,6 +167,54 @@ public class MainView extends VerticalLayout {
contentContainer.add(chartLayout); 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) { private Component createCategoryBarChart(List<TimeEntry> entries) {
Map<String, Long> statusCount = entries.stream() Map<String, Long> statusCount = entries.stream()
.collect(Collectors.groupingBy(e -> e.getStatus().name(), Collectors.counting())); .collect(Collectors.groupingBy(e -> e.getStatus().name(), Collectors.counting()));
@@ -191,6 +249,7 @@ public class MainView extends VerticalLayout {
return new ChartJsComponent( return new ChartJsComponent(
ChartJsComponent.generateLineChartData(scopedDates, saldoValues), ChartJsComponent.generateLineChartData(scopedDates, saldoValues),
"Overtime Balance Over Time (in hours)"); "Overtime Balance Over Time (in hours)"
);
} }
} }

View File

@@ -1,206 +0,0 @@
package de.nilzbu.mytimetracker.ui.view;
import com.vaadin.flow.component.button.Button;
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.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.timepicker.TimePicker;
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.layout.MainLayout;
import jakarta.annotation.security.PermitAll;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDate;
@Route(value = "time-entry", layout = MainLayout.class)
@PageTitle("Time Entry")
@PermitAll
public class TimeEntryView extends VerticalLayout {
private final TimeEntryService timeEntryService;
private final UserRepository userRepository;
private User currentUser;
private final DatePicker datePicker = new DatePicker("Date");
private final TimePicker startTimePicker = new TimePicker("Start Time");
private final TimePicker endTimePicker = new TimePicker("End Time");
private final NumberField breakField = new NumberField("Break (min)");
private final NumberField targetField = new NumberField("Target Time (min)");
private final TextArea commentArea = new TextArea("Comment");
private final ComboBox<DayStatus> statusCombo = new ComboBox<>("Status");
private final Button saveBtn = new Button("Save");
private final Button updateBtn = new Button("Update");
private final Button deleteBtn = new Button("Delete");
private final Grid<TimeEntry> entryGrid = new Grid<>(TimeEntry.class, false);
private TimeEntry selectedEntry = null;
public TimeEntryView(TimeEntryService timeEntryService, UserRepository userRepository) {
this.timeEntryService = timeEntryService;
this.userRepository = userRepository;
initializeUser();
configureFormFields();
configureButtons();
configureGrid();
datePicker.addValueChangeListener(e -> toggleSaveButton());
add(buildFormLayout(), buildButtonLayout(), entryGrid);
refreshGrid();
toggleSaveButton();
}
private void initializeUser() {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
this.currentUser = userRepository.findByUsername(username)
.orElseThrow(() -> new RuntimeException("User not found"));
}
private void configureFormFields() {
datePicker.setValue(LocalDate.now());
breakField.setValue(30.0);
targetField.setValue(480.0);
statusCombo.setItems(DayStatus.values());
statusCombo.setValue(DayStatus.REMOTE);
}
private HorizontalLayout buildFormLayout() {
return new HorizontalLayout(datePicker, startTimePicker, endTimePicker,
breakField, targetField, commentArea, statusCombo);
}
private HorizontalLayout buildButtonLayout() {
updateBtn.setEnabled(false);
deleteBtn.setEnabled(false);
return new HorizontalLayout(saveBtn, updateBtn, deleteBtn);
}
private void configureButtons() {
saveBtn.addClickListener(e -> saveEntry());
updateBtn.addClickListener(e -> updateEntry());
deleteBtn.addClickListener(e -> deleteEntry());
}
private void saveEntry() {
if (!isDateAvailable(datePicker.getValue())) {
Notification.show("An entry already exists for this date.");
return;
}
TimeEntry entry = TimeEntry.builder()
.user(currentUser)
.date(datePicker.getValue())
.startTime(startTimePicker.getValue())
.endTime(endTimePicker.getValue())
.pauseMinutes(breakField.getValue().intValue())
.targetMinutes(targetField.getValue().intValue())
.status(statusCombo.getValue())
.comment(commentArea.getValue())
.build();
timeEntryService.save(entry);
clearForm();
refreshGrid();
Notification.show("Entry saved");
}
private void updateEntry() {
if (selectedEntry == null) return;
selectedEntry.setDate(datePicker.getValue());
selectedEntry.setStartTime(startTimePicker.getValue());
selectedEntry.setEndTime(endTimePicker.getValue());
selectedEntry.setPauseMinutes(breakField.getValue().intValue());
selectedEntry.setTargetMinutes(targetField.getValue().intValue());
selectedEntry.setStatus(statusCombo.getValue());
selectedEntry.setComment(commentArea.getValue());
timeEntryService.save(selectedEntry);
clearForm();
refreshGrid();
Notification.show("Entry updated");
}
private void deleteEntry() {
if (selectedEntry == null) return;
timeEntryService.delete(selectedEntry);
clearForm();
refreshGrid();
Notification.show("Entry deleted");
}
private void configureGrid() {
entryGrid.addColumn(TimeEntry::getDate).setHeader("Date");
entryGrid.addColumn(TimeEntry::getStartTime).setHeader("Start");
entryGrid.addColumn(TimeEntry::getEndTime).setHeader("End");
entryGrid.addColumn(TimeEntry::getPauseMinutes).setHeader("Break (min)");
entryGrid.addColumn(TimeEntry::getTargetMinutes).setHeader("Target (min)");
entryGrid.addColumn(timeEntryService::calculateNetWorkMinutes).setHeader("Actual (min)");
entryGrid.addColumn(TimeEntry::getStatus).setHeader("Status");
entryGrid.addColumn(TimeEntry::getComment).setHeader("Comment");
entryGrid.addColumn(timeEntryService::calculateDeviation).setHeader("Deviation (min)");
entryGrid.asSingleSelect().addValueChangeListener(event -> populateForm(event.getValue()));
}
private void refreshGrid() {
entryGrid.setItems(timeEntryService.getEntriesForUser(currentUser));
}
private void populateForm(TimeEntry entry) {
selectedEntry = entry;
if (entry != null) {
datePicker.setValue(entry.getDate());
startTimePicker.setValue(entry.getStartTime());
endTimePicker.setValue(entry.getEndTime());
breakField.setValue((double) entry.getPauseMinutes());
targetField.setValue((double) entry.getTargetMinutes());
statusCombo.setValue(entry.getStatus());
commentArea.setValue(entry.getComment() != null ? entry.getComment() : "");
updateBtn.setEnabled(true);
deleteBtn.setEnabled(true);
saveBtn.setEnabled(false);
} else {
clearForm();
}
}
private void clearForm() {
selectedEntry = null;
datePicker.setValue(LocalDate.now());
startTimePicker.clear();
endTimePicker.clear();
breakField.setValue(30.0);
targetField.setValue(480.0);
statusCombo.setValue(DayStatus.REMOTE);
commentArea.clear();
updateBtn.setEnabled(false);
deleteBtn.setEnabled(false);
toggleSaveButton();
}
private void toggleSaveButton() {
saveBtn.setEnabled(isDateAvailable(datePicker.getValue()));
}
private boolean isDateAvailable(LocalDate date) {
return timeEntryService.getEntriesForUser(currentUser).stream()
.noneMatch(entry -> entry.getDate().equals(date));
}
}

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