init: SR System — Helpdesk & ITAM Portal
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
'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',
|
||||
};
|
||||
|
||||
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">{ticket.category}</p></div>
|
||||
<div><span className="text-text-muted text-xs">Priority</span><p className={`font-medium ${ticket.priority === 'CRITICAL' ? 'text-status-critical-text' : 'text-text-main'}`}>{ticket.priority}</p></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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Search, Plus, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import TicketCard from '@/components/issues/KanbanBoard';
|
||||
import { KanbanColumn } from '@/components/issues/KanbanBoard';
|
||||
import ticketService from '@/services/ticketService';
|
||||
import type { IssueTicket, TicketCategory, TicketPriority } from '@/types';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
|
||||
const columns = [
|
||||
{ key: 'OPEN', label: 'Open', color: 'bg-status-open-text' },
|
||||
{ key: 'ASSIGNED', label: 'Assigned', color: 'bg-status-pending-text' },
|
||||
{ key: 'IN_PROGRESS', label: 'In Progress', color: 'bg-status-pending-text' },
|
||||
{ key: 'DONE', label: 'Done', color: 'bg-status-resolved-text' },
|
||||
];
|
||||
|
||||
function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
category: 'BUG',
|
||||
priority: 'NORMAL',
|
||||
projectId: '',
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
await ticketService.createTicket({
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
category: formData.category as TicketCategory,
|
||||
priority: formData.priority as TicketPriority,
|
||||
projectId: formData.projectId ? parseInt(formData.projectId) : undefined,
|
||||
});
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to create ticket');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 overflow-hidden">
|
||||
<div className="flex justify-between items-center p-6 border-b border-border">
|
||||
<h3 className="font-outfit text-xl font-semibold text-text-main">New SR Ticket</h3>
|
||||
<button onClick={onClose} className="p-2 hover:bg-surface-container rounded-lg transition-colors">
|
||||
<X className="w-5 h-5 text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-error-container/20 border border-error text-error rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Brief description of the issue"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
placeholder="Detailed explanation, error logs, steps to reproduce..."
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">Category</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-white"
|
||||
>
|
||||
<option value="">Select</option>
|
||||
<option value="BUG">Bug</option>
|
||||
<option value="SUPPORT">Support</option>
|
||||
<option value="QA">QA</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">Priority</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
||||
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-white"
|
||||
>
|
||||
<option value="LOW">Low</option>
|
||||
<option value="NORMAL">Normal</option>
|
||||
<option value="HIGH">High</option>
|
||||
<option value="CRITICAL">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">Project ID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.projectId}
|
||||
onChange={(e) => setFormData({ ...formData, projectId: e.target.value })}
|
||||
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Enter project ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 text-sm font-medium text-text-main bg-surface-container hover:bg-surface-container-high rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2.5 text-sm font-medium text-white bg-primary hover:opacity-90 rounded-lg transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create Ticket'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IssuesPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [tickets, setTickets] = useState<IssueTicket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('ALL');
|
||||
const [filterPriority, setFilterPriority] = useState('ALL');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [mobileColumn, setMobileColumn] = useState<string>(columns[0].key);
|
||||
|
||||
const { codes: priorities } = useCommonCodes('TICKET_PRIORITY');
|
||||
const { codes: categories } = useCommonCodes('TICKET_CATEGORY');
|
||||
|
||||
useEffect(() => {
|
||||
const q = searchParams.get('search');
|
||||
if (q) setSearchKeyword(q);
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
ticketService.getTickets({ size: 100 }).then((res) => {
|
||||
setTickets(res.data.content);
|
||||
}).catch(console.error).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const refreshTickets = () => {
|
||||
setLoading(true);
|
||||
ticketService.getTickets({ size: 100 }).then((res) => {
|
||||
setTickets(res.data.content);
|
||||
}).catch(console.error).finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return tickets.filter((t) => {
|
||||
if (searchKeyword && !t.title.toLowerCase().includes(searchKeyword.toLowerCase())) return false;
|
||||
if (filterCategory !== 'ALL' && t.category !== filterCategory) return false;
|
||||
if (filterPriority !== 'ALL' && t.priority !== filterPriority) return false;
|
||||
return true;
|
||||
});
|
||||
}, [tickets, searchKeyword, filterCategory, filterPriority]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, IssueTicket[]> = {};
|
||||
columns.forEach((c) => { map[c.key] = []; });
|
||||
filtered.forEach((t) => {
|
||||
const colKey = t.status === 'RESOLVED' || t.status === 'CLOSED' ? 'DONE' : t.status;
|
||||
if (map[colKey]) map[colKey].push(t);
|
||||
});
|
||||
return map;
|
||||
}, [filtered]);
|
||||
|
||||
useEffect(() => {
|
||||
const firstWithTickets = columns.find((col) => grouped[col.key]?.length > 0);
|
||||
if (firstWithTickets) {
|
||||
setMobileColumn(firstWithTickets.key);
|
||||
}
|
||||
}, [grouped]);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6">
|
||||
<div>
|
||||
<h2 className="font-outfit text-2xl font-semibold text-text-main tracking-tight">SR Issues</h2>
|
||||
<p className="text-text-muted text-sm mt-0.5">Track and manage field support requests</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Ticket</span>
|
||||
</button>
|
||||
<select value={filterPriority} onChange={(e) => setFilterPriority(e.target.value)} className="text-xs bg-white border border-border rounded-lg px-3 py-2 text-text-main focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<option value="ALL">All Priority</option>
|
||||
{priorities.map((p) => (
|
||||
<option key={p.codeValue} value={p.codeValue}>{p.codeName}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="text-xs bg-white border border-border rounded-lg px-3 py-2 text-text-main focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<option value="ALL">All Category</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.codeValue} value={c.codeValue}>{c.codeName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full mb-6">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
value={searchKeyword}
|
||||
onChange={(e) => {
|
||||
setSearchKeyword(e.target.value);
|
||||
router.replace(`/issues?search=${encodeURIComponent(e.target.value)}`);
|
||||
}}
|
||||
placeholder="Search tickets by title..."
|
||||
className="w-full sm:w-96 pl-10 pr-4 py-2.5 rounded-xl bg-white border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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 */}
|
||||
<div className="hidden md:flex gap-4 overflow-x-auto pb-2">
|
||||
{columns.map((col) => (
|
||||
<div key={col.key} className="flex-1 min-w-[220px]">
|
||||
<KanbanColumn title={col.label} count={grouped[col.key].length} color={col.color}>
|
||||
{grouped[col.key].map((ticket) => (
|
||||
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
|
||||
<TicketCard
|
||||
ticket={{
|
||||
ticketId: ticket.ticketId,
|
||||
title: ticket.title,
|
||||
projectName: ticket.projectName,
|
||||
requesterName: ticket.requesterName,
|
||||
assigneeName: ticket.assigneeName,
|
||||
priority: ticket.priority,
|
||||
category: ticket.category,
|
||||
status: ticket.status,
|
||||
dueDate: ticket.dueDate,
|
||||
isImminent: ticket.isImminent,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</KanbanColumn>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile: single column */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{(grouped[mobileColumn] || []).map((ticket) => (
|
||||
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
|
||||
<TicketCard
|
||||
ticket={{
|
||||
ticketId: ticket.ticketId,
|
||||
title: ticket.title,
|
||||
projectName: ticket.projectName,
|
||||
requesterName: ticket.requesterName,
|
||||
assigneeName: ticket.assigneeName,
|
||||
priority: ticket.priority,
|
||||
category: ticket.category,
|
||||
status: ticket.status,
|
||||
dueDate: ticket.dueDate,
|
||||
isImminent: ticket.isImminent,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
{(grouped[mobileColumn] || []).length === 0 && (
|
||||
<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
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<CreateTicketModal onClose={() => setShowCreateModal(false)} onSuccess={refreshTickets} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user