Repository: urlaubsverwaltung/urlaubsverwaltung (java)

Issue:
Überstunden: Ohne bearbeiten Kommentar hinzufügen
**Is your feature request related to a problem? Please describe.**

Beim Urlaubsantrag und Krankmeldungen können Kommentare ohne editieren des Urlaubsantrags bzw. Krankmeldung direkt hinzugefügt werden über folgende Funktionalität:

![Image](https://github.com/user-attachments/assets/57685e2a-858b-49b7-924d-67b9a695bd45)

Dieses Verhalten ist auch bei Überstunden gewünscht.


Pull request description:
Add overtime comment directly
closes #5296

<!--

Thanks for contributing to the Urlaubsverwaltung.
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 open a report of a security vulnerability via
[Report a security vulnerability](https://github.com/urlaubsverwaltung/urlaubsverwaltung/security/advisories/new)


# 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 #.

-->


Patch:
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/extension/backup/model/OvertimeCommentActionDTO.java b/src/main/java/org/synyx/urlaubsverwaltung/extension/backup/model/OvertimeCommentActionDTO.java
index ecfb36ff6..430b75028 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/extension/backup/model/OvertimeCommentActionDTO.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/extension/backup/model/OvertimeCommentActionDTO.java
@@ -3,7 +3,7 @@ package org.synyx.urlaubsverwaltung.extension.backup.model;
 import org.synyx.urlaubsverwaltung.overtime.OvertimeCommentAction;
 
 public enum OvertimeCommentActionDTO {
-    CREATED, EDITED;
+    CREATED, EDITED, COMMENTED;
 
     public OvertimeCommentAction toOvertimeCommentAction() {
         return OvertimeCommentAction.valueOf(this.name());
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeCommentAction.java b/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeCommentAction.java
index d64768ff2..fbb153d9b 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeCommentAction.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeCommentAction.java
@@ -8,5 +8,6 @@ package org.synyx.urlaubsverwaltung.overtime;
 public enum OvertimeCommentAction {
 
     CREATED,
-    EDITED
+    EDITED,
+    COMMENTED
 }
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeService.java b/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeService.java
index ec7b7a561..c0fb74435 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeService.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeService.java
@@ -35,6 +35,16 @@ public interface OvertimeService {
      */
     Overtime save(Overtime overtime, Optional<String> comment, Person author);
 
+    /**
+     * Saves a comment for a certain overtime record.
+     *
+     * @param overtime to save the comment for
+     * @param action type of the comment
+     * @param comment comment to save
+     * @param author the author of the comment
+     * @return the saved comment
+     */
+    OvertimeComment saveComment(Overtime overtime, OvertimeCommentAction action, String comment, Person author);
 
     /**
      * Fetch the overtime record for a certain ID.
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeServiceImpl.java b/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeServiceImpl.java
index d76564383..8f6cdc37e 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeServiceImpl.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeServiceImpl.java
@@ -102,6 +102,15 @@ class OvertimeServiceImpl implements OvertimeService {
         return savedOvertime;
     }
 
+    @Override
+    public OvertimeComment saveComment(Overtime overtime, OvertimeCommentAction action, String comment, Person author) {
+
+        final OvertimeComment overtimeComment = new OvertimeComment(author, overtime, action, clock);
+        overtimeComment.setText(comment);
+
+        return overtimeCommentRepository.save(overtimeComment);
+    }
+
     @Override
     public Optional<Overtime> getOvertimeById(Long id) {
         return overtimeRepository.findById(id);
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/overtime/web/OvertimeCommentFormDto.java b/src/main/java/org/synyx/urlaubsverwaltung/overtime/web/OvertimeCommentFormDto.java
new file mode 100644
index 000000000..203162261
--- /dev/null
+++ b/src/main/java/org/synyx/urlaubsverwaltung/overtime/web/OvertimeCommentFormDto.java
@@ -0,0 +1,16 @@
+package org.synyx.urlaubsverwaltung.overtime.web;
+
+import java.io.Serializable;
+
+public class OvertimeCommentFormDto implements Serializable {
+
+    private String text;
+
+    public String getText() {
+        return text;
+    }
+
+    public void setText(String text) {
+        this.text = text;
+    }
+}
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/overtime/web/OvertimeViewController.java b/src/main/java/org/synyx/urlaubsverwaltung/overtime/web/OvertimeViewController.java
index 2407b2b54..9c66db648 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/overtime/web/OvertimeViewController.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/overtime/web/OvertimeViewController.java
@@ -44,6 +44,7 @@ import static java.lang.String.format;
 import static java.time.temporal.TemporalAdjusters.lastDayOfYear;
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationStatus.activeStatuses;
 import static org.synyx.urlaubsverwaltung.application.vacationtype.VacationCategory.OVERTIME;
+import static org.synyx.urlaubsverwaltung.overtime.OvertimeCommentAction.COMMENTED;
 import static org.synyx.urlaubsverwaltung.overtime.web.OvertimeListMapper.mapToDto;
 import static org.synyx.urlaubsverwaltung.person.Role.BOSS;
 import static org.synyx.urlaubsverwaltung.person.Role.DEPARTMENT_HEAD;
@@ -160,11 +161,11 @@ public class OvertimeViewController implements HasLaunchpad {
         final int currentYear = Year.now(clock).getValue();
         model.addAttribute("currentYear", currentYear);
 
-
         model.addAttribute("record", overtimeDetailsDto.getRecord());
         model.addAttribute("comments", overtimeDetailsDto.getComments());
         model.addAttribute("overtimeTotal", overtimeDetailsDto.getOvertimeTotal());
         model.addAttribute("overtimeLeft", overtimeDetailsDto.getOvertimeLeft());
+        model.addAttribute("comment", new OvertimeCommentFormDto());
         model.addAttribute("userIsAllowedToWriteOvertime", overtimeService.isUserIsAllowedToWriteOvertime(signedInUser, person));
         model.addAttribute("departmentsOfPerson", departmentService.getAssignedDepartmentsOfMember(person));
 
@@ -276,6 +277,27 @@ public class OvertimeViewController implements HasLaunchpad {
         return "redirect:/web/overtime/" + id;
     }
 
+    @PostMapping("/overtime/{id}/comment")
+    public String addComment(
+        @PathVariable("id") Long overtimeId,
+        @ModelAttribute("comment") OvertimeCommentFormDto comment
+    ) throws UnknownOvertimeException {
+
+        final Overtime overtime = overtimeService.getOvertimeById(overtimeId).orElseThrow(() -> new UnknownOvertimeException(overtimeId));
+        final Person signedInUser = personService.getSignedInUser();
+        final Person person = overtime.getPerson();
+
+        if (!overtimeService.isUserIsAllowedToWriteOvertime(signedInUser, person)) {
+            throw new AccessDeniedException(format(
+                "User '%s' has not the correct permissions to add overtime comment of user '%s'",
+                signedInUser.getId(), person.getId()));
+        }
+
+        overtimeService.saveComment(overtime, COMMENTED, comment.getText(), signedInUser);
+
+        return "redirect:/web/overtime/" + overtime.getId();
+    }
+
     private void prepareModelForCreation(Model model, Person signedInUser, Person person, OvertimeForm overtimeForm) {
 
         if (signedInUser.hasRole(DEPARTMENT_HEAD) || signedInUser.hasRole(SECOND_STAGE_AUTHORITY)) {
diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties
index 22580040f..dc28b7d17 100644
--- a/src/main/resources/messages.properties
+++ b/src/main/resources/messages.properties
@@ -665,6 +665,7 @@ overtime.person.total.2=Überstunden eingetragen.
 overtime.person.zero=keine
 overtime.progress.CREATED=eingetragen am
 overtime.progress.EDITED=bearbeitet am
+overtime.progress.COMMENTED=kommentiert am
 overtime.progress.comment=mit Kommentar:
 overtime.progress.deleted-author=Gelöschte Person
 overtime.progress.lastEdited=zuletzt bearbeitet am
diff --git a/src/main/resources/messages_de_AT.properties b/src/main/resources/messages_de_AT.properties
index 5cac1a05b..aaf5ab523 100644
--- a/src/main/resources/messages_de_AT.properties
+++ b/src/main/resources/messages_de_AT.properties
@@ -662,6 +662,7 @@ overtime.person.left.2=Überstunden abgebaut werden.
 overtime.person.total.1=In diesem Jahr
 overtime.person.total.2=Überstunden eingetragen.
 overtime.person.zero=keine
+overtime.progress.COMMENTED=kommentiert am
 overtime.progress.CREATED=eingetragen am
 overtime.progress.EDITED=bearbeitet am
 overtime.progress.comment=mit Kommentar:
diff --git a/src/main/resources/messages_el.properties b/src/main/resources/messages_el.properties
index cba3f928a..8718bac52 100644
--- a/src/main/resources/messages_el.properties
+++ b/src/main/resources/messages_el.properties
@@ -662,6 +662,7 @@ overtime.person.left.2=υπερωρίες που μπορούν να μειωθ
 overtime.person.total.1=Αυτό το έτος
 overtime.person.total.2=έχει μπει υπερωρία.
 overtime.person.zero=όχι
+overtime.progress.COMMENTED=σχολιάστηκε στις
 overtime.progress.CREATED=Δημιουργήθηκε στις
 overtime.progress.EDITED=Επεξεργάστηκε στις
 overtime.progress.comment=με σχόλιο:
diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties
index 7b62e7c50..63b79ba2c 100644
--- a/src/main/resources/messages_en.properties
+++ b/src/main/resources/messages_en.properties
@@ -662,6 +662,7 @@ overtime.person.left.2=overtime that can be cleared.
 overtime.person.total.1=This year
 overtime.person.total.2=overtime has been entered.
 overtime.person.zero=no
+overtime.progress.COMMENTED=commented on
 overtime.progress.CREATED=Created on
 overtime.progress.EDITED=Edited on
 overtime.progress.comment=with comment:
diff --git a/src/main/resources/templates/overtime/overtime_details.html b/src/main/resources/templates/overtime/overtime_details.html
index c6767f1f6..2946e0ed6 100644
--- a/src/main/resources/templates/overtime/overtime_details.html
+++ b/src/main/resources/templates/overtime/overtime_details.html
@@ -107,10 +107,23 @@
             </tr>
           </table>
 
-          <div th:replace="~{fragments/section-heading::section-heading(~{::overtime-progress-heading-body}, ~{})}">
+          <div
+            th:replace="~{fragments/section-heading::section-heading(~{::overtime-progress-heading-body}, ~{::overtime-progress-heading-actions})}"
+          >
             <th:block th:fragment="overtime-progress-heading-body">
               <h2 th:text="#{overtime.progress.title}"></h2>
             </th:block>
+            <th:block th:ref="overtime-progress-heading-actions">
+              <th:block th:if="${userIsAllowedToWriteOvertime}">
+                <button
+                  class="icon-link tw-bg-transparent tw-px-1 tw-py-0"
+                  onclick="document.getElementById('comment-form').classList.remove('tw-hidden');"
+                >
+                  <svg th:replace="~{icon/message-square::svg(className='tw-w-5 tw-h-5')}"></svg>&nbsp;
+                  <span th:text="#{action.comment.new}"></span>
+                </button>
+              </th:block>
+            </th:block>
           </div>
           <table class="list-table striped-table bordered-table tw-mb-8 tw-text-sm">
             <caption class="tw-sr-only" th:text="#{overtime.progress.title}">
@@ -147,6 +160,30 @@
               </tr>
             </tbody>
           </table>
+
+          <form
+            th:if="${userIsAllowedToWriteOvertime}"
+            id="comment-form"
+            method="post"
+            th:action="@{/web/overtime/__${record.id}__/comment}"
+            th:object="${comment}"
+            class="tw-hidden"
+          >
+            <div th:if="${#fields.hasGlobalErrors()}" class="feedback">
+              <div th:text="#{application.action.reason.error}" class="alert alert-danger"></div>
+            </div>
+
+            <textarea th:field="*{text}" class="form-control" th:errorclass="error" rows="2"></textarea>
+            <div class="tw-flex tw-justify-between tw-mt-2 tw-mb-6">
+              <button
+                th:text="#{action.cancel}"
+                class="button"
+                type="button"
+                onclick="document.getElementById('comment-form').classList.add('tw-hidden');"
+              ></button>
+              <button th:text="#{action.save}" class="button-main-green tw-w-56" type="submit"></button>
+            </div>
+          </form>
         </div>
 
         <div class="tw-mb-16">


Test patch:
diff --git a/src/test/java/org/synyx/urlaubsverwaltung/overtime/OvertimeServiceImplTest.java b/src/test/java/org/synyx/urlaubsverwaltung/overtime/OvertimeServiceImplTest.java
index a5aa00ff7..f9ac8d585 100644
--- a/src/test/java/org/synyx/urlaubsverwaltung/overtime/OvertimeServiceImplTest.java
+++ b/src/test/java/org/synyx/urlaubsverwaltung/overtime/OvertimeServiceImplTest.java
@@ -589,6 +589,33 @@ class OvertimeServiceImplTest {
         inOrder.verify(overtimeRepository).deleteByPerson(person);
     }
 
+    @Test
+    void ensuresToSaveComment() {
+
+        final LocalDate from = LocalDate.now(clock).withMonth(AUGUST.getValue()).with(firstDayOfMonth());
+        final LocalDate to = LocalDate.now(clock).withMonth(AUGUST.getValue()).with(lastDayOfMonth());
+
+        final Person person = new Person();
+        person.setId(1L);
+
+        final Overtime overtime = new Overtime();
+        overtime.setPerson(person);
+        overtime.setStartDate(from);
+        overtime.setEndDate(to);
+        overtime.setDuration(Duration.ofHours(1));
+
+        sut.saveComment(overtime, OvertimeCommentAction.COMMENTED, "Foo Bar", person);
+
+        final ArgumentCaptor<OvertimeComment> commentCaptor = ArgumentCaptor.forClass(OvertimeComment.class);
+        verify(overtimeCommentRepository).save(commentCaptor.capture());
+
+        final OvertimeComment comment = commentCaptor.getValue();
+        assertThat(comment.getText()).isEqualTo("Foo Bar");
+        assertThat(comment.getAction()).isEqualTo(OvertimeCommentAction.COMMENTED);
+        assertThat(comment.getPerson()).isEqualTo(person);
+        assertThat(comment.getOvertime()).isEqualTo(overtime);
+    }
+
     private Settings overtimeSettings(boolean overtimeWritePrivilegedOnly) {
 
         final Settings settings = new Settings();
diff --git a/src/test/java/org/synyx/urlaubsverwaltung/overtime/web/OvertimeViewControllerTest.java b/src/test/java/org/synyx/urlaubsverwaltung/overtime/web/OvertimeViewControllerTest.java
index b30f3c4a1..95a4449af 100644
--- a/src/test/java/org/synyx/urlaubsverwaltung/overtime/web/OvertimeViewControllerTest.java
+++ b/src/test/java/org/synyx/urlaubsverwaltung/overtime/web/OvertimeViewControllerTest.java
@@ -53,6 +53,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
 import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
@@ -60,6 +61,7 @@ import static org.synyx.urlaubsverwaltung.TestDataCreator.createApplication;
 import static org.synyx.urlaubsverwaltung.TestDataCreator.createVacationType;
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationStatus.activeStatuses;
 import static org.synyx.urlaubsverwaltung.application.vacationtype.VacationCategory.OVERTIME;
+import static org.synyx.urlaubsverwaltung.overtime.OvertimeCommentAction.COMMENTED;
 import static org.synyx.urlaubsverwaltung.overtime.OvertimeCommentAction.CREATED;
 import static org.synyx.urlaubsverwaltung.period.DayLength.FULL;
 import static org.synyx.urlaubsverwaltung.person.Role.DEPARTMENT_HEAD;
@@ -987,6 +989,63 @@ class OvertimeViewControllerTest {
         )).hasCause(new AccessDeniedException("User '1' has not the correct permissions to edit overtime record of user '4'"));
     }
 
+    @Test
+    void addCommentWithUnknownOvertimeIdThrowsUnknownOvertimeForLeaveException() {
+
+        assertThatThrownBy(() ->
+            perform(post("/web/overtime/12345/comment"))
+        ).hasCauseInstanceOf(UnknownOvertimeException.class);
+    }
+
+    @Test
+    void addCommentThrowsAccessDeniedIfPersonHasNotEnoughPermissions() {
+
+        final Person signedInPerson = new Person();
+        signedInPerson.setId(1L);
+        signedInPerson.setPermissions(List.of(USER));
+        when(personService.getSignedInUser()).thenReturn(signedInPerson);
+
+        final Person overtimePerson = new Person();
+        overtimePerson.setId(4L);
+
+        final Overtime overtime = new Overtime(overtimePerson, LocalDate.of(2019, 7, 2), LocalDate.of(2019, 7, 2), ofHours(10));
+        overtime.setId(2L);
+        when(overtimeService.getOvertimeById(2L)).thenReturn(Optional.of(overtime));
+
+        when(overtimeService.isUserIsAllowedToWriteOvertime(signedInPerson, overtimePerson)).thenReturn(false);
+
+        assertThatThrownBy(() ->
+            perform(post("/web/overtime/2/comment"))
+        ).hasCauseInstanceOf(AccessDeniedException.class);
+    }
+
+    @Test
+    void addCommentCreatesAnComment() throws Exception {
+
+        final Person signedInPerson = new Person();
+        signedInPerson.setId(1L);
+        signedInPerson.setPermissions(List.of(OFFICE));
+        when(personService.getSignedInUser()).thenReturn(signedInPerson);
+
+        final Person overtimePerson = new Person();
+        overtimePerson.setId(4L);
+
+        final Overtime overtime = new Overtime(overtimePerson, LocalDate.of(2019, 7, 2), LocalDate.of(2019, 7, 2), ofHours(10));
+        overtime.setId(2L);
+        when(overtimeService.getOvertimeById(2L)).thenReturn(Optional.of(overtime));
+
+        when(overtimeService.isUserIsAllowedToWriteOvertime(signedInPerson, overtimePerson)).thenReturn(true);
+
+        perform(
+            post("/web/overtime/2/comment")
+                .param("text", "comment")
+        )
+            .andExpect(status().isFound())
+            .andExpect(redirectedUrl("/web/overtime/2"));
+
+        verify(overtimeService).saveComment(overtime, COMMENTED, "comment", signedInPerson);
+    }
+
     private void mockSettings() {
         final Settings settings = new Settings();
         when(settingsService.getSettings()).thenReturn(settings);