diff --git a/src/main/java/clap/server/adapter/inbound/web/dto/task/request/FilterTeamStatusRequest.java b/src/main/java/clap/server/adapter/inbound/web/dto/task/request/FilterTeamStatusRequest.java new file mode 100644 index 00000000..9fa71e91 --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/web/dto/task/request/FilterTeamStatusRequest.java @@ -0,0 +1,32 @@ +package clap.server.adapter.inbound.web.dto.task.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record FilterTeamStatusRequest( + @Schema(description = "정렬 기준 (기여도순, 기본)", example = "기여도순") + String sortBy, + + @Schema(description = "1차 카테고리 ID 목록", example = "[10, 20, 30]") + List mainCategoryIds, + + @Schema(description = "2차 카테고리 ID 목록", example = "[1, 2, 3]") + List categoryIds, + + @Schema(description = "작업 타이틀 검색", example = "타이틀1") + String taskTitle +) { + public FilterTeamStatusRequest { + sortBy = (sortBy == null || sortBy.isEmpty()) ? "기본" : sortBy; + mainCategoryIds = mainCategoryIds == null ? List.of() : mainCategoryIds; + categoryIds = categoryIds == null ? List.of() : categoryIds; + taskTitle = taskTitle == null ? "" : taskTitle; + } + + // 카테고리 유효성 검사 + public boolean isValid() { + // 1차 카테고리가 없으면 2차 카테고리는 선택할 수 없으므로 + return mainCategoryIds.isEmpty() || !categoryIds.isEmpty(); + } +} diff --git a/src/main/java/clap/server/adapter/inbound/web/dto/task/response/TeamMemberTaskResponse.java b/src/main/java/clap/server/adapter/inbound/web/dto/task/response/TeamMemberTaskResponse.java new file mode 100644 index 00000000..157993a4 --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/web/dto/task/response/TeamMemberTaskResponse.java @@ -0,0 +1,21 @@ +package clap.server.adapter.inbound.web.dto.task.response; + +import com.querydsl.core.annotations.QueryProjection; + +import java.util.List; + +public record TeamMemberTaskResponse( + Long processorId, + String nickname, + String imageUrl, + String department, + int inProgressTaskCount, + int pendingTaskCount, + int totalTaskCount, + List tasks +) { + @QueryProjection + public TeamMemberTaskResponse { + tasks = (tasks == null) ? List.of() : tasks; + } +} diff --git a/src/main/java/clap/server/adapter/inbound/web/dto/task/response/TeamStatusResponse.java b/src/main/java/clap/server/adapter/inbound/web/dto/task/response/TeamStatusResponse.java new file mode 100644 index 00000000..61cd5dfc --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/web/dto/task/response/TeamStatusResponse.java @@ -0,0 +1,11 @@ +package clap.server.adapter.inbound.web.dto.task.response; + +import java.util.List; + +public record TeamStatusResponse( + List members +) { + public TeamStatusResponse(List members) { + this.members = (members == null) ? List.of() : members; + } +} diff --git a/src/main/java/clap/server/adapter/inbound/web/task/TeamStatusController.java b/src/main/java/clap/server/adapter/inbound/web/task/TeamStatusController.java new file mode 100644 index 00000000..69a559bf --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/web/task/TeamStatusController.java @@ -0,0 +1,29 @@ +package clap.server.adapter.inbound.web.task; + +import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest; + +import clap.server.adapter.inbound.web.dto.task.response.TeamStatusResponse; +import clap.server.application.service.task.TeamStatusService; +import clap.server.common.annotation.architecture.WebAdapter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/team-status") +@RequiredArgsConstructor +@Tag(name = "팀 현황 조회 API") +@WebAdapter +public class TeamStatusController { + + private final TeamStatusService teamStatusService; + + @GetMapping("/filter") + public ResponseEntity filterTeamStatus(@Valid@ModelAttribute FilterTeamStatusRequest filter) { + TeamStatusResponse response = teamStatusService.filterTeamStatus(filter); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/clap/server/adapter/outbound/persistense/TaskPersistenceAdapter.java b/src/main/java/clap/server/adapter/outbound/persistense/TaskPersistenceAdapter.java index 4aeedd4c..7171871b 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/TaskPersistenceAdapter.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/TaskPersistenceAdapter.java @@ -2,6 +2,8 @@ import clap.server.adapter.inbound.web.dto.task.request.FilterTaskListRequest; import clap.server.adapter.inbound.web.dto.task.request.FilterTaskBoardRequest; +import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest; +import clap.server.adapter.inbound.web.dto.task.response.TeamMemberTaskResponse; import clap.server.adapter.outbound.persistense.entity.task.TaskEntity; import clap.server.adapter.outbound.persistense.entity.task.constant.TaskStatus; import clap.server.adapter.outbound.persistense.mapper.TaskPersistenceMapper; @@ -109,5 +111,10 @@ public Slice findTaskBoardByFilter(Long processorId, List stat return new SliceImpl<>(taskList, pageable, hasNext); } + @Override + public List findTeamStatus(Long memberId, FilterTeamStatusRequest filter) { + return taskRepository.findTeamStatus(memberId, filter); + } + } diff --git a/src/main/java/clap/server/adapter/outbound/persistense/mapper/TaskPersistenceMapper.java b/src/main/java/clap/server/adapter/outbound/persistense/mapper/TaskPersistenceMapper.java index 08387c40..053d8290 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/mapper/TaskPersistenceMapper.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/mapper/TaskPersistenceMapper.java @@ -4,7 +4,6 @@ import clap.server.adapter.outbound.persistense.mapper.common.PersistenceMapper; import clap.server.domain.model.task.Task; import org.mapstruct.Mapper; -import org.mapstruct.Mapping; @Mapper(componentModel = "spring", uses = {MemberPersistenceMapper.class, LabelPersistenceMapper.class, CategoryPersistenceMapper.class}) public interface TaskPersistenceMapper extends PersistenceMapper { diff --git a/src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskCustomRepository.java b/src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskCustomRepository.java index 27bfc61e..b016c5cb 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskCustomRepository.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskCustomRepository.java @@ -2,6 +2,8 @@ import clap.server.adapter.inbound.web.dto.task.request.FilterTaskListRequest; import clap.server.adapter.inbound.web.dto.task.request.FilterTaskBoardRequest; +import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest; +import clap.server.adapter.inbound.web.dto.task.response.TeamMemberTaskResponse; import clap.server.adapter.outbound.persistense.entity.task.TaskEntity; import clap.server.adapter.outbound.persistense.entity.task.constant.TaskStatus; import org.springframework.data.domain.Page; @@ -13,6 +15,7 @@ public interface TaskCustomRepository { Page findTasksRequestedByUser(Long requesterId, Pageable pageable, FilterTaskListRequest findTaskListRequest); + List findTeamStatus(Long memberId, FilterTeamStatusRequest filter); Page findPendingApprovalTasks(Pageable pageable, FilterTaskListRequest findTaskListRequest); Page findAllTasks(Pageable pageable, FilterTaskListRequest findTaskListRequest); List findTasksByFilter(Long processorId, List statuses, LocalDateTime localDateTime, FilterTaskBoardRequest request, Pageable pageable); diff --git a/src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskCustomRepositoryImpl.java b/src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskCustomRepositoryImpl.java index 600c27d8..84e8880c 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskCustomRepositoryImpl.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskCustomRepositoryImpl.java @@ -2,12 +2,17 @@ import clap.server.adapter.inbound.web.dto.task.request.FilterTaskListRequest; import clap.server.adapter.inbound.web.dto.task.request.FilterTaskBoardRequest; +import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest; +import clap.server.adapter.inbound.web.dto.task.response.TaskItemResponse; +import clap.server.adapter.inbound.web.dto.task.response.TeamMemberTaskResponse; import clap.server.adapter.outbound.persistense.entity.task.TaskEntity; import clap.server.adapter.outbound.persistense.entity.task.constant.TaskStatus; import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.DateTimePath; import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -16,6 +21,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.stream.Collectors; import static clap.server.adapter.outbound.persistense.entity.task.QTaskEntity.taskEntity; import static com.querydsl.core.types.Order.ASC; @@ -26,7 +32,7 @@ public class TaskCustomRepositoryImpl implements TaskCustomRepository { private final JPAQueryFactory queryFactory; - + private final EntityManager entityManager; @Override public Page findTasksRequestedByUser(Long requesterId, Pageable pageable, FilterTaskListRequest filterTaskListRequest) { @@ -50,6 +56,103 @@ public Page findTasksAssignedByManager(Long processorId, Pageable pa return getTasksPage(pageable, builder, filterTaskListRequest.sortBy(), filterTaskListRequest.sortDirection()); } + @Override + public List findTeamStatus(Long memberId, FilterTeamStatusRequest filter) { + // 1. 담당자 목록을 가져옴 (페이징 제거) + List processorIds = queryFactory + .select(taskEntity.processor.memberId) + .from(taskEntity) + .groupBy(taskEntity.processor.memberId) + .orderBy("기여도순".equals(filter.sortBy()) ? + taskEntity.taskId.count().desc() : + taskEntity.processor.nickname.asc()) + .fetch(); + + if (processorIds.isEmpty()) { + return List.of(); // 결과가 없으면 빈 리스트 반환 + } + + // 2. 담당자별 작업 조회 (페이징 제거) + List taskEntities = queryFactory + .selectFrom(taskEntity) + .where(taskEntity.processor.memberId.in(processorIds)) + .fetch(); + + // 3. 담당자별 그룹핑 + return taskEntities.stream() + .collect(Collectors.groupingBy(t -> t.getProcessor().getMemberId())) + .entrySet().stream() + .map(entry -> { + List taskResponses = entry.getValue().stream() + .map(taskEntity -> new TaskItemResponse( + taskEntity.getTaskId(), + taskEntity.getTaskCode(), + taskEntity.getTitle(), + taskEntity.getCategory().getMainCategory().getName(), + taskEntity.getCategory().getName(), + taskEntity.getRequester().getNickname(), + taskEntity.getRequester().getImageUrl(), + taskEntity.getRequester().getDepartment().getName(), + taskEntity.getProcessorOrder(), + taskEntity.getTaskStatus(), + taskEntity.getCreatedAt() + )).collect(Collectors.toList()); + + return new TeamMemberTaskResponse( + entry.getKey(), + entry.getValue().get(0).getProcessor().getNickname(), + entry.getValue().get(0).getProcessor().getImageUrl(), + entry.getValue().get(0).getProcessor().getDepartment().getName(), + (int) entry.getValue().stream().filter(t -> t.getTaskStatus() == TaskStatus.IN_PROGRESS).count(), + (int) entry.getValue().stream().filter(t -> t.getTaskStatus() == TaskStatus.PENDING_COMPLETED).count(), + entry.getValue().size(), + taskResponses + ); + }).collect(Collectors.toList()); + } + + + + private String buildQueryString(FilterTeamStatusRequest filter) { + StringBuilder queryStr = new StringBuilder("SELECT t FROM TaskEntity t " + + "JOIN FETCH t.processor p " + + "WHERE (:memberId IS NULL OR p.memberId = :memberId) "); + + if (!filter.taskTitle().isEmpty()) { + queryStr.append("AND t.title LIKE :title "); + } + if (!filter.mainCategoryIds().isEmpty()) { + queryStr.append("AND t.category.mainCategory.id IN :mainCategories "); + } + if (!filter.categoryIds().isEmpty()) { + queryStr.append("AND t.category.id IN :categories "); + } + + if ("기여도순".equals(filter.sortBy())) { + queryStr.append("ORDER BY (SELECT COUNT(te) FROM TaskEntity te WHERE te.processor = p AND te.taskStatus IN ('IN_PROGRESS', 'PENDING_COMPLETED')) DESC"); + } else { + queryStr.append("ORDER BY p.nickname ASC"); + } + + return queryStr.toString(); + } + + private boolean isValidTitle(FilterTeamStatusRequest filter) { + return filter.taskTitle() != null && !filter.taskTitle().isEmpty(); + } + + private void setQueryParameters(TypedQuery query, FilterTeamStatusRequest filter) { + if (isValidTitle(filter)) { + query.setParameter("title", "%" + filter.taskTitle() + "%"); + } + if (!filter.mainCategoryIds().isEmpty()) { + query.setParameter("mainCategories", filter.mainCategoryIds()); + } + if (!filter.categoryIds().isEmpty()) { + query.setParameter("categories", filter.categoryIds()); + } + } + @Override public Page findPendingApprovalTasks(Pageable pageable, FilterTaskListRequest filterTaskListRequest) { BooleanBuilder builder = createFilter(filterTaskListRequest); diff --git a/src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskRepository.java b/src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskRepository.java index 006791ba..adf87e22 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskRepository.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/repository/task/TaskRepository.java @@ -1,8 +1,12 @@ package clap.server.adapter.outbound.persistense.repository.task; + +import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest; +import clap.server.adapter.inbound.web.dto.task.response.TeamMemberTaskResponse; import clap.server.adapter.outbound.persistense.entity.task.TaskEntity; import clap.server.adapter.outbound.persistense.entity.task.constant.TaskStatus; import io.lettuce.core.dynamic.annotation.Param; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -50,4 +54,9 @@ Slice findTasksWithTaskStatusAndCompletedAt( Optional findTopByProcessor_MemberIdAndTaskStatusAndProcessorOrderAfterOrderByProcessorOrderDesc( Long processorId, TaskStatus taskStatus, Long processorOrder); + @Query("SELECT t FROM TaskEntity t JOIN FETCH t.processor p WHERE (:memberId IS NULL OR p.memberId = :memberId) ") + Page findTeamStatus(@Param("memberId") Long memberId, FilterTeamStatusRequest filter, Pageable pageable); + + + } \ No newline at end of file diff --git a/src/main/java/clap/server/application/mapper/TaskMapper.java b/src/main/java/clap/server/application/mapper/TaskMapper.java index f700416e..4828acfd 100644 --- a/src/main/java/clap/server/application/mapper/TaskMapper.java +++ b/src/main/java/clap/server/application/mapper/TaskMapper.java @@ -1,6 +1,8 @@ package clap.server.application.mapper; +import clap.server.adapter.inbound.web.dto.task.response.TaskBoardResponse; +import clap.server.adapter.inbound.web.dto.task.response.TaskItemResponse; import clap.server.adapter.inbound.web.dto.task.response.*; import clap.server.adapter.outbound.persistense.entity.task.constant.TaskStatus; import clap.server.domain.model.task.Attachment; @@ -131,7 +133,7 @@ public static TaskBoardResponse toSliceTaskItemResponse(Slice tasks) { ); } - private static TaskItemResponse toTaskItemResponse(Task task) { + public static TaskItemResponse toTaskItemResponse(Task task) { return new TaskItemResponse( task.getTaskId(), task.getTaskCode(), diff --git a/src/main/java/clap/server/application/port/inbound/task/FilterTeamStatusUsecase.java b/src/main/java/clap/server/application/port/inbound/task/FilterTeamStatusUsecase.java new file mode 100644 index 00000000..646a9975 --- /dev/null +++ b/src/main/java/clap/server/application/port/inbound/task/FilterTeamStatusUsecase.java @@ -0,0 +1,10 @@ +package clap.server.application.port.inbound.task; + + +import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest; +import clap.server.adapter.inbound.web.dto.task.response.TeamStatusResponse; +import org.springframework.data.domain.Pageable; + +public interface FilterTeamStatusUsecase { + TeamStatusResponse filterTeamStatus(FilterTeamStatusRequest filter); +} diff --git a/src/main/java/clap/server/application/port/inbound/task/LoadTeamStatusUsecase.java b/src/main/java/clap/server/application/port/inbound/task/LoadTeamStatusUsecase.java new file mode 100644 index 00000000..68b204f4 --- /dev/null +++ b/src/main/java/clap/server/application/port/inbound/task/LoadTeamStatusUsecase.java @@ -0,0 +1,10 @@ +package clap.server.application.port.inbound.task; + +import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest; +import clap.server.adapter.inbound.web.dto.task.response.TeamStatusResponse; +import org.springframework.data.domain.Pageable; + +public interface LoadTeamStatusUsecase { + TeamStatusResponse getTeamStatus(Long memberId, FilterTeamStatusRequest filter, Pageable pageable); +} + diff --git a/src/main/java/clap/server/application/port/outbound/task/LoadTaskPort.java b/src/main/java/clap/server/application/port/outbound/task/LoadTaskPort.java index 278dd99c..054d0343 100644 --- a/src/main/java/clap/server/application/port/outbound/task/LoadTaskPort.java +++ b/src/main/java/clap/server/application/port/outbound/task/LoadTaskPort.java @@ -2,6 +2,8 @@ import clap.server.adapter.inbound.web.dto.task.request.FilterTaskListRequest; import clap.server.adapter.inbound.web.dto.task.request.FilterTaskBoardRequest; +import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest; +import clap.server.adapter.inbound.web.dto.task.response.TeamMemberTaskResponse; import clap.server.adapter.outbound.persistense.entity.task.constant.TaskStatus; import clap.server.domain.model.task.Task; import org.springframework.data.domain.Page; @@ -34,4 +36,6 @@ public interface LoadTaskPort { Optional findNextOrderTaskByProcessorIdAndStatus(Long processorId, TaskStatus taskStatus, Long processorOrder); Slice findTaskBoardByFilter(Long processorId, List statuses, LocalDateTime untilDateTime, FilterTaskBoardRequest request, Pageable pageable); -} \ No newline at end of file + + List findTeamStatus(Long memberId, FilterTeamStatusRequest filter); +} diff --git a/src/main/java/clap/server/application/service/task/TeamStatusService.java b/src/main/java/clap/server/application/service/task/TeamStatusService.java new file mode 100644 index 00000000..e24d09a6 --- /dev/null +++ b/src/main/java/clap/server/application/service/task/TeamStatusService.java @@ -0,0 +1,35 @@ +package clap.server.application.service.task; + +import clap.server.adapter.inbound.web.dto.task.request.FilterTeamStatusRequest; +import clap.server.adapter.inbound.web.dto.task.response.TeamMemberTaskResponse; +import clap.server.adapter.inbound.web.dto.task.response.TeamStatusResponse; +import clap.server.application.port.inbound.task.FilterTeamStatusUsecase; +import clap.server.application.port.inbound.task.LoadTeamStatusUsecase; +import clap.server.application.port.outbound.task.LoadTaskPort; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class TeamStatusService implements LoadTeamStatusUsecase, FilterTeamStatusUsecase { + + private final LoadTaskPort loadTaskPort; + + public TeamStatusService(LoadTaskPort loadTaskPort) { + this.loadTaskPort = loadTaskPort; + } + + @Override + public TeamStatusResponse getTeamStatus(Long memberId, FilterTeamStatusRequest filter, Pageable pageable) { + List members = loadTaskPort.findTeamStatus(memberId, filter); // 페이징 처리 + return new TeamStatusResponse(members); + } + + @Override + public TeamStatusResponse filterTeamStatus(FilterTeamStatusRequest filter) { + List members = loadTaskPort.findTeamStatus(null, filter); + return new TeamStatusResponse(members); + } + +}