Repository: urlaubsverwaltung/urlaubsverwaltung (java)

Issue:
Urlaubsantrag: Ohne bearbeiten Kommentar hinzufügen
Gesehen in Version 4.9.0

Wird ein bestehender Urlaubsantrag, welcher ein Kommentar enthält, bearbeitet, so verschwindet der Kommentar.

Schritte zum reproduzieren (alles als normaler User):

- einen neuen Urlaubsantrag stellen - dabei im Feld "Kommentar" etwas eingeben
- den Urlaubsantrag bearbeiten
- in der Maske werden die zuvor in "Kommentar" eingegebenen Daten nicht angezeigt
- wird der Urlaubsantrag dann gespeichert, so ist das Kommentar weg

Pull request:
application comment without edit
closes #1766

ToDo:
- [x] ~~ggf direkt bei den Überstunden auch hinzufügen?~~ -> https://github.com/urlaubsverwaltung/urlaubsverwaltung/issues/5296
- [x] Ist "Mit Kommentar zum Verlauf hinzufügen" ok? Ausreichend? Oder doch besser anders?

<!--

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/application/application/ApplicationForLeaveDetailsViewController.java b/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationForLeaveDetailsViewController.java
index 034e5f1e2..3d79ae307 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationForLeaveDetailsViewController.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationForLeaveDetailsViewController.java
@@ -54,6 +54,7 @@ import static org.synyx.urlaubsverwaltung.application.application.ApplicationFor
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationForLeavePermissionEvaluator.isAllowedToAllowWaitingApplication;
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationForLeavePermissionEvaluator.isAllowedToCancelApplication;
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationForLeavePermissionEvaluator.isAllowedToCancelDirectlyApplication;
+import static org.synyx.urlaubsverwaltung.application.application.ApplicationForLeavePermissionEvaluator.isAllowedToCommentApplication;
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationForLeavePermissionEvaluator.isAllowedToDeclineCancellationRequest;
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationForLeavePermissionEvaluator.isAllowedToEditApplication;
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationForLeavePermissionEvaluator.isAllowedToReferApplication;
@@ -64,6 +65,7 @@ import static org.synyx.urlaubsverwaltung.application.application.ApplicationFor
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationStatus.ALLOWED;
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationStatus.TEMPORARY_ALLOWED;
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationStatus.WAITING;
+import static org.synyx.urlaubsverwaltung.application.comment.ApplicationCommentAction.COMMENTED;
 import static org.synyx.urlaubsverwaltung.person.Role.BOSS;
 import static org.synyx.urlaubsverwaltung.person.Role.DEPARTMENT_HEAD;
 import static org.synyx.urlaubsverwaltung.person.Role.OFFICE;
@@ -403,6 +405,39 @@ class ApplicationForLeaveDetailsViewController implements HasLaunchpad {
         return REDIRECT_WEB_APPLICATION + applicationId;
     }
 
+    @PreAuthorize("hasAnyAuthority('OFFICE', 'APPLICATION_ADD')")
+    @PostMapping("/{applicationId}/comment")
+    public String addComment(
+        @PathVariable("applicationId") Long applicationId,
+        @ModelAttribute("comment") ApplicationCommentForm comment, Errors errors, RedirectAttributes redirectAttributes
+    ) throws UnknownApplicationForLeaveException {
+
+        final Application application = applicationService.getApplicationById(applicationId)
+            .orElseThrow(() -> new UnknownApplicationForLeaveException(applicationId));
+
+        final Person person = application.getPerson();
+        final Person signedInUser = personService.getSignedInUser();
+
+        final boolean isDepartmentHead = departmentService.isDepartmentHeadAllowedToManagePerson(signedInUser, person);
+        final boolean isSecondStageAuthority = departmentService.isSecondStageAuthorityAllowedToManagePerson(signedInUser, person);
+        final boolean allowedToCommentApplication = isAllowedToCommentApplication(signedInUser, isDepartmentHead, isSecondStageAuthority);
+        if (!allowedToCommentApplication) {
+            throw new AccessDeniedException(format(
+                "User '%s' has not the correct permissions to comment the application for leave of user '%s'",
+                signedInUser.getId(), application.getPerson().getId()));
+        }
+
+        commentValidator.validate(comment, errors);
+        if (errors.hasErrors()) {
+            redirectAttributes.addFlashAttribute(ATTRIBUTE_ERRORS, errors);
+            return "redirect:/web/application/" + application.getId();
+        }
+
+        commentService.create(application, COMMENTED, Optional.ofNullable(comment.getText()), signedInUser);
+
+        return "redirect:/web/application/" + application.getId();
+    }
+
     private void prepareDetailView(Application application, int year, String action, boolean shortcut, Model model, Locale locale, Person signedInUser) {
 
         // signed in user
@@ -478,6 +513,7 @@ class ApplicationForLeaveDetailsViewController implements HasLaunchpad {
             model.addAttribute("availablePersonsToRefer", getPossibleManagersToRefer(application.getPerson(), signedInUser, application));
             model.addAttribute("referredPerson", new ReferredPerson());
         }
+        model.addAttribute("isAllowedToCommentApplication", isAllowedToCommentApplication(signedInUser, isDepartmentHeadOfPerson, isSecondStageAuthorityOfPerson));
 
         model.addAttribute("isDepartmentHeadOfPerson", isDepartmentHeadOfPerson);
         model.addAttribute("isSecondStageAuthorityOfPerson", isSecondStageAuthorityOfPerson);
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationForLeavePermissionEvaluator.java b/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationForLeavePermissionEvaluator.java
index c8da8e1ff..0ac239bdf 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationForLeavePermissionEvaluator.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/application/application/ApplicationForLeavePermissionEvaluator.java
@@ -6,6 +6,7 @@ import static org.synyx.urlaubsverwaltung.application.application.ApplicationSta
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationStatus.ALLOWED_CANCELLATION_REQUESTED;
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationStatus.TEMPORARY_ALLOWED;
 import static org.synyx.urlaubsverwaltung.application.application.ApplicationStatus.WAITING;
+import static org.synyx.urlaubsverwaltung.person.Role.APPLICATION_ADD;
 import static org.synyx.urlaubsverwaltung.person.Role.APPLICATION_CANCEL;
 import static org.synyx.urlaubsverwaltung.person.Role.APPLICATION_CANCELLATION_REQUESTED;
 import static org.synyx.urlaubsverwaltung.person.Role.BOSS;
@@ -74,4 +75,8 @@ class ApplicationForLeavePermissionEvaluator {
         return (application.hasStatus(WAITING) || application.hasStatus(TEMPORARY_ALLOWED))
             && (signedInUser.hasRole(BOSS) || signedInUser.hasRole(OFFICE) || ((isDepartmentHeadOfPerson || isSecondStageAuthorityOfPerson) && !application.getPerson().equals(signedInUser)));
     }
+
+    static boolean isAllowedToCommentApplication(Person signedInUser, boolean isDepartmentHeadOfPerson, boolean isSecondStageAuthorityOfPerson) {
+        return signedInUser.hasRole(OFFICE) || ((signedInUser.hasRole(BOSS) || isDepartmentHeadOfPerson || isSecondStageAuthorityOfPerson) && signedInUser.hasRole(APPLICATION_ADD));
+    }
 }
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/application/comment/ApplicationCommentAction.java b/src/main/java/org/synyx/urlaubsverwaltung/application/comment/ApplicationCommentAction.java
index 1a6d76921..c7c87fef9 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/application/comment/ApplicationCommentAction.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/application/comment/ApplicationCommentAction.java
@@ -24,6 +24,7 @@ public enum ApplicationCommentAction {
     CANCELLED_DIRECTLY,
     CANCEL_REQUESTED,
     CANCEL_REQUESTED_DECLINED,
+    COMMENTED,
     REVOKED,
     REFERRED,
     EDITED
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/comment/AbstractComment.java b/src/main/java/org/synyx/urlaubsverwaltung/comment/AbstractComment.java
deleted file mode 100644
index 0a615bef8..000000000
--- a/src/main/java/org/synyx/urlaubsverwaltung/comment/AbstractComment.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package org.synyx.urlaubsverwaltung.comment;
-
-import jakarta.persistence.Column;
-import jakarta.persistence.ManyToOne;
-import jakarta.persistence.MappedSuperclass;
-import org.synyx.urlaubsverwaltung.person.Person;
-import org.synyx.urlaubsverwaltung.tenancy.tenant.AbstractTenantAwareEntity;
-
-import java.time.Clock;
-import java.time.Instant;
-
-import static java.time.temporal.ChronoUnit.DAYS;
-import static java.util.Optional.ofNullable;
-
-
-/**
- * Represents a basic comment.
- */
-@MappedSuperclass
-public abstract class AbstractComment extends AbstractTenantAwareEntity {
-
-    // Who has written the comment?
-    @ManyToOne
-    private Person person;
-
-    // When has the comment be written?
-    @Column(nullable = false)
-    private Instant date;
-
-    // What is the content of the comment?
-    private String text;
-
-    protected AbstractComment() {
-        // needed for hibernate
-    }
-
-    protected AbstractComment(Clock clock) {
-        final Clock c = ofNullable(clock).orElse(Clock.systemUTC());
-        this.date = Instant.now(c).truncatedTo(DAYS);
-    }
-
-    public Person getPerson() {
-        return person;
-    }
-
-    public void setPerson(Person person) {
-        this.person = person;
-    }
-
-    public Instant getDate() {
-        if (date == null) {
-            throw new IllegalStateException("Date of comment can never be null!");
-        }
-
-        return date;
-    }
-
-    public String getText() {
-        return text;
-    }
-
-    public void setText(String text) {
-        this.text = text;
-    }
-}
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/extension/backup/model/ApplicationCommentActionDTO.java b/src/main/java/org/synyx/urlaubsverwaltung/extension/backup/model/ApplicationCommentActionDTO.java
index b28211bb8..884b6485b 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/extension/backup/model/ApplicationCommentActionDTO.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/extension/backup/model/ApplicationCommentActionDTO.java
@@ -13,6 +13,7 @@ public enum ApplicationCommentActionDTO {
     CANCELLED_DIRECTLY,
     CANCEL_REQUESTED,
     CANCEL_REQUESTED_DECLINED,
+    COMMENTED,
     REVOKED,
     REFERRED,
     EDITED;
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeComment.java b/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeComment.java
index bd08b3bd4..4610dde5b 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeComment.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/overtime/OvertimeComment.java
@@ -7,14 +7,17 @@ import jakarta.persistence.GeneratedValue;
 import jakarta.persistence.Id;
 import jakarta.persistence.ManyToOne;
 import jakarta.persistence.SequenceGenerator;
-import org.synyx.urlaubsverwaltung.comment.AbstractComment;
 import org.synyx.urlaubsverwaltung.person.Person;
+import org.synyx.urlaubsverwaltung.tenancy.tenant.AbstractTenantAwareEntity;
 
 import java.time.Clock;
+import java.time.Instant;
 import java.util.Objects;
 
 import static jakarta.persistence.EnumType.STRING;
 import static jakarta.persistence.GenerationType.SEQUENCE;
+import static java.time.temporal.ChronoUnit.DAYS;
+import static java.util.Optional.ofNullable;
 
 /**
  * Recorded comment after executed an overtime action, e.g. create a new overtime record.
@@ -22,7 +25,7 @@ import static jakarta.persistence.GenerationType.SEQUENCE;
  * @since 2.11.0
  */
 @Entity
-public class OvertimeComment extends AbstractComment {
+public class OvertimeComment extends AbstractTenantAwareEntity {
 
     @Id
     @Column(name = "id", unique = true, nullable = false, updatable = false)
@@ -37,17 +40,27 @@ public class OvertimeComment extends AbstractComment {
     @Enumerated(STRING)
     private OvertimeCommentAction action;
 
+    @ManyToOne
+    private Person person;
+
+    @Column(nullable = false)
+    private Instant date;
+
+    private String text;
+
     protected OvertimeComment() {
-        super();
+        // needed for hibernate
     }
 
     public OvertimeComment(Clock clock) {
-        super(clock);
+        final Clock c = ofNullable(clock).orElse(Clock.systemUTC());
+        this.date = Instant.now(c).truncatedTo(DAYS);
     }
 
     public OvertimeComment(Person author, Overtime overtime, OvertimeCommentAction action, Clock clock) {
-        super(clock);
-        super.setPerson(author);
+        final Clock c = ofNullable(clock).orElse(Clock.systemUTC());
+        this.date = Instant.now(c).truncatedTo(DAYS);
+        setPerson(author);
 
         this.overtime = overtime;
         this.action = action;
@@ -77,6 +90,26 @@ public class OvertimeComment extends AbstractComment {
         this.action = action;
     }
 
+    public Person getPerson() {
+        return person;
+    }
+
+    public void setPerson(Person person) {
+        this.person = person;
+    }
+
+    public Instant getDate() {
+        return date;
+    }
+
+    public String getText() {
+        return text;
+    }
+
+    public void setText(String text) {
+        this.text = text;
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) {
diff --git a/src/main/java/org/synyx/urlaubsverwaltung/sicknote/comment/SickNoteCommentEntity.java b/src/main/java/org/synyx/urlaubsverwaltung/sicknote/comment/SickNoteCommentEntity.java
index 454274205..0b2d339ae 100644
--- a/src/main/java/org/synyx/urlaubsverwaltung/sicknote/comment/SickNoteCommentEntity.java
+++ b/src/main/java/org/synyx/urlaubsverwaltung/sicknote/comment/SickNoteCommentEntity.java
@@ -5,23 +5,28 @@ import jakarta.persistence.Entity;
 import jakarta.persistence.Enumerated;
 import jakarta.persistence.GeneratedValue;
 import jakarta.persistence.Id;
+import jakarta.persistence.ManyToOne;
 import jakarta.persistence.SequenceGenerator;
 import jakarta.validation.constraints.NotNull;
 import org.hibernate.annotations.OnDelete;
-import org.synyx.urlaubsverwaltung.comment.AbstractComment;
+import org.synyx.urlaubsverwaltung.person.Person;
+import org.synyx.urlaubsverwaltung.tenancy.tenant.AbstractTenantAwareEntity;
 
 import java.time.Clock;
+import java.time.Instant;
 import java.util.Objects;
 
 import static jakarta.persistence.EnumType.STRING;
 import static jakarta.persistence.GenerationType.SEQUENCE;
+import static java.time.temporal.ChronoUnit.DAYS;
+import static java.util.Optional.ofNullable;
 import static org.hibernate.annotations.OnDeleteAction.CASCADE;
 
 /**
  * Comment to a sick note containing detailed information like date of comment or commenting person.
  */
 @Entity(name = "sick_note_comment")
-public class SickNoteCommentEntity extends AbstractComment {
+public class SickNoteCommentEntity extends AbstractTenantAwareEntity {
 
     @Id
     @Column(name = "id", unique = true, nullable = false, updatable = false)
@@ -37,12 +42,21 @@ public class SickNoteCommentEntity extends AbstractComment {
     @Enumerated(STRING)
     private SickNoteCommentAction action;
 
+    @ManyToOne
+    private Person person;
+
+    @Column(nullable = false)
+    private Instant date;
+
+    private String text;
+
     protected SickNoteCommentEntity() {
-        super();
+        // needed for hibernate
     }
 
     public SickNoteCommentEntity(Clock clock) {
-        super(clock);
+        final Clock c = ofNullable(clock).orElse(Clock.systemUTC());
+        this.date = Instant.now(c).truncatedTo(DAYS);
     }
 
     public Long getId() {
@@ -69,6 +83,26 @@ public class SickNoteCommentEntity extends AbstractComment {
         this.action = action;
     }
 
+    public Person getPerson() {
+        return person;
+    }
+
+    public void setPerson(Person person) {
+        this.person = person;
+    }
+
+    public Instant getDate() {
+        return date;
+    }
+
+    public String getText() {
+        return text;
+    }
+
+    public void setText(String text) {
+        this.text = text;
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) {
diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties
index 7e2d1af9f..22580040f 100644
--- a/src/main/resources/messages.properties
+++ b/src/main/resources/messages.properties
@@ -146,7 +146,7 @@ application.applier.none=keine
 application.applier.represents=vertritt
 application.data.days={0, choice, 0#{0} Tage|1#1 Tag|1.5#{0} Tage}
 application.data.furtherInformation.address=Anschrift/Telefon
-application.data.furtherInformation.comment=Kommentar
+application.data.furtherInformation.comment=Mit Kommentar zum Verlauf hinzufügen
 application.data.furtherInformation.notSpecified=Keine Angabe
 application.data.furtherInformation.title=Weitere Informationen
 application.data.header.title.edit=Abwesenheit bearbeiten
@@ -235,6 +235,7 @@ application.progress.CANCELLED=storniert am
 application.progress.CANCELLED_DIRECTLY=storniert am
 application.progress.CANCEL_REQUESTED=Stornierung beantragt am
 application.progress.CANCEL_REQUESTED_DECLINED=Stornierung abgelehnt am
+application.progress.COMMENTED=Kommentar hinzugefügt am
 application.progress.CONVERTED=umgewandelt aus Krankmeldung am
 application.progress.EDITED=bearbeitet am
 application.progress.EXPIRED=verfallen am
@@ -621,7 +622,7 @@ nav.urlaubsverwaltung.title=Urlaubsverwaltung
 nav.user-settings.title=Personalisierung
 nav.user.title=Angemeldet als
 nav.vacation.title=Abwesenheiten
-overtime.data.comment=Kommentar
+overtime.data.comment=Mit Kommentar zum Verlauf hinzufügen
 overtime.data.description.general=Hier kann die Anzahl der geleisteten Überstunden für einen bestimmten Zeitraum eingetragen werden.
 overtime.data.description.overtimeReductionPossible=Mit "Überstunden abbauen statt hinzufügen" kannst du Überstunden abbauen, ohne einen Antrag zu stellen.
 overtime.data.endDate=Bis
@@ -1084,7 +1085,7 @@ sicknote.data.aub.short=AU-Bescheinigung
 sicknote.data.calendar.days={0, choice, 0#{0} Kalendertage|1#1 Kalendertag|1.5#{0} Kalendertage}
 sicknote.data.days={0, choice, 0#{0} Arbeitstage|1#1 Arbeitstag|1.5#{0} Arbeitstage}
 sicknote.data.description=Hinweis: Bei einer halbtägigen Krankmeldung müssen Start- und Enddatum gleich sein.
-sicknote.data.furtherInformation.comment=Kommentar
+sicknote.data.furtherInformation.comment=Mit Kommentar zum Verlauf hinzufügen
 sicknote.data.furtherInformation.description=Weitere Informationen zur Krankmeldung sind optional.
 sicknote.data.furtherInformation.title=Weitere Informationen
 sicknote.data.inactive=(nicht mehr aktiv)
diff --git a/src/main/resources/messages_de_AT.properties b/src/main/resources/messages_de_AT.properties
index ff7fb8743..5cac1a05b 100644
--- a/src/main/resources/messages_de_AT.properties
+++ b/src/main/resources/messages_de_AT.properties
@@ -146,7 +146,7 @@ application.applier.none=keine
 application.applier.represents=vertritt
 application.data.days={0, choice, 0#{0} Tage|1#1 Tag|1.5#{0} Tage}
 application.data.furtherInformation.address=Anschrift/Telefon
-application.data.furtherInformation.comment=Kommentar
+application.data.furtherInformation.comment=Mit Kommentar zum Verlauf hinzufügen
 application.data.furtherInformation.notSpecified=Keine Angabe
 application.data.furtherInformation.title=Weitere Informationen
 application.data.header.title.edit=Abwesenheit bearbeiten
@@ -621,7 +621,7 @@ nav.urlaubsverwaltung.title=Urlaubsverwaltung
 nav.user-settings.title=Personalisierung
 nav.user.title=Angemeldet als
 nav.vacation.title=Abwesenheiten
-overtime.data.comment=Kommentar
+overtime.data.comment=Mit Kommentar zum Verlauf hinzufügen
 overtime.data.description.general=Hier kann die Anzahl der geleisteten Überstunden für einen bestimmten Zeitraum eingetragen werden.
 overtime.data.description.overtimeReductionPossible=Mit "Überstunden abbauen statt hinzufügen" kannst du Überstunden abbauen, ohne einen Antrag zu stellen.
 overtime.data.endDate=Bis
@@ -1084,7 +1084,7 @@ sicknote.data.aub.short=AU-Bescheinigung
 sicknote.data.calendar.days={0, choice, 0#{0} Kalendertage|1#1 Kalendertag|1.5#{0} Kalendertage}
 sicknote.data.days={0, choice, 0#{0} Arbeitstage|1#1 Arbeitstag|1.5#{0} Arbeitstage}
 sicknote.data.description=Hinweis: Bei einer halbtägigen Krankmeldung müssen Start- und Enddatum gleich sein.
-sicknote.data.furtherInformation.comment=Kommentar
+sicknote.data.furtherInformation.comment=Mit Kommentar zum Verlauf hinzufügen
 sicknote.data.furtherInformation.description=Weitere Informationen zur Krankmeldung sind optional.
 sicknote.data.furtherInformation.title=Weitere Informationen
 sicknote.data.inactive=(nicht mehr aktiv)
diff --git a/src/main/resources/messages_el.properties b/src/main/resources/messages_el.properties
index 1b3333653..cba3f928a 100644
--- a/src/main/resources/messages_el.properties
+++ b/src/main/resources/messages_el.properties
@@ -146,7 +146,7 @@ application.applier.none=κανένας
 application.applier.represents=αντιπροσωπεύει
 application.data.days={0, choice, 0#{0} Ημέρες|1#1 Ημέρα|1.5#{0} Ημέρες}
 application.data.furtherInformation.address=Διεύθυνση/αριθμός τηλεφώνου
-application.data.furtherInformation.comment=Σχόλιο
+application.data.furtherInformation.comment=Προσθήκη με σχόλιο στην πρόοδο
 application.data.furtherInformation.notSpecified=Δεν διευκρινίζεται
 application.data.furtherInformation.title=Προαιρετική πληροφορία
 application.data.header.title.edit=Επεξεργασία άδειας
@@ -621,7 +621,7 @@ nav.urlaubsverwaltung.title=Urlaubsverwaltung
 nav.user-settings.title=Ρυθμίσεις λογαριασμού
 nav.user.title=Σύνδεση ώς
 nav.vacation.title=Άδεια
-overtime.data.comment=Σχόλιο
+overtime.data.comment=Προσθήκη με σχόλιο στην πρόοδο
 overtime.data.description.general=Εδώ μπορεί να εισαχθεί ο αριθμός των ωρών εργασίας για μια συγκεκριμένη χρονική περίοδο.
 overtime.data.description.overtimeReductionPossible=Επιλέξτε "μείωση υπερωριών αντί για διακοπή" σε περίπτωση που έχετε εργαστεί υπερωρίες χωρίς να υποβάλετε αίτημα άδειας.
 overtime.data.endDate=Μέχρι
@@ -1084,7 +1084,7 @@ sicknote.data.aub.short=Πιστοποιητικό ανικανότητας πρ
 sicknote.data.calendar.days={0, choice, 0#{0} ημερολογιακές ημέρες|1#1 ημερολογιακή ημέρα|1.5#{0} ημερολογιακές ημέρες}
 sicknote.data.days={0, choice, 0#{0} εργάσιμες ημέρες|1#1 εργάσιμη ημέρα|1.5#{0} εργάσιμες ημέρες}
 sicknote.data.description=Σημείωση: Για ένα σημείωμα ασθενείας μισής ημέρας, οι ημερομηνίες έναρξης και λήξης πρέπει να είναι οι ίδιες.
-sicknote.data.furtherInformation.comment=Σχόλιο
+sicknote.data.furtherInformation.comment=Προσθήκη με σχόλιο στην πρόοδο
 sicknote.data.furtherInformation.description=Περαιτέρω πληροφορίες σχετικά με το σημείωμα ασθενείας είναι προαιρετικές.
 sicknote.data.furtherInformation.title=Περαιτέρω πληροφορίες
 sicknote.data.inactive=(αδρανής)
diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties
index 73e0f2624..7b62e7c50 100644
--- a/src/main/resources/messages_en.properties
+++ b/src/main/resources/messages_en.properties
@@ -146,7 +146,7 @@ application.applier.none=none
 application.applier.represents=represents
 application.data.days={0, choice, 0#{0} days|1#1 day|1.5#{0} days} of holiday
 application.data.furtherInformation.address=Address/phone number
-application.data.furtherInformation.comment=Comment
+application.data.furtherInformation.comment=Add with comment to progress
 application.data.furtherInformation.notSpecified=Not specified
 application.data.furtherInformation.title=Optional information
 application.data.header.title.edit=Edit absence
@@ -621,7 +621,7 @@ nav.urlaubsverwaltung.title=Vacation Management
 nav.user-settings.title=Account settings
 nav.user.title=Signed in as
 nav.vacation.title=Absence
-overtime.data.comment=Comment
+overtime.data.comment=Add with comment to progress
 overtime.data.description.general=Here the number of hours worked can be entered for a certain period of time.
 overtime.data.description.overtimeReductionPossible=Check "reduce overtime instead of inceasing" in case you have worked overtime without making a leave request.
 overtime.data.endDate=Until
@@ -1084,7 +1084,7 @@ sicknote.data.aub.short=Certificate of incapacity for work
 sicknote.data.calendar.days={0, choice, 0#{0} calendar days|1#1 calendar day|1.5#{0} calendar days}
 sicknote.data.days={0, choice, 0#{0} working days|1#1 working day|1.5#{0} working days}
 sicknote.data.description=Note: For a half-day sick note, the start and end dates must be the same.
-sicknote.data.furtherInformation.comment=Comment
+sicknote.data.furtherInformation.comment=Add with comment to progress
 sicknote.data.furtherInformation.description=Further information regarding the sick note are optional.
 sicknote.data.furtherInformation.title=Further information
 sicknote.data.inactive=(inactive)
diff --git a/src/main/resources/templates/application/app-sections/section-progress.html b/src/main/resources/templates/application/app-sections/section-progress.html
index c1be5587d..6c676014d 100644
--- a/src/main/resources/templates/application/app-sections/section-progress.html
+++ b/src/main/resources/templates/application/app-sections/section-progress.html
@@ -6,10 +6,23 @@
   <body>
     <th:block th:fragment="section">
       <div th:if="${not #lists.isEmpty(comments)}">
-        <div th:replace="~{fragments/section-heading::section-heading(~{::app-progress-heading-body}, ~{})}">
+        <div
+          th:replace="~{fragments/section-heading::section-heading(~{::app-progress-heading-body}, ~{::app-progress-heading-actions})}"
+        >
           <th:block th:ref="app-progress-heading-body">
             <h2 id="vacation" th:text="#{application.progress.title}">Verlauf</h2>
           </th:block>
+          <th:block th:ref="app-progress-heading-actions">
+            <th:block th:if="${isAllowedToCommentApplication}">
+              <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-text-sm">
@@ -53,7 +66,7 @@
                     <th:block th:replace="~{fragments/date::date(${app.cancelDate})}"></th:block>
                   </th:block>
                   <th:block
-                    th:if="${comment.action.name == 'REFERRED' || comment.action.name == 'CANCEL_REQUESTED' || comment.action.name == 'CANCEL_REQUESTED_DECLINED'}"
+                    th:if="${comment.action.name == 'REFERRED' || comment.action.name == 'CANCEL_REQUESTED' || comment.action.name == 'CANCEL_REQUESTED_DECLINED' || comment.action.name == 'COMMENTED'}"
                   >
                     <th:block th:replace="~{fragments/date::date(${comment.date})}"></th:block>
                   </th:block>
@@ -67,6 +80,30 @@
             </tr>
           </tbody>
         </table>
+
+        <form
+          th:if="${isAllowedToCommentApplication}"
+          id="comment-form"
+          method="post"
+          th:action="@{/web/application/__${app.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>
     </th:block>
   </body>
diff --git a/src/main/resources/templates/application/application_form.html b/src/main/resources/templates/application/application_form.html
index b9a53dfe2..37d80b819 100644
--- a/src/main/resources/templates/application/application_form.html
+++ b/src/main/resources/templates/application/application_form.html
@@ -539,7 +539,7 @@
                   <div class="col-md-9">
                     <textarea
                       id="comment"
-                      rows="1"
+                      rows="2"
                       name="comment"
                       th:text="${applicationForLeaveForm.comment}"
                       class="form-control"
diff --git a/src/main/resources/templates/overtime/overtime_form.html b/src/main/resources/templates/overtime/overtime_form.html
index 7fff77f89..962ecaf24 100644
--- a/src/main/resources/templates/overtime/overtime_form.html
+++ b/src/main/resources/templates/overtime/overtime_form.html
@@ -177,7 +177,7 @@
                 </div>
               </div>
 
-              <div class="form-group">
+              <div class="form-group tw-mb-16">
                 <label th:text="|#{overtime.data.comment}:|" class="control-label col-md-3" for="comment"></label>
                 <div class="col-md-9">
                   <textarea id="comment" th:field="*{comment}" class="form-control" rows="2"></textarea>
diff --git a/src/main/resources/templates/sicknote/sick_note_form.html b/src/main/resources/templates/sicknote/sick_note_form.html
index e1c37de2b..6ed1666be 100644
--- a/src/main/resources/templates/sicknote/sick_note_form.html
+++ b/src/main/resources/templates/sicknote/sick_note_form.html
@@ -299,7 +299,7 @@
                 <div class="col-md-9">
                   <textarea
                     id="comment"
-                    rows="1"
+                    rows="2"
                     th:field="*{comment}"
                     class="form-control"
                     th:errorclass="error"


Test patch:
diff --git a/src/test/java/org/synyx/urlaubsverwaltung/application/application/ApplicationForLeaveDetailsViewControllerTest.java b/src/test/java/org/synyx/urlaubsverwaltung/application/application/ApplicationForLeaveDetailsViewControllerTest.java
index 8ffd6285f..ae898f756 100644
--- a/src/test/java/org/synyx/urlaubsverwaltung/application/application/ApplicationForLeaveDetailsViewControllerTest.java
+++ b/src/test/java/org/synyx/urlaubsverwaltung/application/application/ApplicationForLeaveDetailsViewControllerTest.java
@@ -1283,6 +1283,105 @@ class ApplicationForLeaveDetailsViewControllerTest {
         verify(applicationInteractionService).declineCancellationRequest(eq(cancellationRequestedApplication), eq(signedInPerson), any());
     }
 
+
+    @Test
+    void addCommentWithUnknownApplicationIdThrowsUnknownApplicationForLeaveException() {
+
+        assertThatThrownBy(() ->
+            perform(post("/web/application/" + APPLICATION_ID + "/comment"))
+        ).hasCauseInstanceOf(UnknownApplicationForLeaveException.class);
+    }
+
+    @Test
+    void addCommentThrowsAccessDeniedIfPersonHasNotEnoughPermissions() {
+
+        final Person signedInPerson = personWithRole(BOSS);
+        final Person person = somePerson();
+
+        when(personService.getSignedInUser()).thenReturn(signedInPerson);
+        when(applicationService.getApplicationById(APPLICATION_ID)).thenReturn(Optional.of(applicationOfPerson(person)));
+
+        assertThatThrownBy(() ->
+            perform(post("/web/application/" + APPLICATION_ID + "/comment"))
+        ).hasCauseInstanceOf(AccessDeniedException.class);
+    }
+
+    @Test
+    void addCommentThrowsAccessDeniedForDepartmentHeadOfPerson() {
+
+        final Person signedInPerson = personWithRole(DEPARTMENT_HEAD);
+        final Person person = somePerson();
+
+        when(personService.getSignedInUser()).thenReturn(signedInPerson);
+        when(applicationService.getApplicationById(APPLICATION_ID)).thenReturn(Optional.of(applicationOfPerson(person)));
+        when(departmentService.isDepartmentHeadAllowedToManagePerson(signedInPerson, person)).thenReturn(true);
+
+        assertThatThrownBy(() ->
+            perform(post("/web/application/" + APPLICATION_ID + "/comment"))
+        ).hasCauseInstanceOf(AccessDeniedException.class);
+    }
+
+    @Test
+    void addCommentThrowsAccessDeniedForSecondStageAuthorityOfPerson() {
+
+        final Person signedInPerson = personWithRole(SECOND_STAGE_AUTHORITY);
+        final Person person = somePerson();
+
+        when(personService.getSignedInUser()).thenReturn(signedInPerson);
+        when(applicationService.getApplicationById(APPLICATION_ID)).thenReturn(Optional.of(applicationOfPerson(person)));
+        when(departmentService.isSecondStageAuthorityAllowedToManagePerson(signedInPerson, person)).thenReturn(true);
+
+        assertThatThrownBy(() ->
+            perform(post("/web/application/" + APPLICATION_ID + "/comment"))
+        ).hasCauseInstanceOf(AccessDeniedException.class);
+    }
+
+    @Test
+    void addCommentAddsFlashAttributesIfErrorsOccur() throws Exception {
+
+        final Person signedInPerson = personWithRole(OFFICE);
+        final Person person = somePerson();
+
+        when(personService.getSignedInUser()).thenReturn(signedInPerson);
+        final Application application = applicationOfPerson(person);
+        application.setId(APPLICATION_ID);
+        when(applicationService.getApplicationById(APPLICATION_ID)).thenReturn(Optional.of(application));
+
+        doAnswer(invocation -> {
+            final Errors errors = invocation.getArgument(1);
+            errors.rejectValue("text", "errors");
+            return null;
+        }).when(commentValidator).validate(any(), any());
+
+        perform(
+            post("/web/application/" + APPLICATION_ID + "/comment")
+        )
+            .andExpect(status().isFound())
+            .andExpect(flash().attribute(ERRORS_ATTRIBUTE, instanceOf(Errors.class)))
+            .andExpect(redirectedUrl("/web/application/" + APPLICATION_ID));
+    }
+
+    @Test
+    void addCommentCreatesAnComment() throws Exception {
+
+        final Person signedInPerson = personWithRole(OFFICE);
+        final Person person = somePerson();
+
+        when(personService.getSignedInUser()).thenReturn(signedInPerson);
+        final Application application = applicationOfPerson(person);
+        application.setId(APPLICATION_ID);
+        when(applicationService.getApplicationById(APPLICATION_ID)).thenReturn(Optional.of(application));
+
+        perform(
+            post("/web/application/" + APPLICATION_ID + "/comment")
+                .param("text", "comment")
+        )
+            .andExpect(status().isFound())
+            .andExpect(redirectedUrl("/web/application/" + APPLICATION_ID));
+
+        verify(commentService).create(application, ApplicationCommentAction.COMMENTED, Optional.of("comment"), signedInPerson);
+    }
+
     @Test
     void remindBossWithUnknownApplicationIdThrowsUnknownApplicationForLeaveException() {
 
diff --git a/src/test/java/org/synyx/urlaubsverwaltung/comment/AbstractCommentTest.java b/src/test/java/org/synyx/urlaubsverwaltung/comment/AbstractCommentTest.java
deleted file mode 100644
index 7efcdff68..000000000
--- a/src/test/java/org/synyx/urlaubsverwaltung/comment/AbstractCommentTest.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package org.synyx.urlaubsverwaltung.comment;
-
-import org.junit.jupiter.api.Test;
-import org.springframework.util.ReflectionUtils;
-
-import java.lang.reflect.Field;
-import java.time.Clock;
-import java.time.Instant;
-
-import static java.time.temporal.ChronoUnit.DAYS;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
-
-
-class AbstractCommentTest {
-
-    @Test
-    void ensureHasDateSetAfterInitialization() {
-
-        final Clock clock = Clock.systemUTC();
-        final TestComment comment = new TestComment(clock);
-        assertThat(comment.getDate()).isEqualTo(Instant.now(clock).truncatedTo(DAYS));
-    }
-
-    @Test
-    void ensureThrowsIfGettingDateOnACorruptedComment() throws IllegalAccessException {
-
-        final TestComment comment = new TestComment(Clock.systemUTC());
-
-        final Field dateField = ReflectionUtils.findField(AbstractComment.class, "date");
-        dateField.setAccessible(true);
-        dateField.set(comment, null);
-
-        assertThatIllegalStateException()
-            .isThrownBy(comment::getDate);
-    }
-
-    private class TestComment extends AbstractComment {
-
-        private TestComment(Clock clock) {
-            super(clock);
-        }
-    }
-}