Skip to content

Commit

Permalink
add aggregated info of selected users to report view (#906)
Browse files Browse the repository at this point in the history
closes #878

TODOs:
- [x] Ausblenden wenn die Ansicht nur für mich ist
- [x] Umsetzung der Wochen-Ansicht
- [x] angezeigte Zahlen auf Korrektheit verifizieren
  - aktuell wird mehr SOLL als GEPLANT angezeigt
  - SOLL (should): geplante Arbeitszeit abzüglich Abwesenheiten
- GEPLANT (planned): geplante Arbeitszeit. z. B. in einer Woche 40h bei
5 Tagen a 8h.
- ~~[ ] wenn im Graphen ein Tag ausgewählt wird, wie soll sich die
Tabelle verhalten?~~ (#911)
  - ~~Information nur für den Tag anzeigen?~~
  - ~~Zusätzlich die Information für diesen Tag anzeigen?~~

Here are some things you should have thought about:

**Multi-Tenancy**
- [ ] Extended new entities with `AbstractTenantAwareEntity`?
- [ ] New entity added to `TenantAwareDatabaseConfiguration`?
- [ ] Tested with `dev-multitenant` profile?

<!--

Thanks for contributing to the zeiterfassung.
Please review the following notes before submitting you pull request.

Please look for other issues or pull requests which already work on this
topic. Is somebody already on it? Do you need to synchronize?

# Security Vulnerabilities

🛑 STOP! 🛑 If your contribution fixes a security vulnerability, please do
not submit it.
Instead, please write an E-Mail to [email protected] with all the
information
to recreate the security vulnerability.

# Describing Your Changes

If, having reviewed the notes above, you're ready to submit your pull
request, please
provide a brief description of the proposed changes.

If they:
🐞 fix a bug, please describe the broken behaviour and how the changes
fix it.
    Please label with 'type: bug' and 'status: new'
    
🎁 make an enhancement, please describe the new functionality and why you
believe it's useful.
    Please label with 'type: enhancement' and 'status: new'
 
If your pull request relates to any existing issues,
please reference them by using the issue number prefixed with #.

-->
  • Loading branch information
derTobsch authored Nov 15, 2024
2 parents 3eeec21 + 010b15e commit 8f9a08c
Show file tree
Hide file tree
Showing 22 changed files with 816 additions and 240 deletions.
54 changes: 54 additions & 0 deletions src/main/css/reports.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,60 @@
display: none;
}

.report-person-detail-table {
width: 100%;
@apply text-sm;

th,
td {
@apply px-2;
@apply py-1;
&:last-of-type {
@apply pr-4;
}
}

thead {
th {
@apply font-medium;
@apply text-sm;
}
}

tbody {
tr {
.report-person-detail-table__avatar {
@apply text-blue-100;
@apply transition-colors;
}
> * {
@apply bg-transparent;
@apply transition-colors;
}
> *:first-child {
@apply rounded-l-2xl;
}
> *:last-child {
@apply rounded-r-2xl;
}
th {
@apply font-normal;
@apply text-left;
@apply sticky;
@apply left-0;
}
&:hover {
> * {
@apply bg-blue-50;
}
.report-person-detail-table__avatar {
@apply text-blue-200;
}
}
}
}
}

@screen xxs {
.report-actions {
grid-template-columns: auto;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package de.focusshift.zeiterfassung.report;

import de.focusshift.zeiterfassung.timeentry.ShouldWorkingHours;
import de.focusshift.zeiterfassung.timeentry.WorkDuration;
import de.focusshift.zeiterfassung.workingtime.ZeitDuration;

import java.time.Duration;

/**
* Describes the difference between {@linkplain WorkDuration} and {@linkplain ShouldWorkingHours}.
*
* <p>
* Example: {@code WorkDuration(7h) - ShouldWorkingHours(8h) = DeltaWorkingHours(-1h)}
*
* @param duration duration value of the delta
*/
record DeltaWorkingHours(Duration duration) implements ZeitDuration {

public static final DeltaWorkingHours ZERO = new DeltaWorkingHours(Duration.ZERO);
public static final DeltaWorkingHours EIGHT_POSITIVE = new DeltaWorkingHours(Duration.ofHours(8));
public static final DeltaWorkingHours EIGHT_NEGATIVE = new DeltaWorkingHours(Duration.ofHours(8).negated());

public boolean isNegative() {
return duration.isNegative();
}

/**
* Returns a {@linkplain DeltaWorkingHours} whose value is {@code (this + augend)}.
*
* <p>
* This instance is immutable and unaffected by this method call.
*
* @param augend value to add, not null
* @return a {@linkplain DeltaWorkingHours} whose value is {@code (this + augend)}
* @throws ArithmeticException if numeric overflow occurs
*/
public DeltaWorkingHours plus(DeltaWorkingHours augend) {
return new DeltaWorkingHours(duration().plus(augend.duration()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package de.focusshift.zeiterfassung.report;

import de.focusshift.zeiterfassung.timeentry.ShouldWorkingHours;
import de.focusshift.zeiterfassung.timeentry.WorkDuration;
import de.focusshift.zeiterfassung.user.UserIdComposite;
import de.focusshift.zeiterfassung.workingtime.PlannedWorkingHours;

import java.util.Map;

import static java.util.stream.Collectors.toMap;

interface HasWorkDurationByUser {

Map<UserIdComposite, WorkDuration> workDurationByUser();

Map<UserIdComposite, ShouldWorkingHours> shouldWorkingHoursByUser();

Map<UserIdComposite, PlannedWorkingHours> plannedWorkingHoursByUser();

/**
* Returns the difference between {@linkplain ShouldWorkingHours} and the {@linkplain WorkDuration}of every user in this week.
*
* @return Map of delta duration for every user in this week
*/
default Map<UserIdComposite, DeltaWorkingHours> deltaDurationByUser() {

final Map<UserIdComposite, WorkDuration> workedByUser = workDurationByUser();

return shouldWorkingHoursByUser().entrySet().stream().collect(toMap(
Map.Entry::getKey,
entry -> new DeltaWorkingHours(workedByUser.get(entry.getKey()).duration().minus(entry.getValue().duration()))
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import de.focusshift.zeiterfassung.user.DateFormatter;
import de.focusshift.zeiterfassung.user.DateRangeFormatter;
import de.focusshift.zeiterfassung.user.UserId;
import de.focusshift.zeiterfassung.user.UserIdComposite;
import de.focusshift.zeiterfassung.usermanagement.User;
import de.focusshift.zeiterfassung.usermanagement.UserLocalId;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
Expand All @@ -21,22 +22,25 @@
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoField;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import static java.util.function.Function.identity;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toMap;

@Component
class ReportControllerHelper {

private final ReportPermissionService reportPermissionService;
private final DateFormatter dateFormatter;
private final DateRangeFormatter dateRangeFormatter;

ReportControllerHelper(ReportPermissionService reportPermissionService, DateFormatter dateFormatter, DateRangeFormatter dateRangeFormatter) {
this.reportPermissionService = reportPermissionService;
ReportControllerHelper(DateFormatter dateFormatter, DateRangeFormatter dateRangeFormatter) {
this.dateFormatter = dateFormatter;
this.dateRangeFormatter = dateRangeFormatter;
}
Expand All @@ -45,25 +49,69 @@ UserId principalToUserId(OidcUser principal) {
return new UserId(principal.getUserInfo().getSubject());
}

void addUserFilterModelAttributes(Model model, boolean allUsersSelected, List<UserLocalId> selectedUserLocalIds, String userReportFilterUrl) {
void addUserFilterModelAttributes(Model model, boolean allUsersSelected, List<User> users, List<UserLocalId> selectedUserLocalIds, String userReportFilterUrl) {

final List<User> permittedUsers = reportPermissionService.findAllPermittedUsersForCurrentUser();
if (permittedUsers.size() > 1) {
final List<SelectableUserDto> selectableUserDtos = permittedUsers
.stream()
.map(user -> userToSelectableUserDto(user, selectedUserLocalIds.contains(user.userLocalId())))
.toList();
final List<SelectableUserDto> selectableUserDtos = users
.stream()
.map(user -> userToSelectableUserDto(user, selectedUserLocalIds.contains(user.userLocalId())))
.sorted(Comparator.comparing(SelectableUserDto::fullName))
.toList();

if (users.size() > 1) {
model.addAttribute("users", selectableUserDtos);
model.addAttribute("usersById", selectableUserDtos.stream().collect(toMap(SelectableUserDto::id, identity())));
model.addAttribute("selectedUsers", selectableUserDtos.stream().filter(SelectableUserDto::selected).toList());
model.addAttribute("selectedUserIds", selectedUserLocalIds.stream().map(UserLocalId::value).toList());
model.addAttribute("allUsersSelected", allUsersSelected);
model.addAttribute("userReportFilterUrl", userReportFilterUrl);
}
}

void addSelectedUserDurationAggregationModelAttributes(Model model, boolean allUsersSelected, List<User> users, List<UserLocalId> selectedUserLocalIds, HasWorkDurationByUser report) {

final List<User> usersToShowInTable = getSelectedUsers(allUsersSelected, users, selectedUserLocalIds)
.stream()
.sorted(Comparator.comparing(User::fullName))
.toList();

final Map<UserIdComposite, WorkDuration> workedByUser = report.workDurationByUser();
final Map<UserIdComposite, ShouldWorkingHours> shouldByUser = report.shouldWorkingHoursByUser();
final Map<UserIdComposite, DeltaWorkingHours> deltaByUser = report.deltaDurationByUser();

final boolean showAggregatedInformation = report.deltaDurationByUser().size() > 1;

if (showAggregatedInformation) {

final List<ReportSelectedUserDurationAggregationDto> dtos = new ArrayList<>();

for (User user : usersToShowInTable) {
final UserIdComposite userIdComposite = user.userIdComposite();
final DeltaWorkingHours delta = deltaByUser.get(userIdComposite);
final ReportSelectedUserDurationAggregationDto dto = new ReportSelectedUserDurationAggregationDto(
userIdComposite.localId().value(),
user.fullName(),
durationToTimeString(delta.durationInMinutes()),
delta.isNegative(),
durationToTimeString(workedByUser.get(userIdComposite).durationInMinutes()),
durationToTimeString(shouldByUser.get(userIdComposite).durationInMinutes())
);
dtos.add(dto);
}

model.addAttribute("selectedUserDurationAggregation", dtos);
}
}

private List<User> getSelectedUsers(boolean allUsersSelected, List<User> users, List<UserLocalId> selectedUserLocalIds) {
if (allUsersSelected) {
return users;
} else {
return users.stream().filter(user -> selectedUserLocalIds.contains(user.userLocalId())).toList();
}
}

private static SelectableUserDto userToSelectableUserDto(User user, boolean selected) {
return new SelectableUserDto(user.userLocalId().value(), user.givenName() + " " + user.familyName(), selected);
return new SelectableUserDto(user.userLocalId().value(), user.fullName(), selected);
}

GraphWeekDto toGraphWeekDto(ReportWeek reportWeek, Month monthPivot) {
Expand Down
64 changes: 43 additions & 21 deletions src/main/java/de/focusshift/zeiterfassung/report/ReportDay.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,36 @@
import de.focusshift.zeiterfassung.timeentry.ShouldWorkingHours;
import de.focusshift.zeiterfassung.timeentry.WorkDuration;
import de.focusshift.zeiterfassung.user.UserIdComposite;
import de.focusshift.zeiterfassung.usermanagement.UserLocalId;
import de.focusshift.zeiterfassung.workingtime.PlannedWorkingHours;
import de.focusshift.zeiterfassung.workingtime.WorkingTimeCalendar;

import java.time.LocalDate;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.stream.Stream;

import static java.util.stream.Collectors.toMap;

/**
* Report information for a certain date and users.
*
* <p>
* All byUser Maps contains values for the same keys. (Please ensure this on constructing this object.)
*
* @param date
* @param workingTimeCalendarByUser {@linkplain WorkingTimeCalendar} for all relevant users
* @param reportDayEntriesByUser {@linkplain ReportDayEntry entries} for all relevant users
* @param detailDayAbsencesByUser {@linkplain ReportDayAbsence absences} for all relevant users
*/
record ReportDay(
LocalDate date,
Map<UserIdComposite, WorkingTimeCalendar> workingTimeCalendarByUser,
Map<UserIdComposite, List<ReportDayEntry>> reportDayEntriesByUser,
Map<UserIdComposite, List<ReportDayAbsence>> detailDayAbsencesByUser
) {
) implements HasWorkDurationByUser {

public List<ReportDayEntry> reportDayEntries() {
return reportDayEntriesByUser.values().stream().flatMap(Collection::stream).toList();
Expand All @@ -33,17 +45,26 @@ public PlannedWorkingHours plannedWorkingHours() {
.reduce(PlannedWorkingHours.ZERO, PlannedWorkingHours::plus);
}

public Map<UserIdComposite, PlannedWorkingHours> plannedWorkingHoursByUser() {
return workingTimeCalendarByUser.entrySet().stream().collect(toMap(
Map.Entry::getKey,
entry -> entry.getValue().plannedWorkingHours(date).orElse(PlannedWorkingHours.ZERO)
));
}

public ShouldWorkingHours shouldWorkingHours() {
return workingTimeCalendarByUser.values().stream()
.map(calendar -> calendar.shouldWorkingHours(date))
.flatMap(Optional::stream)
.reduce(ShouldWorkingHours.ZERO, ShouldWorkingHours::plus);
}

public PlannedWorkingHours plannedWorkingHoursByUser(UserLocalId userLocalId) {
return findValueByFirstKeyMatch(workingTimeCalendarByUser, userIdComposite -> userLocalId.equals(userIdComposite.localId()))
.flatMap(calendar -> calendar.plannedWorkingHours(date))
.orElse(PlannedWorkingHours.ZERO);
public Map<UserIdComposite, ShouldWorkingHours> shouldWorkingHoursByUser() {
return workingTimeCalendarByUser.entrySet().stream()
.collect(toMap(
Map.Entry::getKey,
entry -> entry.getValue().shouldWorkingHours(date).orElse(ShouldWorkingHours.ZERO))
);
}

public WorkDuration workDuration() {
Expand All @@ -55,26 +76,27 @@ public WorkDuration workDuration() {
return calculateWorkDurationFrom(allReportDayEntries);
}

public WorkDuration workDurationByUser(UserLocalId userLocalId) {
return workDurationByUserPredicate(userIdComposite -> userLocalId.equals(userIdComposite.localId()));
}
public Map<UserIdComposite, WorkDuration> workDurationByUser() {

final HashMap<UserIdComposite, WorkDuration> workDurationByUser = new HashMap<>();

for (Map.Entry<UserIdComposite, List<ReportDayEntry>> entry : reportDayEntriesByUser.entrySet()) {
final UserIdComposite id = entry.getKey();
final List<ReportDayEntry> reportDayEntries = entry.getValue();

private WorkDuration workDurationByUserPredicate(Predicate<UserIdComposite> predicate) {
final List<ReportDayEntry> reportDayEntries = findValueByFirstKeyMatch(reportDayEntriesByUser, predicate).orElse(List.of());
return calculateWorkDurationFrom(reportDayEntries.stream());
final WorkDuration workDuration = reportDayEntries.stream()
.map(ReportDayEntry::workDuration)
.reduce(WorkDuration.ZERO, WorkDuration::plus);

workDurationByUser.put(id, workDuration);
}

return workDurationByUser;
}

private WorkDuration calculateWorkDurationFrom(Stream<ReportDayEntry> reportDayEntries) {
return reportDayEntries
.map(ReportDayEntry::workDuration)
.reduce(WorkDuration.ZERO, WorkDuration::plus);
}

private <K, T> Optional<T> findValueByFirstKeyMatch(Map<K, T> map, Predicate<K> predicate) {
return map.entrySet()
.stream()
.filter(entry -> predicate.test(entry.getKey()))
.findFirst()
.map(Map.Entry::getValue);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
import java.time.Duration;
import java.time.ZonedDateTime;

/**
* Provides {@linkplain de.focusshift.zeiterfassung.timeentry.TimeEntry} information for reports.
*
* @param user user the entry belongs to
* @param comment comment of the day entry, never {@code null}
* @param start start timestamp fo the entry
* @param end end timestamp of the entry
* @param isBreak whether the entry is a break or not
*/
record ReportDayEntry(
User user,
String comment,
Expand Down
Loading

0 comments on commit 8f9a08c

Please sign in to comment.