Skip to content

Commit 60cf44e

Browse files
authored
Merge pull request #206 from TaskFlow-CLAP/CLAP-72-팀-현황-조회-API
CLAP-72 feat: 팀 현황 조회 API 구현
2 parents f7376a6 + 276f881 commit 60cf44e

File tree

14 files changed

+279
-4
lines changed

14 files changed

+279
-4
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package clap.server.adapter.inbound.web.dto.task.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
import java.util.List;
6+
7+
public record FilterTeamStatusRequest(
8+
@Schema(description = "정렬 기준 (기여도순, 기본)", example = "기여도순")
9+
String sortBy,
10+
11+
@Schema(description = "1차 카테고리 ID 목록", example = "[10, 20, 30]")
12+
List<Long> mainCategoryIds,
13+
14+
@Schema(description = "2차 카테고리 ID 목록", example = "[1, 2, 3]")
15+
List<Long> categoryIds,
16+
17+
@Schema(description = "작업 타이틀 검색", example = "타이틀1")
18+
String taskTitle
19+
) {
20+
public FilterTeamStatusRequest {
21+
sortBy = (sortBy == null || sortBy.isEmpty()) ? "기본" : sortBy;
22+
mainCategoryIds = mainCategoryIds == null ? List.of() : mainCategoryIds;
23+
categoryIds = categoryIds == null ? List.of() : categoryIds;
24+
taskTitle = taskTitle == null ? "" : taskTitle;
25+
}
26+
27+
// 카테고리 유효성 검사
28+
public boolean isValid() {
29+
// 1차 카테고리가 없으면 2차 카테고리는 선택할 수 없으므로
30+
return mainCategoryIds.isEmpty() || !categoryIds.isEmpty();
31+
}
32+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package clap.server.adapter.inbound.web.dto.task.response;
2+
3+
import com.querydsl.core.annotations.QueryProjection;
4+
5+
import java.util.List;
6+
7+
public record TeamMemberTaskResponse(
8+
Long processorId,
9+
String nickname,
10+
String imageUrl,
11+
String department,
12+
int inProgressTaskCount,
13+
int pendingTaskCount,
14+
int totalTaskCount,
15+
List<TaskItemResponse> tasks
16+
) {
17+
@QueryProjection
18+
public TeamMemberTaskResponse {
19+
tasks = (tasks == null) ? List.of() : tasks;
20+
}
21+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package clap.server.adapter.inbound.web.dto.task.response;
2+
3+
import java.util.List;
4+
5+
public record TeamStatusResponse(
6+
List<TeamMemberTaskResponse> members
7+
) {
8+
public TeamStatusResponse(List<TeamMemberTaskResponse> members) {
9+
this.members = (members == null) ? List.of() : members;
10+
}
11+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package clap.server.adapter.inbound.web.task;
2+
3+
import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest;
4+
5+
import clap.server.adapter.inbound.web.dto.task.response.TeamStatusResponse;
6+
import clap.server.application.service.task.TeamStatusService;
7+
import clap.server.common.annotation.architecture.WebAdapter;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import jakarta.validation.Valid;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.data.domain.Pageable;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.web.bind.annotation.*;
14+
15+
@RestController
16+
@RequestMapping("/api/team-status")
17+
@RequiredArgsConstructor
18+
@Tag(name = "팀 현황 조회 API")
19+
@WebAdapter
20+
public class TeamStatusController {
21+
22+
private final TeamStatusService teamStatusService;
23+
24+
@GetMapping("/filter")
25+
public ResponseEntity<TeamStatusResponse> filterTeamStatus(@Valid@ModelAttribute FilterTeamStatusRequest filter) {
26+
TeamStatusResponse response = teamStatusService.filterTeamStatus(filter);
27+
return ResponseEntity.ok(response);
28+
}
29+
}

src/main/java/clap/server/adapter/outbound/persistense/TaskPersistenceAdapter.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import clap.server.adapter.inbound.web.dto.task.request.FilterTaskListRequest;
44
import clap.server.adapter.inbound.web.dto.task.request.FilterTaskBoardRequest;
5+
import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest;
6+
import clap.server.adapter.inbound.web.dto.task.response.TeamMemberTaskResponse;
57
import clap.server.adapter.outbound.persistense.entity.task.TaskEntity;
68
import clap.server.adapter.outbound.persistense.entity.task.constant.TaskStatus;
79
import clap.server.adapter.outbound.persistense.mapper.TaskPersistenceMapper;
@@ -109,5 +111,10 @@ public Slice<Task> findTaskBoardByFilter(Long processorId, List<TaskStatus> stat
109111
return new SliceImpl<>(taskList, pageable, hasNext);
110112
}
111113

114+
@Override
115+
public List<TeamMemberTaskResponse> findTeamStatus(Long memberId, FilterTeamStatusRequest filter) {
116+
return taskRepository.findTeamStatus(memberId, filter);
117+
}
118+
112119

113120
}

src/main/java/clap/server/adapter/outbound/persistense/mapper/TaskPersistenceMapper.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import clap.server.adapter.outbound.persistense.mapper.common.PersistenceMapper;
55
import clap.server.domain.model.task.Task;
66
import org.mapstruct.Mapper;
7-
import org.mapstruct.Mapping;
87

98
@Mapper(componentModel = "spring", uses = {MemberPersistenceMapper.class, LabelPersistenceMapper.class, CategoryPersistenceMapper.class})
109
public interface TaskPersistenceMapper extends PersistenceMapper<TaskEntity, Task> {

src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskCustomRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import clap.server.adapter.inbound.web.dto.task.request.FilterTaskListRequest;
44
import clap.server.adapter.inbound.web.dto.task.request.FilterTaskBoardRequest;
5+
import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest;
6+
import clap.server.adapter.inbound.web.dto.task.response.TeamMemberTaskResponse;
57
import clap.server.adapter.outbound.persistense.entity.task.TaskEntity;
68
import clap.server.adapter.outbound.persistense.entity.task.constant.TaskStatus;
79
import org.springframework.data.domain.Page;
@@ -13,6 +15,7 @@
1315
public interface TaskCustomRepository {
1416

1517
Page<TaskEntity> findTasksRequestedByUser(Long requesterId, Pageable pageable, FilterTaskListRequest findTaskListRequest);
18+
List<TeamMemberTaskResponse> findTeamStatus(Long memberId, FilterTeamStatusRequest filter);
1619
Page<TaskEntity> findPendingApprovalTasks(Pageable pageable, FilterTaskListRequest findTaskListRequest);
1720
Page<TaskEntity> findAllTasks(Pageable pageable, FilterTaskListRequest findTaskListRequest);
1821
List<TaskEntity> findTasksByFilter(Long processorId, List<TaskStatus> statuses, LocalDateTime localDateTime, FilterTaskBoardRequest request, Pageable pageable);

src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskCustomRepositoryImpl.java

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22

33
import clap.server.adapter.inbound.web.dto.task.request.FilterTaskListRequest;
44
import clap.server.adapter.inbound.web.dto.task.request.FilterTaskBoardRequest;
5+
import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest;
6+
import clap.server.adapter.inbound.web.dto.task.response.TaskItemResponse;
7+
import clap.server.adapter.inbound.web.dto.task.response.TeamMemberTaskResponse;
58
import clap.server.adapter.outbound.persistense.entity.task.TaskEntity;
69
import clap.server.adapter.outbound.persistense.entity.task.constant.TaskStatus;
710
import com.querydsl.core.BooleanBuilder;
811
import com.querydsl.core.types.OrderSpecifier;
912
import com.querydsl.core.types.dsl.DateTimePath;
1013
import com.querydsl.jpa.impl.JPAQueryFactory;
14+
import jakarta.persistence.EntityManager;
15+
import jakarta.persistence.TypedQuery;
1116
import lombok.RequiredArgsConstructor;
1217
import org.springframework.data.domain.Page;
1318
import org.springframework.data.domain.PageImpl;
@@ -16,6 +21,7 @@
1621

1722
import java.time.LocalDateTime;
1823
import java.util.List;
24+
import java.util.stream.Collectors;
1925

2026
import static clap.server.adapter.outbound.persistense.entity.task.QTaskEntity.taskEntity;
2127
import static com.querydsl.core.types.Order.ASC;
@@ -26,7 +32,7 @@
2632
public class TaskCustomRepositoryImpl implements TaskCustomRepository {
2733

2834
private final JPAQueryFactory queryFactory;
29-
35+
private final EntityManager entityManager;
3036

3137
@Override
3238
public Page<TaskEntity> findTasksRequestedByUser(Long requesterId, Pageable pageable, FilterTaskListRequest filterTaskListRequest) {
@@ -50,6 +56,103 @@ public Page<TaskEntity> findTasksAssignedByManager(Long processorId, Pageable pa
5056
return getTasksPage(pageable, builder, filterTaskListRequest.sortBy(), filterTaskListRequest.sortDirection());
5157
}
5258

59+
@Override
60+
public List<TeamMemberTaskResponse> findTeamStatus(Long memberId, FilterTeamStatusRequest filter) {
61+
// 1. 담당자 목록을 가져옴 (페이징 제거)
62+
List<Long> processorIds = queryFactory
63+
.select(taskEntity.processor.memberId)
64+
.from(taskEntity)
65+
.groupBy(taskEntity.processor.memberId)
66+
.orderBy("기여도순".equals(filter.sortBy()) ?
67+
taskEntity.taskId.count().desc() :
68+
taskEntity.processor.nickname.asc())
69+
.fetch();
70+
71+
if (processorIds.isEmpty()) {
72+
return List.of(); // 결과가 없으면 빈 리스트 반환
73+
}
74+
75+
// 2. 담당자별 작업 조회 (페이징 제거)
76+
List<TaskEntity> taskEntities = queryFactory
77+
.selectFrom(taskEntity)
78+
.where(taskEntity.processor.memberId.in(processorIds))
79+
.fetch();
80+
81+
// 3. 담당자별 그룹핑
82+
return taskEntities.stream()
83+
.collect(Collectors.groupingBy(t -> t.getProcessor().getMemberId()))
84+
.entrySet().stream()
85+
.map(entry -> {
86+
List<TaskItemResponse> taskResponses = entry.getValue().stream()
87+
.map(taskEntity -> new TaskItemResponse(
88+
taskEntity.getTaskId(),
89+
taskEntity.getTaskCode(),
90+
taskEntity.getTitle(),
91+
taskEntity.getCategory().getMainCategory().getName(),
92+
taskEntity.getCategory().getName(),
93+
taskEntity.getRequester().getNickname(),
94+
taskEntity.getRequester().getImageUrl(),
95+
taskEntity.getRequester().getDepartment().getName(),
96+
taskEntity.getProcessorOrder(),
97+
taskEntity.getTaskStatus(),
98+
taskEntity.getCreatedAt()
99+
)).collect(Collectors.toList());
100+
101+
return new TeamMemberTaskResponse(
102+
entry.getKey(),
103+
entry.getValue().get(0).getProcessor().getNickname(),
104+
entry.getValue().get(0).getProcessor().getImageUrl(),
105+
entry.getValue().get(0).getProcessor().getDepartment().getName(),
106+
(int) entry.getValue().stream().filter(t -> t.getTaskStatus() == TaskStatus.IN_PROGRESS).count(),
107+
(int) entry.getValue().stream().filter(t -> t.getTaskStatus() == TaskStatus.PENDING_COMPLETED).count(),
108+
entry.getValue().size(),
109+
taskResponses
110+
);
111+
}).collect(Collectors.toList());
112+
}
113+
114+
115+
116+
private String buildQueryString(FilterTeamStatusRequest filter) {
117+
StringBuilder queryStr = new StringBuilder("SELECT t FROM TaskEntity t " +
118+
"JOIN FETCH t.processor p " +
119+
"WHERE (:memberId IS NULL OR p.memberId = :memberId) ");
120+
121+
if (!filter.taskTitle().isEmpty()) {
122+
queryStr.append("AND t.title LIKE :title ");
123+
}
124+
if (!filter.mainCategoryIds().isEmpty()) {
125+
queryStr.append("AND t.category.mainCategory.id IN :mainCategories ");
126+
}
127+
if (!filter.categoryIds().isEmpty()) {
128+
queryStr.append("AND t.category.id IN :categories ");
129+
}
130+
131+
if ("기여도순".equals(filter.sortBy())) {
132+
queryStr.append("ORDER BY (SELECT COUNT(te) FROM TaskEntity te WHERE te.processor = p AND te.taskStatus IN ('IN_PROGRESS', 'PENDING_COMPLETED')) DESC");
133+
} else {
134+
queryStr.append("ORDER BY p.nickname ASC");
135+
}
136+
137+
return queryStr.toString();
138+
}
139+
140+
private boolean isValidTitle(FilterTeamStatusRequest filter) {
141+
return filter.taskTitle() != null && !filter.taskTitle().isEmpty();
142+
}
143+
144+
private void setQueryParameters(TypedQuery<TaskEntity> query, FilterTeamStatusRequest filter) {
145+
if (isValidTitle(filter)) {
146+
query.setParameter("title", "%" + filter.taskTitle() + "%");
147+
}
148+
if (!filter.mainCategoryIds().isEmpty()) {
149+
query.setParameter("mainCategories", filter.mainCategoryIds());
150+
}
151+
if (!filter.categoryIds().isEmpty()) {
152+
query.setParameter("categories", filter.categoryIds());
153+
}
154+
}
155+
53156
@Override
54157
public Page<TaskEntity> findPendingApprovalTasks(Pageable pageable, FilterTaskListRequest filterTaskListRequest) {
55158
BooleanBuilder builder = createFilter(filterTaskListRequest);

src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskRepository.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package clap.server.adapter.outbound.persistense.repository.task;
22

3+
4+
import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest;
5+
import clap.server.adapter.inbound.web.dto.task.response.TeamMemberTaskResponse;
36
import clap.server.adapter.outbound.persistense.entity.task.TaskEntity;
47
import clap.server.adapter.outbound.persistense.entity.task.constant.TaskStatus;
58
import io.lettuce.core.dynamic.annotation.Param;
9+
import org.springframework.data.domain.Page;
610
import org.springframework.data.domain.Pageable;
711
import org.springframework.data.domain.Slice;
812
import org.springframework.data.jpa.repository.JpaRepository;
@@ -50,4 +54,9 @@ Slice<TaskEntity> findTasksWithTaskStatusAndCompletedAt(
5054
Optional<TaskEntity> findTopByProcessor_MemberIdAndTaskStatusAndProcessorOrderAfterOrderByProcessorOrderDesc(
5155
Long processorId, TaskStatus taskStatus, Long processorOrder);
5256

57+
@Query("SELECT t FROM TaskEntity t JOIN FETCH t.processor p WHERE (:memberId IS NULL OR p.memberId = :memberId) ")
58+
Page<TeamMemberTaskResponse> findTeamStatus(@Param("memberId") Long memberId, FilterTeamStatusRequest filter, Pageable pageable);
59+
60+
61+
5362
}

src/main/java/clap/server/application/mapper/TaskMapper.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package clap.server.application.mapper;
22

33

4+
import clap.server.adapter.inbound.web.dto.task.response.TaskBoardResponse;
5+
import clap.server.adapter.inbound.web.dto.task.response.TaskItemResponse;
46
import clap.server.adapter.inbound.web.dto.task.response.*;
57
import clap.server.adapter.outbound.persistense.entity.task.constant.TaskStatus;
68
import clap.server.domain.model.task.Attachment;
@@ -131,7 +133,7 @@ public static TaskBoardResponse toSliceTaskItemResponse(Slice<Task> tasks) {
131133
);
132134
}
133135

134-
private static TaskItemResponse toTaskItemResponse(Task task) {
136+
public static TaskItemResponse toTaskItemResponse(Task task) {
135137
return new TaskItemResponse(
136138
task.getTaskId(),
137139
task.getTaskCode(),

0 commit comments

Comments
 (0)