feat: back button + reopen for approved/rejected tickets
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
@@ -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,9 +145,26 @@ 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) && (
|
||||||
<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'}
|
{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">
|
||||||
|
{canAdvance ? <>Advance <ArrowRight className="w-3.5 h-3.5 ml-1.5" /></> : 'Completed'}
|
||||||
|
</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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user