Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.fowoco.server.dashboard.api;

import com.fowoco.server.auth.application.ActorContext;
import com.fowoco.server.auth.application.port.ActorContextProvider;
import com.fowoco.server.dashboard.application.DashboardQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Parameter;
import java.time.LocalDate;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Dashboard", description = "오늘 업무 대시보드 요약")
@RestController
@RequestMapping("/api/v1/dashboard")
@SecurityRequirement(name = "bearerAuth")
public class DashboardController {

private final DashboardQueryService dashboardQueryService;
private final ActorContextProvider actorContextProvider;

public DashboardController(
DashboardQueryService dashboardQueryService,
ActorContextProvider actorContextProvider
) {
this.dashboardQueryService = dashboardQueryService;
this.actorContextProvider = actorContextProvider;
}

@Operation(
operationId = "getDashboardToday",
summary = "오늘 대시보드 조회",
description = "오늘 우선 업무·승인 대기·응답 대기·기한 경고를 한 번에 조회합니다."
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = DashboardTodayResponse.class)
)
),
@ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"),
@ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden")
})
@GetMapping(path = "/today", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')")
public DashboardTodayResponse today(
@Parameter(description = "기준 날짜 (생략 시 서버 오늘 날짜)") @RequestParam(required = false) LocalDate date,
@Parameter(description = "IANA 타임존 ID (예: Asia/Seoul, 생략 시 서버 기본 타임존)")
@RequestParam(required = false) String timezone
) {
ActorContext actor = actorContextProvider.requireCurrentActor();
return dashboardQueryService.today(actor, date, timezone);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.fowoco.server.dashboard.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;

@Schema(name = "DashboardSummaryCountsResponse", description = "오늘 대시보드 상태별 개수")
public final class DashboardSummaryCountsResponse {

@JsonProperty("pending_approval")
@Schema(name = "pending_approval", description = "승인 대기(READY_FOR_REVIEW) 개수")
private final long pendingApproval;

@JsonProperty("due_today")
@Schema(name = "due_today", description = "오늘 마감인 업무 개수")
private final long dueToday;

@JsonProperty("needs_info")
@Schema(name = "needs_info", description = "정보 보완(NEEDS_INFO) 개수")
private final long needsInfo;

@JsonProperty("worker_response")
@Schema(name = "worker_response", description = "근로자 응답 대기(WAITING_WORKER) 개수")
private final long workerResponse;

public DashboardSummaryCountsResponse(long pendingApproval, long dueToday, long needsInfo, long workerResponse) {
this.pendingApproval = pendingApproval;
this.dueToday = dueToday;
this.needsInfo = needsInfo;
this.workerResponse = workerResponse;
}

public long getPendingApproval() {
return pendingApproval;
}

public long getDueToday() {
return dueToday;
}

public long getNeedsInfo() {
return needsInfo;
}

public long getWorkerResponse() {
return workerResponse;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.fowoco.server.dashboard.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.task.domain.Task;
import com.fowoco.server.task.domain.TaskStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate;
import java.util.UUID;

@Schema(name = "DashboardTaskSummaryResponse", description = "대시보드에 표시할 업무 요약")
public final class DashboardTaskSummaryResponse {

@JsonProperty("task_id")
@Schema(name = "task_id", format = "uuid")
private final UUID taskId;

@JsonProperty("worker_id")
@Schema(name = "worker_id", format = "uuid")
private final UUID workerId;

@JsonProperty("title")
private final String title;

@JsonProperty("status")
private final TaskStatus status;

@JsonProperty("due_date")
@Schema(name = "due_date", format = "date")
private final LocalDate dueDate;

private DashboardTaskSummaryResponse(UUID taskId, UUID workerId, String title, TaskStatus status, LocalDate dueDate) {
this.taskId = taskId;
this.workerId = workerId;
this.title = title;
this.status = status;
this.dueDate = dueDate;
}

public static DashboardTaskSummaryResponse from(Task task) {
return new DashboardTaskSummaryResponse(
task.taskId(),
task.workerId(),
task.title(),
task.status(),
task.dueDate()
);
}

public UUID getTaskId() {
return taskId;
}

public UUID getWorkerId() {
return workerId;
}

public String getTitle() {
return title;
}

public TaskStatus getStatus() {
return status;
}

public LocalDate getDueDate() {
return dueDate;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.fowoco.server.dashboard.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;

@Schema(name = "DashboardTodayResponse", description = "오늘 대시보드 응답")
public final class DashboardTodayResponse {

@JsonProperty("summary_counts")
@Schema(name = "summary_counts")
private final DashboardSummaryCountsResponse summaryCounts;

@JsonProperty("priority_tasks")
@Schema(name = "priority_tasks", description = "오늘의 우선 업무 (열린 업무, 마감일 순 최대 5건)")
private final List<DashboardTaskSummaryResponse> priorityTasks;

@JsonProperty("approval_count")
@Schema(name = "approval_count", description = "승인 대기 개수 (summary_counts.pending_approval과 동일)")
private final long approvalCount;

@JsonProperty("worker_response_count")
@Schema(name = "worker_response_count", description = "근로자 응답 대기 개수")
private final long workerResponseCount;

public DashboardTodayResponse(
DashboardSummaryCountsResponse summaryCounts,
List<DashboardTaskSummaryResponse> priorityTasks,
long approvalCount,
long workerResponseCount
) {
this.summaryCounts = summaryCounts;
this.priorityTasks = priorityTasks;
this.approvalCount = approvalCount;
this.workerResponseCount = workerResponseCount;
}

public DashboardSummaryCountsResponse getSummaryCounts() {
return summaryCounts;
}

public List<DashboardTaskSummaryResponse> getPriorityTasks() {
return priorityTasks;
}

public long getApprovalCount() {
return approvalCount;
}

public long getWorkerResponseCount() {
return workerResponseCount;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.fowoco.server.dashboard.application;

import com.fowoco.server.auth.application.ActorContext;
import com.fowoco.server.common.error.ApiException;
import com.fowoco.server.common.security.TenantDatabaseContext;
import com.fowoco.server.dashboard.api.DashboardSummaryCountsResponse;
import com.fowoco.server.dashboard.api.DashboardTaskSummaryResponse;
import com.fowoco.server.dashboard.api.DashboardTodayResponse;
import com.fowoco.server.dashboard.application.error.DashboardErrorCode;
import com.fowoco.server.task.application.port.TaskRepository;
import com.fowoco.server.task.domain.Task;
import com.fowoco.server.task.domain.TaskStatus;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class DashboardQueryService {

private static final int PRIORITY_TASK_LIMIT = 5;

private final TaskRepository taskRepository;
private final TenantDatabaseContext tenantDatabaseContext;
private final Clock clock;

public DashboardQueryService(
TaskRepository taskRepository,
TenantDatabaseContext tenantDatabaseContext,
Clock clock
) {
this.taskRepository = taskRepository;
this.tenantDatabaseContext = tenantDatabaseContext;
this.clock = clock;
}

@Transactional(readOnly = true)
public DashboardTodayResponse today(ActorContext actor, LocalDate date, String timezone) {
tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId());
UUID companyId = actor.companyId();
Clock effectiveClock = timezone != null ? clock.withZone(parseTimezone(timezone)) : clock;
LocalDate targetDate = date != null ? date : LocalDate.now(effectiveClock);

long pendingApproval = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.READY_FOR_REVIEW);
long needsInfo = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.NEEDS_INFO);
long workerResponse = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.WAITING_WORKER);
long dueToday = taskRepository.countOpenTasksDueOn(companyId, targetDate);

DashboardSummaryCountsResponse summaryCounts = new DashboardSummaryCountsResponse(
pendingApproval, dueToday, needsInfo, workerResponse
);

List<Task> openTasks = taskRepository.findOpenTasks(companyId, PRIORITY_TASK_LIMIT);
List<DashboardTaskSummaryResponse> priorityTasks = openTasks.stream()
.map(DashboardTaskSummaryResponse::from)
.toList();

return new DashboardTodayResponse(summaryCounts, priorityTasks, pendingApproval, workerResponse);
}

private ZoneId parseTimezone(String timezone) {
try {
return ZoneId.of(timezone);
} catch (DateTimeException exception) {
throw new ApiException(DashboardErrorCode.INVALID_TIMEZONE);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.fowoco.server.dashboard.application.error;

import com.fowoco.server.common.error.ApiErrorCode;
import org.springframework.http.HttpStatus;

public enum DashboardErrorCode implements ApiErrorCode {
INVALID_TIMEZONE(
HttpStatus.BAD_REQUEST,
"유효하지 않은 timezone 값입니다. IANA 타임존 ID(예: Asia/Seoul)를 사용해 주세요."
);

private final HttpStatus status;
private final String defaultMessage;

DashboardErrorCode(HttpStatus status, String defaultMessage) {
this.status = status;
this.defaultMessage = defaultMessage;
}

@Override
public String code() {
return name();
}

@Override
public HttpStatus status() {
return status;
}

@Override
public String defaultMessage() {
return defaultMessage;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
import java.util.UUID;

public interface TaskRepository {

Optional<Task> findByIdAndCompanyId(UUID taskId, UUID companyId);

TaskPage findAll(TaskSearchCriteria criteria);

List<Task> findOpenTasks(UUID companyId, int limit);
long countByCompanyIdAndStatus(UUID companyId, TaskStatus status);
long countOpenTasksDueOn(UUID companyId, LocalDate dueDate);
Task save(Task task);

record TaskSearchCriteria(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

import com.fowoco.server.task.application.port.TaskRepository;
import com.fowoco.server.task.domain.Task;
import com.fowoco.server.task.domain.TaskStatus;
import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
Expand Down Expand Up @@ -51,6 +54,25 @@ public TaskPage findAll(TaskSearchCriteria criteria) {
);
}

@Override
public List<Task> findOpenTasks(UUID companyId, int limit) {
Page<TaskJpaEntity> page = repository.findOpenTasksByCompanyId(
companyId,
PageRequest.of(0, limit, Sort.by(Sort.Order.asc("dueDate"), Sort.Order.desc("createdAt")))
);
return page.getContent().stream().map(TaskJpaEntity::toDomain).toList();
}

@Override
public long countByCompanyIdAndStatus(UUID companyId, TaskStatus status) {
return repository.countByCompanyIdAndStatus(companyId, status);
}

@Override
public long countOpenTasksDueOn(UUID companyId, LocalDate dueDate) {
return repository.countOpenTasksDueOn(companyId, dueDate);
}

@Override
public Task save(Task task) {
TaskJpaEntity entity = repository.findByTaskIdAndCompanyId(task.taskId(), task.companyId())
Expand Down
Loading
Loading