Merge commit '2a9a1495203830ed0434649c153ee75e812ffe51' into work/reports-ui

# Conflicts:
#	src/main/resources/templates/projects/detail.html
This commit is contained in:
sechmachine
2026-08-15 01:36:32 +07:00
11 changed files with 192 additions and 31 deletions
@@ -63,6 +63,12 @@ public class ProjectController {
return "projects/detail";
}
@PostMapping("/{projectId}/activate")
public String activate(Principal principal, @PathVariable long projectId) {
projects.activate(actorId(principal), projectId);
return "redirect:/projects/" + projectId;
}
@GetMapping("/{projectId}/members")
public String members(Principal principal, @PathVariable long projectId, Model model) {
long actorId = actorId(principal);
@@ -21,6 +21,7 @@ import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@Entity
@Table(name = "projects")
@@ -157,15 +158,23 @@ public class ProjectEntity {
this, change.replacement(), change.effectiveAt(), actorMentorUserId));
}
public void activate(long actorMentorUserId, boolean allTaskAssigneesAreCurrent, Instant at) {
public void activate(
long actorMentorUserId,
Set<Long> activeInternUserIds,
boolean allTaskAssigneesAreCurrent,
Instant at) {
requireOwner(actorMentorUserId);
Objects.requireNonNull(activeInternUserIds, "activeInternUserIds");
Objects.requireNonNull(at, "at");
if (status != ProjectStatus.PLANNED) {
throw new ProjectRuleViolationException("Only a planned Project can be activated");
}
if (memberships.stream().noneMatch(ProjectMembershipEntity::isCurrent)
|| leadershipTerms.stream().noneMatch(ProjectLeadershipTermEntity::isCurrent)) {
throw new ProjectRuleViolationException("Project requires a current member and Leader");
if (memberships.stream().noneMatch(membership -> membership.isCurrent()
&& activeInternUserIds.contains(membership.internUserId()))) {
throw new ProjectRuleViolationException("Project requires an active member");
}
if (!activeInternUserIds.contains(currentLeader().internUserId())) {
throw new ProjectRuleViolationException("Project Leader must be an active member");
}
if (!allTaskAssigneesAreCurrent) {
throw new ProjectRuleViolationException("Every current Task assignee must be an active Project member");
@@ -7,7 +7,10 @@ import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext;
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
import com.lab.labtimesheet.feature.task.service.TaskQueryService;
import java.time.Clock;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -17,16 +20,19 @@ public class ProjectService {
private final ProjectRepository projects;
private final AccountService accounts;
private final ProjectQueryService queries;
private final TaskQueryService taskQueries;
private final Clock clock;
public ProjectService(
ProjectRepository projects,
AccountService accounts,
ProjectQueryService queries,
TaskQueryService taskQueries,
Clock clock) {
this.projects = projects;
this.accounts = accounts;
this.queries = queries;
this.taskQueries = taskQueries;
this.clock = clock;
}
@@ -70,6 +76,27 @@ public class ProjectService {
return queries.taskContext(actorUserId, lockedProject(projectId));
}
@Transactional
public void activate(long actorUserId, long projectId) {
var project = lockedProject(projectId);
project.authorizeOwner(actorUserId);
var activeMemberships = project.memberships().stream()
.filter(membership -> membership.isCurrent()
&& accounts.isEligibleIntern(membership.internUserId()))
.toList();
var activeMembershipIds = activeMemberships.stream()
.map(membership -> membership.id())
.collect(Collectors.toUnmodifiableSet());
Set<Long> activeInternUserIds = activeMemberships.stream()
.map(membership -> membership.internUserId())
.collect(Collectors.toUnmodifiableSet());
var allTaskAssigneesAreCurrent = taskQueries.countCurrentTasksAssignedOutside(
projectId, activeMembershipIds) == 0;
project.activate(actorUserId, activeInternUserIds, allTaskAssigneesAreCurrent, clock.instant());
projects.flush();
}
private ProjectEntity lockedProject(long projectId) {
return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new);
}
@@ -16,6 +16,9 @@
<div class="metric"><div class="metric-label">Leader</div><div class="metric-value" th:text="${project.leaderName}">Leader</div></div>
<div class="metric"><div class="metric-label">Project window</div><div class="metric-value" th:text="${#temporals.format(project.startDate, 'dd/MM/yyyy') + ' ' + #temporals.format(project.endDate, 'dd/MM/yyyy')}">15/08/2026 30/09/2026</div></div>
</section>
<form class="inline-actions" th:if="${project.canManage and project.status == 'PLANNED'}" th:action="@{/projects/{id}/activate(id=${project.id})}" method="post">
<button class="button button-primary" type="submit">Activate</button>
</form>
<nav class="tabs" aria-label="Project sections">
<a class="tab" th:href="@{/projects/{id}(id=${project.id})}" aria-current="page">Overview</a>
<a class="tab" th:href="@{/projects/{id}/members(id=${project.id})}">Members</a>