diff --git a/backend/src/main/java/com/srsystem/controller/EmployeeController.java b/backend/src/main/java/com/srsystem/controller/EmployeeController.java index a832407..ae4ca2a 100644 --- a/backend/src/main/java/com/srsystem/controller/EmployeeController.java +++ b/backend/src/main/java/com/srsystem/controller/EmployeeController.java @@ -237,6 +237,13 @@ public class EmployeeController { return ResponseEntity.ok(ApiResponse.ok("비상연락망이 삭제되었습니다", null)); } + @Operation(summary = "승인자 목록 조회", description = "티켓 승인자로 지정 가능한 MANAGER/ADMIN 목록을 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping("/approvers") + public ResponseEntity>> getApprovers() { + return ResponseEntity.ok(ApiResponse.ok(employeeService.getApprovers())); + } + @Operation(summary = "프로필 이미지 설정", description = "직원의 프로필 이미지 URL을 저장합니다") @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") @PutMapping("/{empId}/profile-image") diff --git a/backend/src/main/java/com/srsystem/controller/TicketController.java b/backend/src/main/java/com/srsystem/controller/TicketController.java index 0570c63..98d4cd8 100644 --- a/backend/src/main/java/com/srsystem/controller/TicketController.java +++ b/backend/src/main/java/com/srsystem/controller/TicketController.java @@ -110,6 +110,31 @@ public class TicketController { return ResponseEntity.ok(ApiResponse.ok("티켓이 삭제되었습니다", null)); } + // ---- Approval ---- + + @Operation(summary = "결재 대기 티켓 조회", description = "내가 승인자/부승인자로 지정된 PENDING_APPROVAL 티켓 목록") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @GetMapping("/pending") + public ResponseEntity>> getPendingApprovals(@CurrentUser Employee currentUser) { + return ResponseEntity.ok(ApiResponse.ok(ticketService.getPendingApprovals(currentUser.getEmpId()))); + } + + @Operation(summary = "티켓 승인", description = "PENDING_APPROVAL 상태의 티켓을 승인합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PostMapping("/{ticketId}/approve") + public ResponseEntity> approveTicket( + @PathVariable Long ticketId, @CurrentUser Employee currentUser) { + return ResponseEntity.ok(ApiResponse.ok("승인되었습니다", ticketService.approveTicket(ticketId, currentUser.getEmpId()))); + } + + @Operation(summary = "티켓 반려", description = "PENDING_APPROVAL 상태의 티켓을 반려합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PostMapping("/{ticketId}/reject") + public ResponseEntity> rejectTicket( + @PathVariable Long ticketId, @RequestBody Map body, @CurrentUser Employee currentUser) { + return ResponseEntity.ok(ApiResponse.ok("반려되었습니다", ticketService.rejectTicket(ticketId, currentUser.getEmpId(), body.get("reason")))); + } + @Operation(summary = "댓글 목록 조회", description = "티켓의 댓글 목록을 조회합니다") @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") @GetMapping("/{ticketId}/comments") diff --git a/backend/src/main/java/com/srsystem/dto/CreateTicketRequest.java b/backend/src/main/java/com/srsystem/dto/CreateTicketRequest.java index 71bca91..574ba4f 100644 --- a/backend/src/main/java/com/srsystem/dto/CreateTicketRequest.java +++ b/backend/src/main/java/com/srsystem/dto/CreateTicketRequest.java @@ -13,6 +13,8 @@ public record CreateTicketRequest( @NotBlank String title, @NotBlank String description, @Nullable String attachmentUrl, - @Nullable String attachmentName + @Nullable String attachmentName, + @Nullable Long approverId, + @Nullable Long backupApproverId ) { } diff --git a/backend/src/main/java/com/srsystem/dto/IssueTicket.java b/backend/src/main/java/com/srsystem/dto/IssueTicket.java index 8ba7f32..64eecc6 100644 --- a/backend/src/main/java/com/srsystem/dto/IssueTicket.java +++ b/backend/src/main/java/com/srsystem/dto/IssueTicket.java @@ -22,6 +22,10 @@ public record IssueTicket( boolean slaWarnFlag, LocalDateTime createdAt, String attachmentUrl, - String attachmentName + String attachmentName, + Long approverId, + Long backupApproverId, + Long approvedBy, + LocalDateTime approvedAt ) { } diff --git a/backend/src/main/java/com/srsystem/entity/Employee.java b/backend/src/main/java/com/srsystem/entity/Employee.java index 1171e66..f2db286 100644 --- a/backend/src/main/java/com/srsystem/entity/Employee.java +++ b/backend/src/main/java/com/srsystem/entity/Employee.java @@ -77,6 +77,12 @@ public class Employee extends BaseEntity { @Column(name = "school_name", length = 200) private String schoolName; + @Column(name = "manager_id") + private Long managerId; + + @Column(name = "backup_manager_id") + private Long backupManagerId; + @Column(name = "position", length = 50) private String position; diff --git a/backend/src/main/java/com/srsystem/entity/Ticket.java b/backend/src/main/java/com/srsystem/entity/Ticket.java index d15adb5..b02a2d3 100644 --- a/backend/src/main/java/com/srsystem/entity/Ticket.java +++ b/backend/src/main/java/com/srsystem/entity/Ticket.java @@ -72,6 +72,18 @@ public class Ticket extends BaseEntity { @Column(name = "attachment_name", length = 255) private String attachmentName; + @Column(name = "approver_id") + private Long approverId; + + @Column(name = "backup_approver_id") + private Long backupApproverId; + + @Column(name = "approved_by") + private Long approvedBy; + + @Column(name = "approved_at") + private LocalDateTime approvedAt; + @Column(name = "sla_warn_flag", nullable = false, length = 1) private char slaWarnFlag = 'N'; } diff --git a/backend/src/main/java/com/srsystem/entity/enums/TicketStatus.java b/backend/src/main/java/com/srsystem/entity/enums/TicketStatus.java index 8ab0cb4..319cdc3 100644 --- a/backend/src/main/java/com/srsystem/entity/enums/TicketStatus.java +++ b/backend/src/main/java/com/srsystem/entity/enums/TicketStatus.java @@ -5,5 +5,8 @@ public enum TicketStatus { ASSIGNED, IN_PROGRESS, RESOLVED, - CLOSED + CLOSED, + PENDING_APPROVAL, + APPROVED, + REJECTED } diff --git a/backend/src/main/java/com/srsystem/exception/ErrorCode.java b/backend/src/main/java/com/srsystem/exception/ErrorCode.java index 2cc582a..d9cf3bb 100644 --- a/backend/src/main/java/com/srsystem/exception/ErrorCode.java +++ b/backend/src/main/java/com/srsystem/exception/ErrorCode.java @@ -26,7 +26,8 @@ public enum ErrorCode { CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "COD_ERR_001", "공통코드를 찾을 수 없습니다"), DUPLICATE_GROUP_CODE(HttpStatus.CONFLICT, "COD_ERR_002", "이미 존재하는 그룹 코드입니다"), GROUP_HAS_CODES(HttpStatus.BAD_REQUEST, "COD_ERR_003", "그룹에 속한 코드가 있어 삭제할 수 없습니다"), - PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLN_ERR_001", "주간 계획을 찾을 수 없습니다"); + PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLN_ERR_001", "주간 계획을 찾을 수 없습니다"), + INVALID_STATUS(HttpStatus.BAD_REQUEST, "TCK_ERR_004", "처리할 수 없는 상태입니다"); private final HttpStatus httpStatus; private final String errorCode; diff --git a/backend/src/main/java/com/srsystem/repository/EmployeeRepository.java b/backend/src/main/java/com/srsystem/repository/EmployeeRepository.java index 3e6bc67..f31bbd2 100644 --- a/backend/src/main/java/com/srsystem/repository/EmployeeRepository.java +++ b/backend/src/main/java/com/srsystem/repository/EmployeeRepository.java @@ -2,6 +2,7 @@ package com.srsystem.repository; import com.srsystem.entity.Employee; import com.srsystem.entity.enums.EmpStatus; +import com.srsystem.entity.enums.RoleCode; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -19,4 +20,5 @@ public interface EmployeeRepository extends JpaRepository { Optional findByEmail(String email); List findByNameContainingIgnoreCase(String name); Page findByEmpStatus(EmpStatus status, Pageable pageable); + List findByRoleCodeIn(List roleCodes); } diff --git a/backend/src/main/java/com/srsystem/repository/TicketRepository.java b/backend/src/main/java/com/srsystem/repository/TicketRepository.java index f7e7c67..8ef4f3f 100644 --- a/backend/src/main/java/com/srsystem/repository/TicketRepository.java +++ b/backend/src/main/java/com/srsystem/repository/TicketRepository.java @@ -5,6 +5,8 @@ import com.srsystem.entity.enums.TicketStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; @@ -20,4 +22,6 @@ public interface TicketRepository extends JpaRepository { List findByRequester_EmpIdAndStatusAndResolvedAtBetween(Long requesterId, TicketStatus status, LocalDateTime start, LocalDateTime end); List findByRequester_EmpIdAndDueDateBetween(Long requesterId, LocalDateTime start, LocalDateTime end); List findByDueDateBetween(LocalDateTime start, LocalDateTime end); + @Query("SELECT t FROM Ticket t WHERE t.status IN :statuses AND (t.approverId = :approverId OR t.backupApproverId = :approverId)") + List findByStatusInAndApproverIdOrBackupApproverId(@Param("statuses") List statuses, @Param("approverId") Long approverId); } diff --git a/backend/src/main/java/com/srsystem/service/EmployeeService.java b/backend/src/main/java/com/srsystem/service/EmployeeService.java index fb36d3c..2036c74 100644 --- a/backend/src/main/java/com/srsystem/service/EmployeeService.java +++ b/backend/src/main/java/com/srsystem/service/EmployeeService.java @@ -65,6 +65,11 @@ public class EmployeeService { return results; } + public List getApprovers() { + return employeeRepository.findByRoleCodeIn(List.of(RoleCode.MANAGER, RoleCode.ADMIN)) + .stream().map(this::toSearchResult).toList(); + } + private EmployeeSearchResult toSearchResult(Employee e) { return new EmployeeSearchResult( e.getEmpId(), @@ -107,6 +112,8 @@ public class EmployeeService { employee.setProfileImage(updateRequest.getProfileImage()); employee.setSchoolName(updateRequest.getSchoolName()); employee.setPosition(updateRequest.getPosition()); + employee.setManagerId(updateRequest.getManagerId()); + employee.setBackupManagerId(updateRequest.getBackupManagerId()); return employee; } diff --git a/backend/src/main/java/com/srsystem/service/TicketService.java b/backend/src/main/java/com/srsystem/service/TicketService.java index 6970449..3cfc915 100644 --- a/backend/src/main/java/com/srsystem/service/TicketService.java +++ b/backend/src/main/java/com/srsystem/service/TicketService.java @@ -59,13 +59,20 @@ public class TicketService { Employee requester = employeeRepository.findById(requesterId) .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + boolean needsApproval = switch (request.category()) { + case VACATION, EXPENSE, EQUIPMENT_REQUEST, GENERAL -> true; + default -> false; + }; + + TicketStatus initialStatus = needsApproval ? TicketStatus.PENDING_APPROVAL : TicketStatus.OPEN; + Ticket.TicketBuilder builder = Ticket.builder() .requester(requester) .category(request.category()) .priority(request.priority()) .title(request.title()) .description(request.description()) - .status(TicketStatus.OPEN) + .status(initialStatus) .slaWarnFlag('N') .attachmentUrl(request.attachmentUrl()) .attachmentName(request.attachmentName()); @@ -76,6 +83,15 @@ public class TicketService { builder.project(project); } + if (needsApproval) { + Long approverId = request.approverId() != null ? request.approverId() : requester.getManagerId(); + builder.approverId(approverId); + Long backupId = request.backupApproverId() != null ? request.backupApproverId() : requester.getBackupManagerId(); + if (backupId != null && !backupId.equals(approverId)) { + builder.backupApproverId(backupId); + } + } + Ticket saved = ticketRepository.save(builder.build()); return toIssueTicket(saved); } @@ -185,6 +201,39 @@ public class TicketService { ticketRepository.delete(ticket); } + public List getPendingApprovals(Long approverId) { + List statuses = List.of(TicketStatus.PENDING_APPROVAL); + List tickets = ticketRepository.findByStatusInAndApproverIdOrBackupApproverId(statuses, approverId); + return tickets.stream().map(this::toIssueTicket).toList(); + } + + @Transactional + public IssueTicket approveTicket(Long ticketId, Long approverId) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + if (ticket.getStatus() != TicketStatus.PENDING_APPROVAL) { + throw new BusinessException(ErrorCode.INVALID_STATUS); + } + ticket.setStatus(TicketStatus.APPROVED); + ticket.setApprovedBy(approverId); + ticket.setApprovedAt(LocalDateTime.now()); + return toIssueTicket(ticketRepository.save(ticket)); + } + + @Transactional + public IssueTicket rejectTicket(Long ticketId, Long approverId, String reason) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + if (ticket.getStatus() != TicketStatus.PENDING_APPROVAL) { + throw new BusinessException(ErrorCode.INVALID_STATUS); + } + ticket.setStatus(TicketStatus.REJECTED); + ticket.setResolutionNote(reason); + ticket.setApprovedBy(approverId); + ticket.setApprovedAt(LocalDateTime.now()); + return toIssueTicket(ticketRepository.save(ticket)); + } + private IssueTicket toIssueTicket(Ticket ticket) { return new IssueTicket( ticket.getTicketId(), @@ -202,7 +251,11 @@ public class TicketService { ticket.getSlaWarnFlag() == 'Y', ticket.getCreatedAt(), ticket.getAttachmentUrl(), - ticket.getAttachmentName() + ticket.getAttachmentName(), + ticket.getApproverId(), + ticket.getBackupApproverId(), + ticket.getApprovedBy(), + ticket.getApprovedAt() ); } } diff --git a/backend/src/main/resources/db/migration/V21__add_approval.sql b/backend/src/main/resources/db/migration/V21__add_approval.sql new file mode 100644 index 0000000..7f1c2bc --- /dev/null +++ b/backend/src/main/resources/db/migration/V21__add_approval.sql @@ -0,0 +1,11 @@ +ALTER TABLE tb_employee ADD COLUMN manager_id BIGINT REFERENCES tb_employee(id); +ALTER TABLE tb_employee ADD COLUMN backup_manager_id BIGINT REFERENCES tb_employee(id); + +ALTER TABLE tb_ticket ADD COLUMN approver_id BIGINT REFERENCES tb_employee(id); +ALTER TABLE tb_ticket ADD COLUMN backup_approver_id BIGINT REFERENCES tb_employee(id); +ALTER TABLE tb_ticket ADD COLUMN approved_by BIGINT REFERENCES tb_employee(id); +ALTER TABLE tb_ticket ADD COLUMN approved_at TIMESTAMP; + +ALTER TABLE tb_ticket DROP CONSTRAINT IF EXISTS tb_ticket_status_check; +ALTER TABLE tb_ticket ADD CONSTRAINT tb_ticket_status_check + CHECK (status IN ('OPEN','ASSIGNED','IN_PROGRESS','RESOLVED','CLOSED','PENDING_APPROVAL','APPROVED','REJECTED')); diff --git a/src/app/(main)/issues/[id]/page.tsx b/src/app/(main)/issues/[id]/page.tsx index cb8849b..6f4b19c 100644 --- a/src/app/(main)/issues/[id]/page.tsx +++ b/src/app/(main)/issues/[id]/page.tsx @@ -4,18 +4,26 @@ import React, { useState, useEffect } from 'react'; import { ChevronLeft, Clock, User, MessageSquare, Paperclip, Send, CheckCircle2, ArrowRight } from 'lucide-react'; import Link from 'next/link'; import ticketService from '@/services/ticketService'; +import apiClient from '@/services/apiClient'; +import { useAuthStore } from '@/stores'; import type { IssueTicket, TicketComment } from '@/types'; const statusFlow = ['OPEN', 'ASSIGNED', 'IN_PROGRESS', 'RESOLVED', 'CLOSED']; +const approvalStatuses = ['PENDING_APPROVAL', 'APPROVED', 'REJECTED']; + const statusColor: Record = { OPEN: 'bg-status-open-bg text-status-open-text', ASSIGNED: 'bg-status-pending-bg text-status-pending-text', IN_PROGRESS: 'bg-status-pending-bg text-status-pending-text', RESOLVED: 'bg-status-resolved-bg text-status-resolved-text', CLOSED: 'bg-gray-100 text-text-muted', + PENDING_APPROVAL: 'bg-amber-50 text-amber-700', + APPROVED: 'bg-emerald-50 text-emerald-700', + REJECTED: 'bg-red-50 text-red-700', }; const statusLabel: Record = { OPEN: 'Open', ASSIGNED: 'Assigned', IN_PROGRESS: 'In Progress', RESOLVED: 'Resolved', CLOSED: 'Closed', + PENDING_APPROVAL: '결재 대기', APPROVED: '승인 완료', REJECTED: '반려', }; const categoryLabel: Record = { @@ -41,10 +49,16 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri const ticketId = parseInt(resolvedParams.id); const [ticket, setTicket] = useState(null); const [comments, setComments] = useState([]); - const [newComment, setNewComment] = useState(''); const [status, setStatus] = useState('OPEN'); const [loading, setLoading] = useState(true); + const [newComment, setNewComment] = useState(''); + + const [showRejectDialog, setShowRejectDialog] = useState(false); + const [rejectReason, setRejectReason] = useState(''); + + const user = useAuthStore((s) => s.user); + useEffect(() => { Promise.all([ ticketService.getTicket(ticketId), @@ -80,6 +94,24 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri } catch { alert('댓글 등록 실패'); } }; + const handleApprove = async () => { + try { + const res: any = await apiClient.post(`/tickets/${ticketId}/approve`); + if (res.success) setTicket(res.data); + } catch (err: any) { alert(err.message || '승인 실패'); } + }; + + const handleReject = async () => { + try { + const res: any = await apiClient.post(`/tickets/${ticketId}/reject`, { reason: rejectReason }); + if (res.success) { + setTicket(res.data); + setShowRejectDialog(false); + setRejectReason(''); + } + } catch (err: any) { alert(err.message || '반려 실패'); } + }; + if (loading) return
Loading...
; if (!ticket) return
Ticket not found
; @@ -133,6 +165,24 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri )} + {ticket.status === 'PENDING_APPROVAL' && ( +
+

⏳ 결재 대기

+

승인자가 검토 중입니다.

+
+ )} + {ticket.status === 'APPROVED' && ( +
+

✅ 승인 완료

+
+ )} + {ticket.status === 'REJECTED' && ( +
+

❌ 반려

+ {ticket.resolutionNote &&

사유: {ticket.resolutionNote}

} +
+ )} + {ticket.category === 'VACATION' && (

휴가 신청

@@ -229,6 +279,20 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
+ {ticket.status === 'PENDING_APPROVAL' && user?.roleCode !== 'EMPLOYEE' && ( +
+

결재 처리

+
+ + +
+
+ )} +

Progress

@@ -257,6 +321,25 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
+ + {showRejectDialog && ( +
+
+

반려 사유

+