From 0361bd7bedf6b7053ec5107bb48bb09f0f87ea6d Mon Sep 17 00:00:00 2001 From: nilzbu Date: Sat, 9 Aug 2025 13:50:53 +0200 Subject: [PATCH] input view refactoring --- build.gradle | 4 +- data/zeiterfassungdb.trace.db | 10 +- src/main/frontend/themes/myTheme/styles.css | 99 +++++--- .../nilzbu/mytimetracker/model/TimeEntry.java | 1 + .../de/nilzbu/mytimetracker/model/User.java | 2 + .../mytimetracker/ui/layout/MainLayout.java | 2 +- .../mytimetracker/ui/view/TimeEntryView.java | 239 ------------------ .../timeentry/TimeEntryFormConfigurer.java | 103 ++++++++ .../timeentry/TimeEntryHeaderFilters.java | 152 +++++++++++ .../view/timeentry/TimeEntryValidators.java | 34 +++ .../ui/view/timeentry/TimeEntryView.java | 103 ++++++++ 11 files changed, 472 insertions(+), 277 deletions(-) delete mode 100644 src/main/java/de/nilzbu/mytimetracker/ui/view/TimeEntryView.java create mode 100644 src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryFormConfigurer.java create mode 100644 src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryHeaderFilters.java create mode 100644 src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryValidators.java create mode 100644 src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryView.java diff --git a/build.gradle b/build.gradle index 0f747c0..c181632 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ version = '0.0.1-SNAPSHOT' java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } @@ -22,6 +22,7 @@ configurations { repositories { mavenCentral() + maven { url 'https://maven.vaadin.com/vaadin-addons' } } ext { @@ -32,6 +33,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'com.vaadin:vaadin-spring-boot-starter' + implementation("org.vaadin.crudui:crudui:7.2.0") runtimeOnly 'com.mysql:mysql-connector-j:8.3.0' diff --git a/data/zeiterfassungdb.trace.db b/data/zeiterfassungdb.trace.db index 172c960..88c7d0d 100644 --- a/data/zeiterfassungdb.trace.db +++ b/data/zeiterfassungdb.trace.db @@ -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 jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source) 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.handleDomEvent(ComponentEventBus.java:501) 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 jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source) 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.handleDomEvent(ComponentEventBus.java:501) 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 jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source) 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.handleDomEvent(ComponentEventBus.java:501) 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 jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source) 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.handleDomEvent(ComponentEventBus.java:501) 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 jdk.proxy4/jdk.proxy4.$Proxy164.save(Unknown Source) 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.handleDomEvent(ComponentEventBus.java:501) at com.vaadin.flow.component.ComponentEventBus.lambda$addDomTrigger$dd1b7957$1(ComponentEventBus.java:303) diff --git a/src/main/frontend/themes/myTheme/styles.css b/src/main/frontend/themes/myTheme/styles.css index 9977a70..365a755 100644 --- a/src/main/frontend/themes/myTheme/styles.css +++ b/src/main/frontend/themes/myTheme/styles.css @@ -1,32 +1,69 @@ +: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; - :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%); - } + --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: #dadcc7; + + --lumo-tint-5pct: rgba(101, 105, 63, 0.05); + --lumo-tint-10pct: rgba(101, 105, 63, 0.1); + --lumo-tint-20pct: rgba(101, 105, 63, 0.2); + --lumo-tint-30pct: rgba(101, 105, 63, 0.3); + --lumo-tint-40pct: rgba(101, 105, 63, 0.4); + --lumo-tint-50pct: rgba(101, 105, 63, 0.5); + --lumo-tint-60pct: rgba(101, 105, 63, 0.6); + --lumo-tint-70pct: rgba(101, 105, 63, 0.7); + --lumo-tint-80pct: rgba(101, 105, 63, 0.8); + --lumo-tint-90pct: rgba(101, 105, 63, 0.9); + --lumo-tint: #65693f; + + --lumo-success-text-color: rgb(56, 204, 36); + --lumo-success-color-50pct: rgba(56, 204, 36, 0.5); + --lumo-success-color-10pct: rgba(56, 204, 36, 0.1); + --lumo-success-color: hsl(113, 70%, 47%); + + /* Lumo-Tokens, die IntelliJ anmeckert – mit sinnvollen Defaults */ + --lumo-space-xs: 0.25rem; + --lumo-border-radius-s: 0.25rem; + --lumo-contrast-10pct: rgba(0, 0, 0, 0.06); + --lumo-size-s: 2rem; +} + +/* 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%; +} diff --git a/src/main/java/de/nilzbu/mytimetracker/model/TimeEntry.java b/src/main/java/de/nilzbu/mytimetracker/model/TimeEntry.java index fada340..9416148 100644 --- a/src/main/java/de/nilzbu/mytimetracker/model/TimeEntry.java +++ b/src/main/java/de/nilzbu/mytimetracker/model/TimeEntry.java @@ -49,6 +49,7 @@ public class TimeEntry { */ @Enumerated(EnumType.STRING) @Column(nullable = false) + @Builder.Default private DayStatus status = DayStatus.REMOTE; private String comment; diff --git a/src/main/java/de/nilzbu/mytimetracker/model/User.java b/src/main/java/de/nilzbu/mytimetracker/model/User.java index 6231eab..56c1b2d 100644 --- a/src/main/java/de/nilzbu/mytimetracker/model/User.java +++ b/src/main/java/de/nilzbu/mytimetracker/model/User.java @@ -34,9 +34,11 @@ public class User { private Set roles; // z.B. ROLE_USER, ROLE_ADMIN @Column(nullable = false) + @Builder.Default private boolean enabled = true; @Column(nullable = false) + @Builder.Default private boolean locked = false; @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/src/main/java/de/nilzbu/mytimetracker/ui/layout/MainLayout.java b/src/main/java/de/nilzbu/mytimetracker/ui/layout/MainLayout.java index 573f174..1beaa75 100644 --- a/src/main/java/de/nilzbu/mytimetracker/ui/layout/MainLayout.java +++ b/src/main/java/de/nilzbu/mytimetracker/ui/layout/MainLayout.java @@ -9,7 +9,7 @@ import com.vaadin.flow.router.RouterLink; 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.timeentry.TimeEntryView; import de.nilzbu.mytimetracker.ui.view.UserManagementView; import jakarta.annotation.security.PermitAll; import jakarta.servlet.http.HttpServletRequest; diff --git a/src/main/java/de/nilzbu/mytimetracker/ui/view/TimeEntryView.java b/src/main/java/de/nilzbu/mytimetracker/ui/view/TimeEntryView.java deleted file mode 100644 index 6ff60ca..0000000 --- a/src/main/java/de/nilzbu/mytimetracker/ui/view/TimeEntryView.java +++ /dev/null @@ -1,239 +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; -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 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 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 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)); - } -} \ No newline at end of file diff --git a/src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryFormConfigurer.java b/src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryFormConfigurer.java new file mode 100644 index 0000000..dcd426b --- /dev/null +++ b/src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryFormConfigurer.java @@ -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 crud, SerializableSupplier newInstanceSupplier) { + CrudFormFactory 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 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); + } +} diff --git a/src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryHeaderFilters.java b/src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryHeaderFilters.java new file mode 100644 index 0000000..8106e2e --- /dev/null +++ b/src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryHeaderFilters.java @@ -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 statusFilter = new ComboBox<>("Status"); + private final TextField searchField = new TextField("Search"); + private final Checkbox onlyWithDeviation = new Checkbox("≠ 0"); + + void configureGridAndFilters(Grid 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 applyFilters(List 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 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; + } +} diff --git a/src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryValidators.java b/src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryValidators.java new file mode 100644 index 0000000..867f340 --- /dev/null +++ b/src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryValidators.java @@ -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."); + } + } +} diff --git a/src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryView.java b/src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryView.java new file mode 100644 index 0000000..27e6bef --- /dev/null +++ b/src/main/java/de/nilzbu/mytimetracker/ui/view/timeentry/TimeEntryView.java @@ -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 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(); + } +} -- 2.49.1