diff --git a/backend/src/main/java/com/srsystem/dto/CreateTicketRequest.java b/backend/src/main/java/com/srsystem/dto/CreateTicketRequest.java index 54219d4..88f87d3 100644 --- a/backend/src/main/java/com/srsystem/dto/CreateTicketRequest.java +++ b/backend/src/main/java/com/srsystem/dto/CreateTicketRequest.java @@ -2,11 +2,12 @@ package com.srsystem.dto; import com.srsystem.entity.enums.TicketCategory; import com.srsystem.entity.enums.TicketPriority; +import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public record CreateTicketRequest( - @NotNull Long projectId, + @Nullable Long projectId, @NotNull TicketCategory category, @NotNull TicketPriority priority, @NotBlank String title, diff --git a/backend/src/main/java/com/srsystem/entity/Ticket.java b/backend/src/main/java/com/srsystem/entity/Ticket.java index 0bfae23..03207bd 100644 --- a/backend/src/main/java/com/srsystem/entity/Ticket.java +++ b/backend/src/main/java/com/srsystem/entity/Ticket.java @@ -28,7 +28,7 @@ public class Ticket extends BaseEntity { private Long ticketId; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "project_id", nullable = false) + @JoinColumn(name = "project_id") private Project project; @ManyToOne(fetch = FetchType.LAZY) diff --git a/backend/src/main/java/com/srsystem/entity/enums/TicketCategory.java b/backend/src/main/java/com/srsystem/entity/enums/TicketCategory.java index 486b586..6e9ed31 100644 --- a/backend/src/main/java/com/srsystem/entity/enums/TicketCategory.java +++ b/backend/src/main/java/com/srsystem/entity/enums/TicketCategory.java @@ -4,5 +4,9 @@ public enum TicketCategory { BUG, FEATURE, SUPPORT, - QA + QA, + VACATION, + EXPENSE, + EQUIPMENT_REQUEST, + GENERAL } diff --git a/backend/src/main/java/com/srsystem/service/TicketService.java b/backend/src/main/java/com/srsystem/service/TicketService.java index 973e517..2cdd188 100644 --- a/backend/src/main/java/com/srsystem/service/TicketService.java +++ b/backend/src/main/java/com/srsystem/service/TicketService.java @@ -59,21 +59,22 @@ public class TicketService { Employee requester = employeeRepository.findById(requesterId) .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); - Project project = projectRepository.findById(request.projectId()) - .orElseThrow(() -> new BusinessException(ErrorCode.PROJECT_NOT_FOUND)); - - Ticket ticket = Ticket.builder() - .project(project) + Ticket.TicketBuilder builder = Ticket.builder() .requester(requester) .category(request.category()) .priority(request.priority()) .title(request.title()) .description(request.description()) .status(TicketStatus.OPEN) - .slaWarnFlag('N') - .build(); + .slaWarnFlag('N'); - Ticket saved = ticketRepository.save(ticket); + if (request.projectId() != null) { + Project project = projectRepository.findById(request.projectId()) + .orElseThrow(() -> new BusinessException(ErrorCode.PROJECT_NOT_FOUND)); + builder.project(project); + } + + Ticket saved = ticketRepository.save(builder.build()); return toIssueTicket(saved); } diff --git a/backend/src/main/resources/db/migration/V18__ticket_nullable_project.sql b/backend/src/main/resources/db/migration/V18__ticket_nullable_project.sql new file mode 100644 index 0000000..e6f012f --- /dev/null +++ b/backend/src/main/resources/db/migration/V18__ticket_nullable_project.sql @@ -0,0 +1,21 @@ +ALTER TABLE tb_ticket ALTER COLUMN project_id DROP NOT NULL; + +INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at) +SELECT nextval('sq_common_codes'), 'TICKET_CATEGORY', 'FEATURE', '기능 요청', 2, 'Y', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'TICKET_CATEGORY' AND code_value = 'FEATURE'); + +INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at) +SELECT nextval('sq_common_codes'), 'TICKET_CATEGORY', 'VACATION', '휴가 신청', 5, 'Y', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'TICKET_CATEGORY' AND code_value = 'VACATION'); + +INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at) +SELECT nextval('sq_common_codes'), 'TICKET_CATEGORY', 'EXPENSE', '비용 청구', 6, 'Y', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'TICKET_CATEGORY' AND code_value = 'EXPENSE'); + +INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at) +SELECT nextval('sq_common_codes'), 'TICKET_CATEGORY', 'EQUIPMENT_REQUEST', '장비 요청', 7, 'Y', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'TICKET_CATEGORY' AND code_value = 'EQUIPMENT_REQUEST'); + +INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at) +SELECT nextval('sq_common_codes'), 'TICKET_CATEGORY', 'GENERAL', '일반 문의', 8, 'Y', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'TICKET_CATEGORY' AND code_value = 'GENERAL'); diff --git a/src/app/(main)/issues/[id]/page.tsx b/src/app/(main)/issues/[id]/page.tsx index 367e19c..55b8520 100644 --- a/src/app/(main)/issues/[id]/page.tsx +++ b/src/app/(main)/issues/[id]/page.tsx @@ -18,6 +18,24 @@ const statusLabel: Record = { OPEN: 'Open', ASSIGNED: 'Assigned', IN_PROGRESS: 'In Progress', RESOLVED: 'Resolved', CLOSED: 'Closed', }; +const categoryLabel: Record = { + BUG: '버그 리포트', + FEATURE: '기능 요청', + SUPPORT: '기술 지원', + QA: 'QA 이슈', + VACATION: '휴가 신청', + EXPENSE: '비용 청구', + EQUIPMENT_REQUEST: '장비 요청', + GENERAL: '일반 문의', +}; + +const priorityColor: Record = { + 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

{ticket.title}

-
Project

{ticket.projectName || '-'}

-
Category

{ticket.category}

-
Priority

{ticket.priority}

+
Project

{ticket.projectName || '해당 없음'}

+
Category

{categoryLabel[ticket.category] || ticket.category}

+
Priority + + {ticket.priority} + +
Due

{ticket.dueDate ? <>{new Date(ticket.dueDate).toLocaleDateString('ko-KR')} : '-'}

Description

{ticket.description}

+ + {ticket.category === 'VACATION' && ( +
+

휴가 신청

+

세부 일정은 description을 확인하세요.

+
+ )} + {ticket.category === 'EXPENSE' && ( +
+

비용 청구

+
+ )} + {ticket.category === 'EQUIPMENT_REQUEST' && ( +
+

장비 요청

+
+ )}
diff --git a/src/app/(main)/issues/page.tsx b/src/app/(main)/issues/page.tsx index adf16ff..977c42d 100644 --- a/src/app/(main)/issues/page.tsx +++ b/src/app/(main)/issues/page.tsx @@ -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>({}); + const [projects, setProjects] = useState([]); const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(''); + const [fieldErrors, setFieldErrors] = useState>({}); + + 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 = {}; + + 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

{fieldErrors[field]}

; + }; + return (
@@ -61,34 +135,43 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
- {error && ( + {fieldErrors.submit && (
- {error} + {fieldErrors.submit}
)}
- + 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')}
- +