247 lines
13 KiB
TypeScript
247 lines
13 KiB
TypeScript
'use client';
|
|
|
|
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 type { IssueTicket, TicketComment } from '@/types';
|
|
|
|
const statusFlow = ['OPEN', 'ASSIGNED', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'];
|
|
const statusColor: Record<string, string> = {
|
|
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',
|
|
};
|
|
const statusLabel: Record<string, string> = {
|
|
OPEN: 'Open', ASSIGNED: 'Assigned', IN_PROGRESS: 'In Progress', RESOLVED: 'Resolved', CLOSED: 'Closed',
|
|
};
|
|
|
|
const categoryLabel: Record<string, string> = {
|
|
BUG: '버그 리포트',
|
|
FEATURE: '기능 요청',
|
|
SUPPORT: '기술 지원',
|
|
QA: 'QA 이슈',
|
|
VACATION: '휴가 신청',
|
|
EXPENSE: '비용 청구',
|
|
EQUIPMENT_REQUEST: '장비 요청',
|
|
GENERAL: '일반 문의',
|
|
};
|
|
|
|
const priorityColor: Record<string, string> = {
|
|
LOW: 'bg-gray-100 text-gray-600',
|
|
NORMAL: 'bg-status-open-bg text-status-open-text',
|
|
HIGH: 'bg-status-pending-bg text-status-pending-text',
|
|
CRITICAL: 'bg-status-critical-bg text-status-critical-text',
|
|
};
|
|
|
|
export default function IssueDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
|
const resolvedParams = React.use(params);
|
|
const ticketId = parseInt(resolvedParams.id);
|
|
const [ticket, setTicket] = useState<IssueTicket | null>(null);
|
|
const [comments, setComments] = useState<TicketComment[]>([]);
|
|
const [newComment, setNewComment] = useState('');
|
|
const [status, setStatus] = useState('OPEN');
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
ticketService.getTicket(ticketId),
|
|
ticketService.getComments(ticketId),
|
|
]).then(([ticketRes, commentsRes]) => {
|
|
setTicket(ticketRes.data);
|
|
setComments(commentsRes.data);
|
|
setStatus(ticketRes.data.status);
|
|
}).catch(console.error).finally(() => setLoading(false));
|
|
}, [ticketId]);
|
|
|
|
const currentIdx = statusFlow.indexOf(status);
|
|
const canAdvance = currentIdx < statusFlow.length - 1;
|
|
const isClosed = status === 'CLOSED';
|
|
|
|
const handleAdvance = async () => {
|
|
if (!canAdvance) return;
|
|
const nextStatus = statusFlow[currentIdx + 1];
|
|
try {
|
|
await ticketService.updateStatus(ticketId, nextStatus);
|
|
setStatus(nextStatus);
|
|
} catch (err: unknown) {
|
|
alert((err as { message?: string })?.message || '상태 변경 실패');
|
|
}
|
|
};
|
|
|
|
const handlePostComment = async () => {
|
|
if (!newComment.trim()) return;
|
|
try {
|
|
const res = await ticketService.addComment(ticketId, newComment.trim());
|
|
setComments([...comments, res.data]);
|
|
setNewComment('');
|
|
} catch { alert('댓글 등록 실패'); }
|
|
};
|
|
|
|
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>;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-canvas">
|
|
<div className="max-w-[1400px] mx-auto p-4 sm:p-6 lg:p-8">
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
|
|
<Link href="/issues" className="flex items-center text-sm text-text-muted hover:text-text-main transition-colors">
|
|
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Issues
|
|
</Link>
|
|
<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>
|
|
{!isClosed && (
|
|
<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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div className="lg:col-span-2 space-y-6">
|
|
<div className="bg-surface rounded-2xl border border-border p-4 sm:p-6">
|
|
<h1 className="font-outfit text-xl font-semibold text-text-main mb-4">{ticket.title}</h1>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 text-sm mb-4">
|
|
<div><span className="text-text-muted text-xs">Project</span><p className="font-medium text-text-main">{ticket.projectName || '해당 없음'}</p></div>
|
|
<div><span className="text-text-muted text-xs">Category</span><p className="font-medium text-text-main">{categoryLabel[ticket.category] || ticket.category}</p></div>
|
|
<div><span className="text-text-muted text-xs">Priority</span>
|
|
<span className={`inline-block text-xs font-semibold px-2.5 py-0.5 rounded-full ${priorityColor[ticket.priority] || 'bg-gray-100 text-gray-600'}`}>
|
|
{ticket.priority}
|
|
</span>
|
|
</div>
|
|
<div><span className="text-text-muted text-xs">Due</span><p className="font-medium text-text-main flex items-center">{ticket.dueDate ? <><Clock className="w-3.5 h-3.5 mr-1 text-status-critical-text" />{new Date(ticket.dueDate).toLocaleDateString('ko-KR')}</> : '-'}</p></div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-xl p-4">
|
|
<p className="text-xs text-text-muted mb-1">Description</p>
|
|
<p className="text-sm text-text-main whitespace-pre-wrap">{ticket.description}</p>
|
|
</div>
|
|
|
|
{ticket.category === 'VACATION' && (
|
|
<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">세부 일정은 description을 확인하세요.</p>
|
|
</div>
|
|
)}
|
|
{ticket.category === 'EXPENSE' && (
|
|
<div className="bg-green-50 border border-green-200 rounded-xl px-4 py-3 text-sm">
|
|
<p className="font-semibold text-green-800">비용 청구</p>
|
|
</div>
|
|
)}
|
|
{ticket.category === 'EQUIPMENT_REQUEST' && (
|
|
<div className="bg-purple-50 border border-purple-200 rounded-xl px-4 py-3 text-sm">
|
|
<p className="font-semibold text-purple-800">장비 요청</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="bg-surface rounded-2xl border border-border p-4 sm:p-6">
|
|
<h3 className="font-outfit font-semibold text-sm text-text-main mb-5 flex items-center">
|
|
<MessageSquare className="w-4 h-4 mr-2 text-primary" /> Timeline ({comments.length})
|
|
</h3>
|
|
<div className="space-y-4">
|
|
{comments.map((c) => (
|
|
<div key={c.commentId} className="flex space-x-3">
|
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold flex-shrink-0 bg-primary">
|
|
{(c.userName || 'U')[0]}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center space-x-2 mb-1">
|
|
<span className="text-xs font-semibold text-text-main">{c.userName || 'User'}</span>
|
|
<span className="text-[10px] text-text-muted">{new Date(c.createdAt).toLocaleString('ko-KR')}</span>
|
|
</div>
|
|
<p className="text-sm text-text-main whitespace-pre-wrap">{c.commentText}</p>
|
|
{c.attachedFileUrl && (
|
|
<div className="mt-1.5 inline-flex items-center px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 text-xs">
|
|
<Paperclip className="w-3 h-3 mr-1" /> {c.attachedFileUrl.split('/').pop()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{!isClosed && (
|
|
<div className="mt-5 pt-4 border-t border-border">
|
|
<div className="flex items-start space-x-3">
|
|
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">A</div>
|
|
<div className="flex-1">
|
|
<textarea
|
|
value={newComment}
|
|
onChange={(e) => setNewComment(e.target.value)}
|
|
placeholder="Type your analysis, script, or guidance..."
|
|
className="w-full px-4 py-2.5 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
|
|
rows={2}
|
|
/>
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mt-2">
|
|
<label className="text-xs text-text-muted hover:text-text-main flex items-center cursor-pointer">
|
|
<Paperclip className="w-3.5 h-3.5 mr-1" /> Attach file
|
|
<input type="file" className="hidden" onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) alert(`File selected: ${f.name}`); e.target.value = '';
|
|
}} />
|
|
</label>
|
|
<button onClick={handlePostComment} className="bg-primary hover:bg-primary-hover text-white text-xs font-semibold px-4 py-2 rounded-lg transition-all flex items-center">
|
|
<Send className="w-3.5 h-3.5 mr-1.5" /> Send
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<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">Assignment</h4>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-text-muted">Requester</span>
|
|
<span className="text-xs font-medium text-text-main flex items-center"><User className="w-3 h-3 mr-1.5 text-dot-orange" />{ticket.requesterName || '-'}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-text-muted">Assignee</span>
|
|
<span className="text-xs font-medium text-text-main flex items-center"><User className="w-3 h-3 mr-1.5 text-primary" />{ticket.assigneeName || '미배정'}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-text-muted">Created</span>
|
|
<span className="text-xs font-medium text-text-main">{new Date(ticket.createdAt).toLocaleDateString('ko-KR')}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<div className="space-y-3">
|
|
{statusFlow.map((s, i) => {
|
|
const idx = statusFlow.indexOf(status);
|
|
const isActive = idx >= i;
|
|
const isCurrent = status === s;
|
|
return (
|
|
<div key={s} className="flex items-center">
|
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${isCurrent ? 'bg-primary text-white' : isActive ? 'bg-status-resolved-bg text-status-resolved-text' : 'bg-gray-100 text-text-muted'}`}>
|
|
{isActive && !isCurrent ? <CheckCircle2 className="w-3.5 h-3.5" /> : <span className="text-[10px] font-bold">{i + 1}</span>}
|
|
</div>
|
|
<span className={`ml-3 text-xs font-medium ${isCurrent ? 'text-text-main font-semibold' : isActive ? 'text-text-main' : 'text-text-muted'}`}>{statusLabel[s]}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{ticket.resolutionNote && (
|
|
<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-2">Resolution Note</h4>
|
|
<p className="text-sm text-text-main">{ticket.resolutionNote}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|