feat: nullable projectId, new ticket categories, improved create form

This commit is contained in:
pray4skylark
2026-06-08 16:53:01 +09:00
parent ca444a3b4f
commit 0e141e16af
8 changed files with 299 additions and 40 deletions
+42 -3
View File
@@ -18,6 +18,24 @@ 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);
@@ -87,15 +105,36 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
<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">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">
+218 -25
View File
@@ -7,7 +7,8 @@ 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 apiClient from '@/services/apiClient';
import type { IssueTicket, TicketCategory, TicketPriority, Project } from '@/types';
import { useCommonCodes } from '@/hooks/useCommonCodes';
const columns = [
@@ -25,18 +26,86 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
priority: 'NORMAL',
projectId: '',
});
const [extraFields, setExtraFields] = useState<Record<string, string>>({});
const [projects, setProjects] = useState<Project[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
useEffect(() => {
apiClient.get<{ success: boolean; data: Project[] }>('/projects')
.then((res) => setProjects(res.data))
.catch(console.error);
}, []);
const updateField = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (fieldErrors[field]) {
setFieldErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
const updateExtraField = (field: string, value: string) => {
setExtraFields((prev) => ({ ...prev, [field]: value }));
if (fieldErrors[field]) {
setFieldErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const errors: Record<string, string> = {};
if (!formData.title.trim()) errors.title = '제목을 입력해주세요';
if (!formData.description.trim()) errors.description = '내용을 입력해주세요';
if (formData.category === 'VACATION') {
if (!extraFields.startDate) errors.startDate = '시작일을 선택해주세요';
if (!extraFields.endDate) errors.endDate = '종료일을 선택해주세요';
}
if (formData.category === 'EXPENSE') {
if (!extraFields.amount) errors.amount = '금액을 입력해주세요';
}
if (formData.category === 'EQUIPMENT_REQUEST') {
if (!extraFields.equipmentType?.trim()) errors.equipmentType = '장비 유형을 입력해주세요';
if (!extraFields.urgency) errors.urgency = '긴급도를 선택해주세요';
}
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
return;
}
setSubmitting(true);
try {
let finalDescription = formData.description;
const extras: string[] = [];
if (formData.category === 'VACATION') {
extras.push(`\n\n[휴가 신청]\n시작일: ${extraFields.startDate}\n종료일: ${extraFields.endDate}`);
}
if (formData.category === 'EXPENSE') {
extras.push(`\n\n[비용 청구]\n금액: ${extraFields.amount}원`);
}
if (formData.category === 'EQUIPMENT_REQUEST') {
extras.push(`\n\n[장비 요청]\n장비 유형: ${extraFields.equipmentType}\n긴급도: ${extraFields.urgency}`);
}
if (extras.length > 0) {
finalDescription += extras.join('');
}
await ticketService.createTicket({
title: formData.title,
description: formData.description,
description: finalDescription,
category: formData.category as TicketCategory,
priority: formData.priority as TicketPriority,
projectId: formData.projectId ? parseInt(formData.projectId) : undefined,
@@ -44,12 +113,17 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
onSuccess();
onClose();
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to create ticket');
setFieldErrors({ submit: err.message || '티켓 생성에 실패했습니다' });
} finally {
setSubmitting(false);
}
};
const renderFieldError = (field: string) => {
if (!fieldErrors[field]) return null;
return <p className="mt-1 text-xs text-error">{fieldErrors[field]}</p>;
};
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">
@@ -61,34 +135,43 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
{fieldErrors.submit && (
<div className="p-3 bg-error-container/20 border border-error text-error rounded-lg text-sm">
{error}
{fieldErrors.submit}
</div>
)}
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Title</label>
<label className="block text-sm font-medium text-text-main mb-1.5">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
autoFocus
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"
onChange={(e) => updateField('title', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary ${
fieldErrors.title ? 'border-error' : 'border-border'
}`}
placeholder="Brief description of the issue"
required
/>
{renderFieldError('title')}
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Description</label>
<label className="block text-sm font-medium text-text-main mb-1.5">
Description <span className="text-red-500">*</span>
</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"
onChange={(e) => updateField('description', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none ${
fieldErrors.description ? 'border-error' : 'border-border'
}`}
placeholder="Detailed explanation, error logs, steps to reproduce..."
rows={4}
required
/>
{renderFieldError('description')}
</div>
<div className="grid grid-cols-2 gap-4">
@@ -96,13 +179,29 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
<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 })}
onChange={(e) => {
updateField('category', e.target.value);
setExtraFields({});
setFieldErrors((prev) => {
const next = { ...prev };
delete next.startDate;
delete next.endDate;
delete next.amount;
delete next.equipmentType;
delete next.urgency;
return next;
});
}}
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="FEATURE">Feature</option>
<option value="SUPPORT">Support</option>
<option value="QA">QA</option>
<option value="GENERAL">일반 문의</option>
<option value="VACATION">휴가 신청</option>
<option value="EXPENSE">비용 청구</option>
<option value="EQUIPMENT_REQUEST">장비 요청</option>
</select>
</div>
@@ -110,7 +209,7 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
<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 })}
onChange={(e) => updateField('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>
@@ -122,16 +221,110 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Project ID</label>
<input
type="number"
<label className="block text-sm font-medium text-text-main mb-1.5">Project</label>
<select
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"
/>
onChange={(e) => updateField('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 bg-white"
>
<option value="">해당 없음</option>
{projects.map((p) => (
<option key={p.projectId} value={p.projectId}>
{p.projectName}
</option>
))}
</select>
</div>
{formData.category === 'VACATION' && (
<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>
<input
type="date"
value={extraFields.startDate || ''}
onChange={(e) => updateExtraField('startDate', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary ${
fieldErrors.startDate ? 'border-error' : 'border-border'
}`}
/>
{renderFieldError('startDate')}
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">
종료일 <span className="text-red-500">*</span>
</label>
<input
type="date"
value={extraFields.endDate || ''}
onChange={(e) => updateExtraField('endDate', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary ${
fieldErrors.endDate ? 'border-error' : 'border-border'
}`}
/>
{renderFieldError('endDate')}
</div>
</div>
)}
{formData.category === 'EXPENSE' && (
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">
금액 (원) <span className="text-red-500">*</span>
</label>
<input
type="number"
value={extraFields.amount || ''}
onChange={(e) => updateExtraField('amount', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary ${
fieldErrors.amount ? 'border-error' : 'border-border'
}`}
placeholder="0"
/>
{renderFieldError('amount')}
</div>
)}
{formData.category === 'EQUIPMENT_REQUEST' && (
<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>
<input
type="text"
value={extraFields.equipmentType || ''}
onChange={(e) => updateExtraField('equipmentType', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary ${
fieldErrors.equipmentType ? 'border-error' : 'border-border'
}`}
placeholder="예: 노트북, 모니터"
/>
{renderFieldError('equipmentType')}
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">
긴급도 <span className="text-red-500">*</span>
</label>
<select
value={extraFields.urgency || ''}
onChange={(e) => updateExtraField('urgency', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-white ${
fieldErrors.urgency ? 'border-error' : 'border-border'
}`}
>
<option value="">선택</option>
<option value="LOW">낮음</option>
<option value="NORMAL">보통</option>
<option value="HIGH">높음</option>
</select>
{renderFieldError('urgency')}
</div>
</div>
)}
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
+1 -1
View File
@@ -24,7 +24,7 @@ export type EmpStatus = 'ACTIVE' | 'ON_LEAVE' | 'RESIGNED';
export type DispatchStatus = 'WAITING' | 'DISPATCHED';
export type TicketStatus = 'OPEN' | 'ASSIGNED' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED';
export type TicketCategory = 'BUG' | 'FEATURE' | 'SUPPORT' | 'QA';
export type TicketCategory = 'BUG' | 'FEATURE' | 'SUPPORT' | 'QA' | 'VACATION' | 'EXPENSE' | 'EQUIPMENT_REQUEST' | 'GENERAL';
export type TicketPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'CRITICAL';
export type ReportType = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'RETURN';