feat: approval workflow with approver/backupApprover
This commit is contained in:
@@ -237,6 +237,13 @@ public class EmployeeController {
|
|||||||
return ResponseEntity.ok(ApiResponse.ok("비상연락망이 삭제되었습니다", null));
|
return ResponseEntity.ok(ApiResponse.ok("비상연락망이 삭제되었습니다", null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "승인자 목록 조회", description = "티켓 승인자로 지정 가능한 MANAGER/ADMIN 목록을 조회합니다")
|
||||||
|
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
|
||||||
|
@GetMapping("/approvers")
|
||||||
|
public ResponseEntity<ApiResponse<List<EmployeeSearchResult>>> getApprovers() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(employeeService.getApprovers()));
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "프로필 이미지 설정", description = "직원의 프로필 이미지 URL을 저장합니다")
|
@Operation(summary = "프로필 이미지 설정", description = "직원의 프로필 이미지 URL을 저장합니다")
|
||||||
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
|
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
|
||||||
@PutMapping("/{empId}/profile-image")
|
@PutMapping("/{empId}/profile-image")
|
||||||
|
|||||||
@@ -110,6 +110,31 @@ public class TicketController {
|
|||||||
return ResponseEntity.ok(ApiResponse.ok("티켓이 삭제되었습니다", null));
|
return ResponseEntity.ok(ApiResponse.ok("티켓이 삭제되었습니다", null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Approval ----
|
||||||
|
|
||||||
|
@Operation(summary = "결재 대기 티켓 조회", description = "내가 승인자/부승인자로 지정된 PENDING_APPROVAL 티켓 목록")
|
||||||
|
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
|
||||||
|
@GetMapping("/pending")
|
||||||
|
public ResponseEntity<ApiResponse<List<IssueTicket>>> 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<ApiResponse<IssueTicket>> 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<ApiResponse<IssueTicket>> rejectTicket(
|
||||||
|
@PathVariable Long ticketId, @RequestBody Map<String, String> body, @CurrentUser Employee currentUser) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok("반려되었습니다", ticketService.rejectTicket(ticketId, currentUser.getEmpId(), body.get("reason"))));
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "댓글 목록 조회", description = "티켓의 댓글 목록을 조회합니다")
|
@Operation(summary = "댓글 목록 조회", description = "티켓의 댓글 목록을 조회합니다")
|
||||||
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
|
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
|
||||||
@GetMapping("/{ticketId}/comments")
|
@GetMapping("/{ticketId}/comments")
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public record CreateTicketRequest(
|
|||||||
@NotBlank String title,
|
@NotBlank String title,
|
||||||
@NotBlank String description,
|
@NotBlank String description,
|
||||||
@Nullable String attachmentUrl,
|
@Nullable String attachmentUrl,
|
||||||
@Nullable String attachmentName
|
@Nullable String attachmentName,
|
||||||
|
@Nullable Long approverId,
|
||||||
|
@Nullable Long backupApproverId
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public record IssueTicket(
|
|||||||
boolean slaWarnFlag,
|
boolean slaWarnFlag,
|
||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
String attachmentUrl,
|
String attachmentUrl,
|
||||||
String attachmentName
|
String attachmentName,
|
||||||
|
Long approverId,
|
||||||
|
Long backupApproverId,
|
||||||
|
Long approvedBy,
|
||||||
|
LocalDateTime approvedAt
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ public class Employee extends BaseEntity {
|
|||||||
@Column(name = "school_name", length = 200)
|
@Column(name = "school_name", length = 200)
|
||||||
private String schoolName;
|
private String schoolName;
|
||||||
|
|
||||||
|
@Column(name = "manager_id")
|
||||||
|
private Long managerId;
|
||||||
|
|
||||||
|
@Column(name = "backup_manager_id")
|
||||||
|
private Long backupManagerId;
|
||||||
|
|
||||||
@Column(name = "position", length = 50)
|
@Column(name = "position", length = 50)
|
||||||
private String position;
|
private String position;
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,18 @@ public class Ticket extends BaseEntity {
|
|||||||
@Column(name = "attachment_name", length = 255)
|
@Column(name = "attachment_name", length = 255)
|
||||||
private String attachmentName;
|
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)
|
@Column(name = "sla_warn_flag", nullable = false, length = 1)
|
||||||
private char slaWarnFlag = 'N';
|
private char slaWarnFlag = 'N';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,8 @@ public enum TicketStatus {
|
|||||||
ASSIGNED,
|
ASSIGNED,
|
||||||
IN_PROGRESS,
|
IN_PROGRESS,
|
||||||
RESOLVED,
|
RESOLVED,
|
||||||
CLOSED
|
CLOSED,
|
||||||
|
PENDING_APPROVAL,
|
||||||
|
APPROVED,
|
||||||
|
REJECTED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ public enum ErrorCode {
|
|||||||
CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "COD_ERR_001", "공통코드를 찾을 수 없습니다"),
|
CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "COD_ERR_001", "공통코드를 찾을 수 없습니다"),
|
||||||
DUPLICATE_GROUP_CODE(HttpStatus.CONFLICT, "COD_ERR_002", "이미 존재하는 그룹 코드입니다"),
|
DUPLICATE_GROUP_CODE(HttpStatus.CONFLICT, "COD_ERR_002", "이미 존재하는 그룹 코드입니다"),
|
||||||
GROUP_HAS_CODES(HttpStatus.BAD_REQUEST, "COD_ERR_003", "그룹에 속한 코드가 있어 삭제할 수 없습니다"),
|
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 HttpStatus httpStatus;
|
||||||
private final String errorCode;
|
private final String errorCode;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.srsystem.repository;
|
|||||||
|
|
||||||
import com.srsystem.entity.Employee;
|
import com.srsystem.entity.Employee;
|
||||||
import com.srsystem.entity.enums.EmpStatus;
|
import com.srsystem.entity.enums.EmpStatus;
|
||||||
|
import com.srsystem.entity.enums.RoleCode;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
@@ -19,4 +20,5 @@ public interface EmployeeRepository extends JpaRepository<Employee, Long> {
|
|||||||
Optional<Employee> findByEmail(String email);
|
Optional<Employee> findByEmail(String email);
|
||||||
List<Employee> findByNameContainingIgnoreCase(String name);
|
List<Employee> findByNameContainingIgnoreCase(String name);
|
||||||
Page<Employee> findByEmpStatus(EmpStatus status, Pageable pageable);
|
Page<Employee> findByEmpStatus(EmpStatus status, Pageable pageable);
|
||||||
|
List<Employee> findByRoleCodeIn(List<RoleCode> roleCodes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import com.srsystem.entity.enums.TicketStatus;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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 org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -20,4 +22,6 @@ public interface TicketRepository extends JpaRepository<Ticket, Long> {
|
|||||||
List<Ticket> findByRequester_EmpIdAndStatusAndResolvedAtBetween(Long requesterId, TicketStatus status, LocalDateTime start, LocalDateTime end);
|
List<Ticket> findByRequester_EmpIdAndStatusAndResolvedAtBetween(Long requesterId, TicketStatus status, LocalDateTime start, LocalDateTime end);
|
||||||
List<Ticket> findByRequester_EmpIdAndDueDateBetween(Long requesterId, LocalDateTime start, LocalDateTime end);
|
List<Ticket> findByRequester_EmpIdAndDueDateBetween(Long requesterId, LocalDateTime start, LocalDateTime end);
|
||||||
List<Ticket> findByDueDateBetween(LocalDateTime start, LocalDateTime end);
|
List<Ticket> 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<Ticket> findByStatusInAndApproverIdOrBackupApproverId(@Param("statuses") List<TicketStatus> statuses, @Param("approverId") Long approverId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ public class EmployeeService {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<EmployeeSearchResult> getApprovers() {
|
||||||
|
return employeeRepository.findByRoleCodeIn(List.of(RoleCode.MANAGER, RoleCode.ADMIN))
|
||||||
|
.stream().map(this::toSearchResult).toList();
|
||||||
|
}
|
||||||
|
|
||||||
private EmployeeSearchResult toSearchResult(Employee e) {
|
private EmployeeSearchResult toSearchResult(Employee e) {
|
||||||
return new EmployeeSearchResult(
|
return new EmployeeSearchResult(
|
||||||
e.getEmpId(),
|
e.getEmpId(),
|
||||||
@@ -107,6 +112,8 @@ public class EmployeeService {
|
|||||||
employee.setProfileImage(updateRequest.getProfileImage());
|
employee.setProfileImage(updateRequest.getProfileImage());
|
||||||
employee.setSchoolName(updateRequest.getSchoolName());
|
employee.setSchoolName(updateRequest.getSchoolName());
|
||||||
employee.setPosition(updateRequest.getPosition());
|
employee.setPosition(updateRequest.getPosition());
|
||||||
|
employee.setManagerId(updateRequest.getManagerId());
|
||||||
|
employee.setBackupManagerId(updateRequest.getBackupManagerId());
|
||||||
|
|
||||||
return employee;
|
return employee;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,13 +59,20 @@ public class TicketService {
|
|||||||
Employee requester = employeeRepository.findById(requesterId)
|
Employee requester = employeeRepository.findById(requesterId)
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND));
|
.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()
|
Ticket.TicketBuilder builder = Ticket.builder()
|
||||||
.requester(requester)
|
.requester(requester)
|
||||||
.category(request.category())
|
.category(request.category())
|
||||||
.priority(request.priority())
|
.priority(request.priority())
|
||||||
.title(request.title())
|
.title(request.title())
|
||||||
.description(request.description())
|
.description(request.description())
|
||||||
.status(TicketStatus.OPEN)
|
.status(initialStatus)
|
||||||
.slaWarnFlag('N')
|
.slaWarnFlag('N')
|
||||||
.attachmentUrl(request.attachmentUrl())
|
.attachmentUrl(request.attachmentUrl())
|
||||||
.attachmentName(request.attachmentName());
|
.attachmentName(request.attachmentName());
|
||||||
@@ -76,6 +83,15 @@ public class TicketService {
|
|||||||
builder.project(project);
|
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());
|
Ticket saved = ticketRepository.save(builder.build());
|
||||||
return toIssueTicket(saved);
|
return toIssueTicket(saved);
|
||||||
}
|
}
|
||||||
@@ -185,6 +201,39 @@ public class TicketService {
|
|||||||
ticketRepository.delete(ticket);
|
ticketRepository.delete(ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<IssueTicket> getPendingApprovals(Long approverId) {
|
||||||
|
List<TicketStatus> statuses = List.of(TicketStatus.PENDING_APPROVAL);
|
||||||
|
List<Ticket> 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) {
|
private IssueTicket toIssueTicket(Ticket ticket) {
|
||||||
return new IssueTicket(
|
return new IssueTicket(
|
||||||
ticket.getTicketId(),
|
ticket.getTicketId(),
|
||||||
@@ -202,7 +251,11 @@ public class TicketService {
|
|||||||
ticket.getSlaWarnFlag() == 'Y',
|
ticket.getSlaWarnFlag() == 'Y',
|
||||||
ticket.getCreatedAt(),
|
ticket.getCreatedAt(),
|
||||||
ticket.getAttachmentUrl(),
|
ticket.getAttachmentUrl(),
|
||||||
ticket.getAttachmentName()
|
ticket.getAttachmentName(),
|
||||||
|
ticket.getApproverId(),
|
||||||
|
ticket.getBackupApproverId(),
|
||||||
|
ticket.getApprovedBy(),
|
||||||
|
ticket.getApprovedAt()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
@@ -4,18 +4,26 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { ChevronLeft, Clock, User, MessageSquare, Paperclip, Send, CheckCircle2, ArrowRight } from 'lucide-react';
|
import { ChevronLeft, Clock, User, MessageSquare, Paperclip, Send, CheckCircle2, ArrowRight } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import ticketService from '@/services/ticketService';
|
import ticketService from '@/services/ticketService';
|
||||||
|
import apiClient from '@/services/apiClient';
|
||||||
|
import { useAuthStore } from '@/stores';
|
||||||
import type { IssueTicket, TicketComment } from '@/types';
|
import type { IssueTicket, TicketComment } from '@/types';
|
||||||
|
|
||||||
const statusFlow = ['OPEN', 'ASSIGNED', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'];
|
const statusFlow = ['OPEN', 'ASSIGNED', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'];
|
||||||
|
const approvalStatuses = ['PENDING_APPROVAL', 'APPROVED', 'REJECTED'];
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
const statusColor: Record<string, string> = {
|
||||||
OPEN: 'bg-status-open-bg text-status-open-text',
|
OPEN: 'bg-status-open-bg text-status-open-text',
|
||||||
ASSIGNED: 'bg-status-pending-bg text-status-pending-text',
|
ASSIGNED: 'bg-status-pending-bg text-status-pending-text',
|
||||||
IN_PROGRESS: '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',
|
RESOLVED: 'bg-status-resolved-bg text-status-resolved-text',
|
||||||
CLOSED: 'bg-gray-100 text-text-muted',
|
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<string, string> = {
|
const statusLabel: Record<string, string> = {
|
||||||
OPEN: 'Open', ASSIGNED: 'Assigned', IN_PROGRESS: 'In Progress', RESOLVED: 'Resolved', CLOSED: 'Closed',
|
OPEN: 'Open', ASSIGNED: 'Assigned', IN_PROGRESS: 'In Progress', RESOLVED: 'Resolved', CLOSED: 'Closed',
|
||||||
|
PENDING_APPROVAL: '결재 대기', APPROVED: '승인 완료', REJECTED: '반려',
|
||||||
};
|
};
|
||||||
|
|
||||||
const categoryLabel: Record<string, string> = {
|
const categoryLabel: Record<string, string> = {
|
||||||
@@ -41,10 +49,16 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
|
|||||||
const ticketId = parseInt(resolvedParams.id);
|
const ticketId = parseInt(resolvedParams.id);
|
||||||
const [ticket, setTicket] = useState<IssueTicket | null>(null);
|
const [ticket, setTicket] = useState<IssueTicket | null>(null);
|
||||||
const [comments, setComments] = useState<TicketComment[]>([]);
|
const [comments, setComments] = useState<TicketComment[]>([]);
|
||||||
const [newComment, setNewComment] = useState('');
|
|
||||||
const [status, setStatus] = useState('OPEN');
|
const [status, setStatus] = useState('OPEN');
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
ticketService.getTicket(ticketId),
|
ticketService.getTicket(ticketId),
|
||||||
@@ -80,6 +94,24 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
|
|||||||
} catch { alert('댓글 등록 실패'); }
|
} 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 <div className="flex items-center justify-center h-screen text-text-muted text-sm">Loading...</div>;
|
if (loading) return <div className="flex items-center justify-center h-screen text-text-muted text-sm">Loading...</div>;
|
||||||
if (!ticket) return <div className="flex items-center justify-center h-screen text-text-muted text-sm">Ticket not found</div>;
|
if (!ticket) return <div className="flex items-center justify-center h-screen text-text-muted text-sm">Ticket not found</div>;
|
||||||
|
|
||||||
@@ -133,6 +165,24 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ticket.status === 'PENDING_APPROVAL' && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-xl px-4 py-3 text-sm">
|
||||||
|
<p className="font-semibold text-amber-800">⏳ 결재 대기</p>
|
||||||
|
<p className="text-amber-700 mt-1">승인자가 검토 중입니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ticket.status === 'APPROVED' && (
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 rounded-xl px-4 py-3 text-sm">
|
||||||
|
<p className="font-semibold text-emerald-800">✅ 승인 완료</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ticket.status === 'REJECTED' && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm">
|
||||||
|
<p className="font-semibold text-red-800">❌ 반려</p>
|
||||||
|
{ticket.resolutionNote && <p className="text-red-700 mt-1">사유: {ticket.resolutionNote}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{ticket.category === 'VACATION' && (
|
{ticket.category === 'VACATION' && (
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-xl px-4 py-3 text-sm">
|
<div className="bg-amber-50 border border-amber-200 rounded-xl px-4 py-3 text-sm">
|
||||||
<p className="font-semibold text-amber-800">휴가 신청</p>
|
<p className="font-semibold text-amber-800">휴가 신청</p>
|
||||||
@@ -229,6 +279,20 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{ticket.status === 'PENDING_APPROVAL' && user?.roleCode !== 'EMPLOYEE' && (
|
||||||
|
<div className="bg-surface rounded-2xl border border-border p-4 sm:p-5">
|
||||||
|
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">결재 처리</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button onClick={handleApprove} className="w-full bg-emerald-500 text-white px-4 py-2 rounded-xl text-sm font-semibold hover:bg-emerald-600 transition-colors">
|
||||||
|
✅ Approve
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowRejectDialog(true)} className="w-full bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded-xl text-sm font-semibold hover:bg-red-100 transition-colors">
|
||||||
|
❌ Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bg-surface rounded-2xl border border-border p-4 sm:p-5">
|
<div className="bg-surface rounded-2xl border border-border p-4 sm:p-5">
|
||||||
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">Progress</h4>
|
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">Progress</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -257,6 +321,25 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showRejectDialog && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl max-w-sm w-full mx-4 p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-1">반려 사유</h3>
|
||||||
|
<textarea
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-lg text-sm mt-3 mb-4 focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||||
|
placeholder="반려 사유를 입력하세요"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setShowRejectDialog(false)} className="flex-1 px-4 py-2 rounded-xl border border-border text-sm hover:bg-gray-50 transition-colors">취소</button>
|
||||||
|
<button onClick={handleReject} disabled={!rejectReason.trim()} className="flex-1 px-4 py-2 rounded-xl bg-red-500 text-white text-sm font-semibold hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">반려</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+200
-72
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { Search, Plus, X } from 'lucide-react';
|
import { Search, Plus, X, ListChecks } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import TicketCard from '@/components/issues/KanbanBoard';
|
import TicketCard from '@/components/issues/KanbanBoard';
|
||||||
@@ -15,6 +15,9 @@ const columns = [
|
|||||||
{ key: 'OPEN', label: 'Open', color: 'bg-status-open-text' },
|
{ key: 'OPEN', label: 'Open', color: 'bg-status-open-text' },
|
||||||
{ key: 'ASSIGNED', label: 'Assigned', color: 'bg-status-pending-text' },
|
{ key: 'ASSIGNED', label: 'Assigned', color: 'bg-status-pending-text' },
|
||||||
{ key: 'IN_PROGRESS', label: 'In Progress', color: 'bg-status-pending-text' },
|
{ key: 'IN_PROGRESS', label: 'In Progress', color: 'bg-status-pending-text' },
|
||||||
|
{ key: 'PENDING_APPROVAL', label: 'Pending Approval', color: 'bg-amber-500' },
|
||||||
|
{ key: 'APPROVED', label: 'Approved', color: 'bg-emerald-500' },
|
||||||
|
{ key: 'REJECTED', label: 'Rejected', color: 'bg-red-500' },
|
||||||
{ key: 'DONE', label: 'Done', color: 'bg-status-resolved-text' },
|
{ key: 'DONE', label: 'Done', color: 'bg-status-resolved-text' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -28,6 +31,7 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
|||||||
});
|
});
|
||||||
const [extraFields, setExtraFields] = useState<Record<string, string>>({});
|
const [extraFields, setExtraFields] = useState<Record<string, string>>({});
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [approvers, setApprovers] = useState<{empId: number; name: string; email: string}[]>([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
@@ -35,6 +39,9 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
|||||||
apiClient.get<{ success: boolean; data: Project[] }>('/projects')
|
apiClient.get<{ success: boolean; data: Project[] }>('/projects')
|
||||||
.then((res) => setProjects(res.data))
|
.then((res) => setProjects(res.data))
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
|
apiClient.get<{ success: boolean; data: {empId: number; name: string; email: string}[] }>('/employees/approvers')
|
||||||
|
.then((res) => setApprovers(res.data))
|
||||||
|
.catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateField = (field: string, value: string) => {
|
const updateField = (field: string, value: string) => {
|
||||||
@@ -59,6 +66,8 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const needsApprover = ['VACATION', 'EXPENSE', 'EQUIPMENT_REQUEST', 'GENERAL'].includes(formData.category);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
@@ -77,6 +86,9 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
|||||||
if (!extraFields.equipmentType?.trim()) errors.equipmentType = '장비 유형을 입력해주세요';
|
if (!extraFields.equipmentType?.trim()) errors.equipmentType = '장비 유형을 입력해주세요';
|
||||||
if (!extraFields.urgency) errors.urgency = '긴급도를 선택해주세요';
|
if (!extraFields.urgency) errors.urgency = '긴급도를 선택해주세요';
|
||||||
}
|
}
|
||||||
|
if (needsApprover && !extraFields.approverId) {
|
||||||
|
errors.approverId = '승인자를 선택해주세요';
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(errors).length > 0) {
|
if (Object.keys(errors).length > 0) {
|
||||||
setFieldErrors(errors);
|
setFieldErrors(errors);
|
||||||
@@ -111,6 +123,8 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
|||||||
projectId: formData.projectId ? parseInt(formData.projectId) : undefined,
|
projectId: formData.projectId ? parseInt(formData.projectId) : undefined,
|
||||||
attachmentUrl: extraFields.receiptUrl || undefined,
|
attachmentUrl: extraFields.receiptUrl || undefined,
|
||||||
attachmentName: extraFields.receiptName || undefined,
|
attachmentName: extraFields.receiptName || undefined,
|
||||||
|
approverId: extraFields.approverId ? parseInt(extraFields.approverId) : undefined,
|
||||||
|
backupApproverId: extraFields.backupApproverId ? parseInt(extraFields.backupApproverId) : undefined,
|
||||||
});
|
});
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
@@ -175,6 +189,8 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
|||||||
delete next.amount;
|
delete next.amount;
|
||||||
delete next.equipmentType;
|
delete next.equipmentType;
|
||||||
delete next.urgency;
|
delete next.urgency;
|
||||||
|
delete next.approverId;
|
||||||
|
delete next.backupApproverId;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -337,6 +353,41 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Approver dropdowns for VACATION/EXPENSE/EQUIPMENT_REQUEST/GENERAL */}
|
||||||
|
{needsApprover && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-main mb-1.5">승인자 (사수) <span className="text-red-500">*</span></label>
|
||||||
|
<select
|
||||||
|
value={extraFields.approverId || ''}
|
||||||
|
onChange={(e) => updateExtraField('approverId', e.target.value)}
|
||||||
|
className={`w-full px-3 py-2.5 border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary ${
|
||||||
|
fieldErrors.approverId ? 'border-error' : 'border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
{approvers.map((a) => (
|
||||||
|
<option key={a.empId} value={a.empId}>{a.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{renderFieldError('approverId')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-main mb-1.5">부승인자 (부사수, 선택)</label>
|
||||||
|
<select
|
||||||
|
value={extraFields.backupApproverId || ''}
|
||||||
|
onChange={(e) => updateExtraField('backupApproverId', e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="">선택 안 함</option>
|
||||||
|
{approvers.map((a) => (
|
||||||
|
<option key={a.empId} value={a.empId}>{a.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-main mb-1.5">
|
<label className="block text-sm font-medium text-text-main mb-1.5">
|
||||||
Description <span className="text-red-500">*</span>
|
Description <span className="text-red-500">*</span>
|
||||||
@@ -385,6 +436,10 @@ export default function IssuesPage() {
|
|||||||
const [filterPriority, setFilterPriority] = useState('ALL');
|
const [filterPriority, setFilterPriority] = useState('ALL');
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [mobileColumn, setMobileColumn] = useState<string>(columns[0].key);
|
const [mobileColumn, setMobileColumn] = useState<string>(columns[0].key);
|
||||||
|
const [approvalMode, setApprovalMode] = useState(false);
|
||||||
|
const [pendingTickets, setPendingTickets] = useState<IssueTicket[]>([]);
|
||||||
|
const [pendingLoading, setPendingLoading] = useState(false);
|
||||||
|
const [userRole, setUserRole] = useState<string>('');
|
||||||
|
|
||||||
const { codes: priorities } = useCommonCodes('TICKET_PRIORITY');
|
const { codes: priorities } = useCommonCodes('TICKET_PRIORITY');
|
||||||
const { codes: categories } = useCommonCodes('TICKET_CATEGORY');
|
const { codes: categories } = useCommonCodes('TICKET_CATEGORY');
|
||||||
@@ -394,6 +449,23 @@ export default function IssuesPage() {
|
|||||||
if (q) setSearchKeyword(q);
|
if (q) setSearchKeyword(q);
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Fetch user role on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const storedRole = localStorage.getItem('userRole');
|
||||||
|
if (storedRole) setUserRole(storedRole);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch pending approvals when approvalMode toggles on
|
||||||
|
useEffect(() => {
|
||||||
|
if (approvalMode) {
|
||||||
|
setPendingLoading(true);
|
||||||
|
apiClient.get<{ success: boolean; data: IssueTicket[] }>('/tickets/pending')
|
||||||
|
.then((res) => setPendingTickets(res.data))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setPendingLoading(false));
|
||||||
|
}
|
||||||
|
}, [approvalMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ticketService.getTickets({ size: 100 }).then((res) => {
|
ticketService.getTickets({ size: 100 }).then((res) => {
|
||||||
setTickets(res.data.content);
|
setTickets(res.data.content);
|
||||||
@@ -433,6 +505,8 @@ export default function IssuesPage() {
|
|||||||
}
|
}
|
||||||
}, [grouped]);
|
}, [grouped]);
|
||||||
|
|
||||||
|
const isManager = userRole === 'MANAGER' || userRole === 'ADMIN';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 lg:p-8">
|
<div className="p-4 sm:p-6 lg:p-8">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
@@ -442,6 +516,20 @@ export default function IssuesPage() {
|
|||||||
<p className="text-text-muted text-sm mt-0.5">Track and manage field support requests</p>
|
<p className="text-text-muted text-sm mt-0.5">Track and manage field support requests</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
|
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
|
||||||
|
{/* Approval mode toggle — only for MANAGER/ADMIN */}
|
||||||
|
{isManager && (
|
||||||
|
<button
|
||||||
|
onClick={() => setApprovalMode(!approvalMode)}
|
||||||
|
className={`flex items-center space-x-2 px-4 py-2.5 text-sm font-medium rounded-lg transition-colors flex-1 sm:flex-none justify-center ${
|
||||||
|
approvalMode
|
||||||
|
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||||
|
: 'bg-white border border-border text-text-main hover:bg-surface-container'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListChecks className="w-4 h-4" />
|
||||||
|
<span>{approvalMode ? 'All Tickets' : 'Pending Approvals'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
className="flex items-center space-x-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity flex-1 sm:flex-none justify-center"
|
className="flex items-center space-x-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity flex-1 sm:flex-none justify-center"
|
||||||
@@ -477,80 +565,120 @@ export default function IssuesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{/* Approval Mode View */}
|
||||||
<div className="flex items-center justify-center h-64 text-text-muted text-sm">Loading tickets...</div>
|
{approvalMode ? (
|
||||||
) : (
|
<div>
|
||||||
<>
|
<h3 className="font-outfit text-lg font-semibold text-text-main mb-4">Pending Approvals ({pendingTickets.length})</h3>
|
||||||
{/* Mobile: column selector */}
|
{pendingLoading ? (
|
||||||
<div className="md:hidden mb-4">
|
<div className="flex items-center justify-center h-48 text-text-muted text-sm">Loading pending approvals...</div>
|
||||||
<select
|
) : pendingTickets.length === 0 ? (
|
||||||
value={mobileColumn}
|
<div className="flex items-center justify-center h-48 text-text-muted text-sm bg-gray-50/30 rounded-2xl">
|
||||||
onChange={(e) => setMobileColumn(e.target.value)}
|
No pending approvals
|
||||||
className="w-full px-4 py-2.5 rounded-xl bg-white border border-border text-sm"
|
</div>
|
||||||
>
|
) : (
|
||||||
{columns.map((col) => (
|
<div className="space-y-3">
|
||||||
<option key={col.key} value={col.key}>
|
{pendingTickets.map((ticket) => (
|
||||||
{col.label} ({grouped[col.key]?.length || 0})
|
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
|
||||||
</option>
|
<div className="bg-white border border-border rounded-xl p-4 hover:bg-surface-container-low transition-colors cursor-pointer">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs font-mono text-text-muted">#{ticket.ticketId}</span>
|
||||||
|
<span className="font-medium text-text-main">{ticket.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-text-muted">{ticket.requesterName}</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
ticket.priority === 'CRITICAL' ? 'bg-status-critical-bg text-status-critical' :
|
||||||
|
ticket.priority === 'HIGH' ? 'bg-amber-50 text-amber-700' :
|
||||||
|
'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{ticket.priority}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Kanban View */
|
||||||
|
loading ? (
|
||||||
|
<div className="flex items-center justify-center h-64 text-text-muted text-sm">Loading tickets...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Mobile: column selector */}
|
||||||
|
<div className="md:hidden mb-4">
|
||||||
|
<select
|
||||||
|
value={mobileColumn}
|
||||||
|
onChange={(e) => setMobileColumn(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl bg-white border border-border text-sm"
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<option key={col.key} value={col.key}>
|
||||||
|
{col.label} ({grouped[col.key]?.length || 0})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Desktop: all columns */}
|
{/* Desktop: all columns */}
|
||||||
<div className="hidden md:flex gap-4 overflow-x-auto pb-2">
|
<div className="hidden md:flex gap-4 overflow-x-auto pb-2">
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<div key={col.key} className="flex-1 min-w-[220px]">
|
<div key={col.key} className="flex-1 min-w-[220px]">
|
||||||
<KanbanColumn title={col.label} count={grouped[col.key].length} color={col.color}>
|
<KanbanColumn title={col.label} count={grouped[col.key].length} color={col.color}>
|
||||||
{grouped[col.key].map((ticket) => (
|
{grouped[col.key].map((ticket) => (
|
||||||
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
|
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
|
||||||
<TicketCard
|
<TicketCard
|
||||||
ticket={{
|
ticket={{
|
||||||
ticketId: ticket.ticketId,
|
ticketId: ticket.ticketId,
|
||||||
title: ticket.title,
|
title: ticket.title,
|
||||||
projectName: ticket.projectName,
|
projectName: ticket.projectName,
|
||||||
requesterName: ticket.requesterName,
|
requesterName: ticket.requesterName,
|
||||||
assigneeName: ticket.assigneeName,
|
assigneeName: ticket.assigneeName,
|
||||||
priority: ticket.priority,
|
priority: ticket.priority,
|
||||||
category: ticket.category,
|
category: ticket.category,
|
||||||
status: ticket.status,
|
status: ticket.status,
|
||||||
dueDate: ticket.dueDate,
|
dueDate: ticket.dueDate,
|
||||||
isImminent: ticket.isImminent,
|
isImminent: ticket.isImminent,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</KanbanColumn>
|
</KanbanColumn>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: single column */}
|
{/* Mobile: single column */}
|
||||||
<div className="md:hidden space-y-3">
|
<div className="md:hidden space-y-3">
|
||||||
{(grouped[mobileColumn] || []).map((ticket) => (
|
{(grouped[mobileColumn] || []).map((ticket) => (
|
||||||
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
|
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
|
||||||
<TicketCard
|
<TicketCard
|
||||||
ticket={{
|
ticket={{
|
||||||
ticketId: ticket.ticketId,
|
ticketId: ticket.ticketId,
|
||||||
title: ticket.title,
|
title: ticket.title,
|
||||||
projectName: ticket.projectName,
|
projectName: ticket.projectName,
|
||||||
requesterName: ticket.requesterName,
|
requesterName: ticket.requesterName,
|
||||||
assigneeName: ticket.assigneeName,
|
assigneeName: ticket.assigneeName,
|
||||||
priority: ticket.priority,
|
priority: ticket.priority,
|
||||||
category: ticket.category,
|
category: ticket.category,
|
||||||
status: ticket.status,
|
status: ticket.status,
|
||||||
dueDate: ticket.dueDate,
|
dueDate: ticket.dueDate,
|
||||||
isImminent: ticket.isImminent,
|
isImminent: ticket.isImminent,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{(grouped[mobileColumn] || []).length === 0 && (
|
{(grouped[mobileColumn] || []).length === 0 && (
|
||||||
<div className="flex items-center justify-center h-32 text-text-muted text-sm bg-gray-50/30 rounded-2xl">
|
<div className="flex items-center justify-center h-32 text-text-muted text-sm bg-gray-50/30 rounded-2xl">
|
||||||
No tickets in this column
|
No tickets in this column
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ interface EditForm {
|
|||||||
education: string;
|
education: string;
|
||||||
schoolName: string;
|
schoolName: string;
|
||||||
position: string;
|
position: string;
|
||||||
|
managerId: string;
|
||||||
|
backupManagerId: string;
|
||||||
major: string;
|
major: string;
|
||||||
graduationDate: string;
|
graduationDate: string;
|
||||||
residence: string;
|
residence: string;
|
||||||
@@ -97,7 +99,7 @@ export default function AccountsPage() {
|
|||||||
const [createForm, setCreateForm] = useState<CreateForm>({ ...emptyCreateForm });
|
const [createForm, setCreateForm] = useState<CreateForm>({ ...emptyCreateForm });
|
||||||
const [editForm, setEditForm] = useState<EditForm>({
|
const [editForm, setEditForm] = useState<EditForm>({
|
||||||
name: '', email: '', phone: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE',
|
name: '', email: '', phone: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE',
|
||||||
joinedDate: '', leftDate: '', birthDate: '', education: '', schoolName: '', position: '', major: '', graduationDate: '', residence: '',
|
joinedDate: '', leftDate: '', birthDate: '', education: '', schoolName: '', position: '', managerId: '', backupManagerId: '', major: '', graduationDate: '', residence: '',
|
||||||
});
|
});
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
const [editError, setEditError] = useState<string | null>(null);
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
@@ -177,6 +179,8 @@ export default function AccountsPage() {
|
|||||||
education: emp.education || '',
|
education: emp.education || '',
|
||||||
schoolName: emp.schoolName || '',
|
schoolName: emp.schoolName || '',
|
||||||
position: emp.position || '',
|
position: emp.position || '',
|
||||||
|
managerId: String(emp.managerId ?? ''),
|
||||||
|
backupManagerId: String(emp.backupManagerId ?? ''),
|
||||||
major: emp.major || '',
|
major: emp.major || '',
|
||||||
graduationDate: emp.graduationDate || '',
|
graduationDate: emp.graduationDate || '',
|
||||||
residence: emp.residence || '',
|
residence: emp.residence || '',
|
||||||
@@ -191,7 +195,10 @@ export default function AccountsPage() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setEditError(null);
|
setEditError(null);
|
||||||
try {
|
try {
|
||||||
await employeeService.update(targetEmployee.empId, editForm);
|
const payload: Record<string, unknown> = { ...editForm };
|
||||||
|
payload.managerId = editForm.managerId ? parseInt(editForm.managerId) : null;
|
||||||
|
payload.backupManagerId = editForm.backupManagerId ? parseInt(editForm.backupManagerId) : null;
|
||||||
|
await employeeService.update(targetEmployee.empId, payload as Partial<Employee>);
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
await fetchEmployees();
|
await fetchEmployees();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -601,6 +608,24 @@ export default function AccountsPage() {
|
|||||||
<option value="DIRECTOR">이사</option>
|
<option value="DIRECTOR">이사</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>사수 (직속 상사)</label>
|
||||||
|
<select value={editForm.managerId} onChange={(e) => setEditForm((f) => ({ ...f, managerId: e.target.value }))} className={selectClass}>
|
||||||
|
<option value="">선택 안 함</option>
|
||||||
|
{employees.map((e) => (
|
||||||
|
<option key={e.empId} value={e.empId}>{e.name} ({e.roleCode})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>부사수 (대리 결재)</label>
|
||||||
|
<select value={editForm.backupManagerId} onChange={(e) => setEditForm((f) => ({ ...f, backupManagerId: e.target.value }))} className={selectClass}>
|
||||||
|
<option value="">선택 안 함</option>
|
||||||
|
{employees.map((e) => (
|
||||||
|
<option key={e.empId} value={e.empId}>{e.name} ({e.roleCode})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>학과</label>
|
<label className={labelClass}>학과</label>
|
||||||
<input value={editForm.major} onChange={(e) => setEditForm((f) => ({ ...f, major: e.target.value }))} className={fieldClass} placeholder="컴퓨터공학" />
|
<input value={editForm.major} onChange={(e) => setEditForm((f) => ({ ...f, major: e.target.value }))} className={fieldClass} placeholder="컴퓨터공학" />
|
||||||
|
|||||||
+7
-1
@@ -23,7 +23,7 @@ export type RoleCode = 'ADMIN' | 'MANAGER' | 'EMPLOYEE';
|
|||||||
export type EmpStatus = 'ACTIVE' | 'ON_LEAVE' | 'RESIGNED';
|
export type EmpStatus = 'ACTIVE' | 'ON_LEAVE' | 'RESIGNED';
|
||||||
export type DispatchStatus = 'WAITING' | 'DISPATCHED';
|
export type DispatchStatus = 'WAITING' | 'DISPATCHED';
|
||||||
|
|
||||||
export type TicketStatus = 'OPEN' | 'ASSIGNED' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED';
|
export type TicketStatus = 'OPEN' | 'ASSIGNED' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED' | 'PENDING_APPROVAL' | 'APPROVED' | 'REJECTED';
|
||||||
export type TicketCategory = 'BUG' | 'FEATURE' | 'SUPPORT' | 'QA' | 'VACATION' | 'EXPENSE' | 'EQUIPMENT_REQUEST' | 'GENERAL';
|
export type TicketCategory = 'BUG' | 'FEATURE' | 'SUPPORT' | 'QA' | 'VACATION' | 'EXPENSE' | 'EQUIPMENT_REQUEST' | 'GENERAL';
|
||||||
export type TicketPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'CRITICAL';
|
export type TicketPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'CRITICAL';
|
||||||
|
|
||||||
@@ -59,6 +59,8 @@ export interface Employee {
|
|||||||
education: string | null;
|
education: string | null;
|
||||||
schoolName: string | null;
|
schoolName: string | null;
|
||||||
position: string | null;
|
position: string | null;
|
||||||
|
managerId?: number;
|
||||||
|
backupManagerId?: number;
|
||||||
major: string | null;
|
major: string | null;
|
||||||
graduationDate: string | null;
|
graduationDate: string | null;
|
||||||
residence: string | null;
|
residence: string | null;
|
||||||
@@ -148,6 +150,10 @@ export interface IssueTicket {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
attachmentUrl?: string;
|
attachmentUrl?: string;
|
||||||
attachmentName?: string;
|
attachmentName?: string;
|
||||||
|
approverId?: number;
|
||||||
|
backupApproverId?: number;
|
||||||
|
approvedBy?: number;
|
||||||
|
approvedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketComment {
|
export interface TicketComment {
|
||||||
|
|||||||
Reference in New Issue
Block a user