16 Commits

Author SHA1 Message Date
Nils Burghardt
0cd0f5f4df change runner 2025-07-21 14:18:03 +02:00
Nils Burghardt
e2e832cfcf add restart action 2025-07-21 14:09:13 +02:00
Nils Burghardt
ea9ceaa57e remove unused method 2025-07-21 12:51:38 +02:00
Nils Burghardt
68ab7f49f1 add key figures 2025-07-21 12:32:24 +02: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
42 changed files with 4138 additions and 134 deletions

View File

@@ -0,0 +1,38 @@
name: Build-und-Deploy
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: 📥 Repository auschecken
uses: actions/checkout@v3
- 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: |
mkdir -p /shared_jar_data /shared_jar_data/archive
# Zeitstempel im Format YYYYMMDD-HHMMSS
timestamp=$(date +"%Y%m%d-%H%M%S")
# JAR-Datei kopieren (aktuelle Version)
cp build/libs/MyTimeTracker-0.0.1-SNAPSHOT.jar /shared_jar_data/MyTimeTracker-0.0.1-SNAPSHOT.jar
# 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 /pfad/zum/docker-verzeichnis
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

@@ -32,10 +32,15 @@ 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'
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 +55,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"
}
}

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,32 @@
: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 */
/* 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);
/* Sekundärfarben / Akzente */
--lumo-success-color: hsl(145, 80%, 35%);
--lumo-error-color: hsl(0, 80%, 60%);
--lumo-border-radius: 6px;
}
/* Hintergrundfarbe für Schaltflächen */
[theme~="primary"] {
background-color: var(--lumo-primary-color);
color: white;
}
vaadin-button {
border-radius: var(--lumo-border-radius);
}
/* Grid-Styling */
vaadin-grid {
border-radius: var(--lumo-border-radius);
background-color: var(--lumo-base-color);
color: var(--lumo-body-text-color);
}
:root {
--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;
--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%);
--lumo-base-color: #dadcc79e;
--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: #65693fed;
--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%);
}

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,55 @@
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)
private DayStatus status = DayStatus.REMOTE;
private String comment;
}

View File

@@ -1,4 +1,44 @@
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)
private boolean enabled = true;
@Column(nullable = false)
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,81 @@
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 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.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,236 @@
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.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);
Component overtimeChart = createOvertimeLineChart(allEntries, scopedEntries);
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 deviation = new KeyFigureWidget(
"Deviation",
"" + scopedEntries.stream()
.map(entry -> timeEntryService.calculateNetWorkMinutes(entry) - entry.getTargetMinutes())
.reduce(0, Integer::sum)
);
keyFigureLayout.add(deviation, workingDays, remoteDays, officeDays, vacationDays, sickDays);
contentContainer.add(keyFigureLayout);
}
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 +1,239 @@
package de.nilzbu.mytimetracker.ui.view;
public class TimeEntryView {
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;
import java.util.List;
@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;
private final DatePicker fromDate = new DatePicker("From");
private final DatePicker toDate = new DatePicker("To");
private final Button applyFilterBtn = new Button("Apply Filter");
public TimeEntryView(TimeEntryService timeEntryService, UserRepository userRepository) {
this.timeEntryService = timeEntryService;
this.userRepository = userRepository;
setSizeFull();
initializeUser();
configureFormFields();
configureButtons();
configureGrid();
datePicker.addValueChangeListener(e -> toggleSaveButton());
add(buildFormLayout(), buildButtonLayout(), buildDateRangeFilterLayout(), 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 HorizontalLayout buildDateRangeFilterLayout() {
applyFilterBtn.addClickListener(e -> refreshGrid());
HorizontalLayout filterLayout = new HorizontalLayout(fromDate, toDate, applyFilterBtn);
filterLayout.setAlignItems(Alignment.END);
return filterLayout;
}
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.setSizeFull();
entryGrid.addColumn(TimeEntry::getDate).setHeader("Date").setSortable(true);
entryGrid.addColumn(TimeEntry::getStartTime).setHeader("Start").setSortable(true);
entryGrid.addColumn(TimeEntry::getEndTime).setHeader("End").setSortable(true);
entryGrid.addColumn(TimeEntry::getPauseMinutes).setHeader("Break (min)").setSortable(true);
entryGrid.addColumn(TimeEntry::getTargetMinutes).setHeader("Target (min)").setSortable(true);
entryGrid.addColumn(timeEntryService::calculateNetWorkMinutes).setHeader("Actual (min)").setSortable(true);
entryGrid.addColumn(TimeEntry::getStatus).setHeader("Status").setSortable(true);
entryGrid.addColumn(TimeEntry::getComment).setHeader("Comment").setSortable(true);
entryGrid.addColumn(timeEntryService::calculateDeviation).setHeader("Deviation (min)").setSortable(true);
entryGrid.getColumns().forEach(col -> col.setAutoWidth(true));
entryGrid.asSingleSelect().addValueChangeListener(event -> populateForm(event.getValue()));
}
private void refreshGrid() {
List<TimeEntry> entries = timeEntryService.getEntriesForUser(currentUser);
if (fromDate.getValue() != null) {
entries = entries.stream()
.filter(entry -> !entry.getDate().isBefore(fromDate.getValue()))
.toList();
}
if (toDate.getValue() != null) {
entries = entries.stream()
.filter(entry -> !entry.getDate().isAfter(toDate.getValue()))
.toList();
}
entryGrid.setItems(entries);
}
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

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