feat: back button + reopen for approved/rejected tickets

This commit is contained in:
pray4skylark
2026-06-08 18:01:53 +09:00
parent 3047354571
commit 969efe3fc3
3 changed files with 62 additions and 4 deletions
@@ -135,6 +135,13 @@ public class TicketController {
return ResponseEntity.ok(ApiResponse.ok("반려되었습니다", ticketService.rejectTicket(ticketId, currentUser.getEmpId(), body.get("reason")))); return ResponseEntity.ok(ApiResponse.ok("반려되었습니다", ticketService.rejectTicket(ticketId, currentUser.getEmpId(), body.get("reason"))));
} }
@Operation(summary = "티켓 재오픈", description = "APPROVED/REJECTED 상태를 PENDING_APPROVAL로 되돌립니다")
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
@PostMapping("/{ticketId}/reopen")
public ResponseEntity<ApiResponse<IssueTicket>> reopenTicket(@PathVariable Long ticketId) {
return ResponseEntity.ok(ApiResponse.ok("재오픈되었습니다", ticketService.reopenTicket(ticketId)));
}
@Operation(summary = "댓글 목록 조회", description = "티켓의 댓글 목록을 조회합니다") @Operation(summary = "댓글 목록 조회", description = "티켓의 댓글 목록을 조회합니다")
@PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')")
@GetMapping("/{ticketId}/comments") @GetMapping("/{ticketId}/comments")
@@ -234,6 +234,19 @@ public class TicketService {
return toIssueTicket(ticketRepository.save(ticket)); return toIssueTicket(ticketRepository.save(ticket));
} }
@Transactional
public IssueTicket reopenTicket(Long ticketId) {
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND));
if (ticket.getStatus() != TicketStatus.APPROVED && ticket.getStatus() != TicketStatus.REJECTED) {
throw new BusinessException(ErrorCode.INVALID_STATUS);
}
ticket.setStatus(TicketStatus.PENDING_APPROVAL);
ticket.setApprovedBy(null);
ticket.setApprovedAt(null);
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(),
+40 -2
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; 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, RotateCcw } 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 apiClient from '@/services/apiClient';
@@ -72,7 +72,10 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
const currentIdx = statusFlow.indexOf(status); const currentIdx = statusFlow.indexOf(status);
const canAdvance = currentIdx < statusFlow.length - 1; const canAdvance = currentIdx < statusFlow.length - 1;
const canGoBack = currentIdx > 0;
const isClosed = status === 'CLOSED'; const isClosed = status === 'CLOSED';
const isApprovalTicket = ['VACATION', 'EXPENSE', 'EQUIPMENT_REQUEST', 'GENERAL'].includes(ticket?.category || '');
const isMgmt = user?.roleCode !== 'EMPLOYEE';
const handleAdvance = async () => { const handleAdvance = async () => {
if (!canAdvance) return; if (!canAdvance) return;
@@ -85,6 +88,24 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
} }
}; };
const handleBack = async () => {
if (!canGoBack) return;
const prevStatus = statusFlow[currentIdx - 1];
try {
await ticketService.updateStatus(ticketId, prevStatus);
setStatus(prevStatus);
} catch (err: unknown) {
alert((err as { message?: string })?.message || '상태 변경 실패');
}
};
const handleReopen = async () => {
try {
const res: any = await apiClient.post(`/tickets/${ticketId}/reopen`);
if (res.success) setTicket(res.data);
} catch (err: any) { alert(err.message || '재오픈 실패'); }
};
const handlePostComment = async () => { const handlePostComment = async () => {
if (!newComment.trim()) return; if (!newComment.trim()) return;
try { try {
@@ -124,10 +145,27 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
</Link> </Link>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span className={`text-xs font-semibold px-3 py-1.5 rounded-full ${statusColor[status]}`}>{statusLabel[status]}</span> <span className={`text-xs font-semibold px-3 py-1.5 rounded-full ${statusColor[status]}`}>{statusLabel[status]}</span>
{!isClosed && ( {!isClosed && statusFlow.includes(status) && (
<>
{canGoBack && (
<button onClick={handleBack} className="bg-gray-100 hover:bg-gray-200 text-text-muted text-xs font-semibold px-3 py-1.5 rounded-lg transition-all flex items-center">
<ChevronLeft className="w-3.5 h-3.5 mr-1" /> Back
</button>
)}
<button onClick={handleAdvance} disabled={!canAdvance} className="bg-primary hover:bg-primary-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-semibold px-4 py-1.5 rounded-lg transition-all flex items-center"> <button onClick={handleAdvance} disabled={!canAdvance} className="bg-primary hover:bg-primary-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-semibold px-4 py-1.5 rounded-lg transition-all flex items-center">
{canAdvance ? <>Advance <ArrowRight className="w-3.5 h-3.5 ml-1.5" /></> : 'Completed'} {canAdvance ? <>Advance <ArrowRight className="w-3.5 h-3.5 ml-1.5" /></> : 'Completed'}
</button> </button>
</>
)}
{status === 'APPROVED' && isMgmt && (
<button onClick={handleReopen} className="bg-amber-50 hover:bg-amber-100 text-amber-700 border border-amber-200 text-xs font-semibold px-3 py-1.5 rounded-lg transition-all">
승인 취소
</button>
)}
{status === 'REJECTED' && (
<button onClick={handleReopen} className="bg-blue-50 hover:bg-blue-100 text-blue-700 border border-blue-200 text-xs font-semibold px-3 py-1.5 rounded-lg transition-all">
재오픈
</button>
)} )}
</div> </div>
</div> </div>