feat: ticket form reorder, attachment, conditional project

This commit is contained in:
pray4skylark
2026-06-08 17:07:31 +09:00
parent 0e141e16af
commit a9ba081144
8 changed files with 116 additions and 54 deletions
@@ -11,6 +11,8 @@ public record CreateTicketRequest(
@NotNull TicketCategory category, @NotNull TicketCategory category,
@NotNull TicketPriority priority, @NotNull TicketPriority priority,
@NotBlank String title, @NotBlank String title,
@NotBlank String description @NotBlank String description,
@Nullable String attachmentUrl,
@Nullable String attachmentName
) { ) {
} }
@@ -20,6 +20,8 @@ public record IssueTicket(
LocalDateTime resolvedAt, LocalDateTime resolvedAt,
String resolutionNote, String resolutionNote,
boolean slaWarnFlag, boolean slaWarnFlag,
LocalDateTime createdAt LocalDateTime createdAt,
String attachmentUrl,
String attachmentName
) { ) {
} }
@@ -66,6 +66,12 @@ public class Ticket extends BaseEntity {
@Column(name = "resolution_note", columnDefinition = "TEXT") @Column(name = "resolution_note", columnDefinition = "TEXT")
private String resolutionNote; private String resolutionNote;
@Column(name = "attachment_url", length = 500)
private String attachmentUrl;
@Column(name = "attachment_name", length = 255)
private String attachmentName;
@Column(name = "sla_warn_flag", nullable = false, length = 1) @Column(name = "sla_warn_flag", nullable = false, length = 1)
private char slaWarnFlag = 'N'; private char slaWarnFlag = 'N';
} }
@@ -66,7 +66,9 @@ public class TicketService {
.title(request.title()) .title(request.title())
.description(request.description()) .description(request.description())
.status(TicketStatus.OPEN) .status(TicketStatus.OPEN)
.slaWarnFlag('N'); .slaWarnFlag('N')
.attachmentUrl(request.attachmentUrl())
.attachmentName(request.attachmentName());
if (request.projectId() != null) { if (request.projectId() != null) {
Project project = projectRepository.findById(request.projectId()) Project project = projectRepository.findById(request.projectId())
@@ -186,7 +188,7 @@ public class TicketService {
private IssueTicket toIssueTicket(Ticket ticket) { private IssueTicket toIssueTicket(Ticket ticket) {
return new IssueTicket( return new IssueTicket(
ticket.getTicketId(), ticket.getTicketId(),
ticket.getProject().getProjectName(), ticket.getProject() != null ? ticket.getProject().getProjectName() : null,
ticket.getRequester().getName(), ticket.getRequester().getName(),
ticket.getAssignee() != null ? ticket.getAssignee().getName() : null, ticket.getAssignee() != null ? ticket.getAssignee().getName() : null,
ticket.getCategory(), ticket.getCategory(),
@@ -198,7 +200,9 @@ public class TicketService {
ticket.getResolvedAt(), ticket.getResolvedAt(),
ticket.getResolutionNote(), ticket.getResolutionNote(),
ticket.getSlaWarnFlag() == 'Y', ticket.getSlaWarnFlag() == 'Y',
ticket.getCreatedAt() ticket.getCreatedAt(),
ticket.getAttachmentUrl(),
ticket.getAttachmentName()
); );
} }
} }
@@ -0,0 +1,2 @@
ALTER TABLE tb_ticket ADD COLUMN IF NOT EXISTS attachment_url VARCHAR(500);
ALTER TABLE tb_ticket ADD COLUMN IF NOT EXISTS attachment_name VARCHAR(255);
+17 -1
View File
@@ -119,20 +119,36 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
<p className="text-sm text-text-main whitespace-pre-wrap">{ticket.description}</p> <p className="text-sm text-text-main whitespace-pre-wrap">{ticket.description}</p>
</div> </div>
{ticket.attachmentUrl && (
<div className="bg-indigo-50 border border-indigo-200 rounded-xl px-4 py-3 text-sm flex items-center gap-3">
<Paperclip className="w-4 h-4 text-indigo-600" />
<div className="flex-1">
<p className="font-semibold text-indigo-800">첨부 파일</p>
<p className="text-indigo-600 text-xs">{ticket.attachmentName || ticket.attachmentUrl.split('/').pop()}</p>
</div>
<a href={ticket.attachmentUrl} target="_blank" rel="noopener noreferrer"
className="px-3 py-1.5 bg-white rounded-lg text-xs font-semibold text-indigo-700 border border-indigo-200 hover:bg-indigo-100 transition-colors">
다운로드
</a>
</div>
)}
{ticket.category === 'VACATION' && ( {ticket.category === 'VACATION' && (
<div className="bg-amber-50 border border-amber-200 rounded-xl px-4 py-3 text-sm"> <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="font-semibold text-amber-800">휴가 신청</p>
<p className="text-amber-700 mt-1">세부 일정은 description을 확인하세요.</p> <p className="text-amber-700 mt-1">해당 티켓은 휴가 신청 건입니다. 휴가 기간 등 세부 내용은 description을 확인하세요.</p>
</div> </div>
)} )}
{ticket.category === 'EXPENSE' && ( {ticket.category === 'EXPENSE' && (
<div className="bg-green-50 border border-green-200 rounded-xl px-4 py-3 text-sm"> <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> <p className="font-semibold text-green-800">비용 청구</p>
<p className="text-green-700 mt-1">비용 청구 건입니다. 금액 및 영수증은 description과 첨부파일을 확인하세요.</p>
</div> </div>
)} )}
{ticket.category === 'EQUIPMENT_REQUEST' && ( {ticket.category === 'EQUIPMENT_REQUEST' && (
<div className="bg-purple-50 border border-purple-200 rounded-xl px-4 py-3 text-sm"> <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> <p className="font-semibold text-purple-800">장비 요청</p>
<p className="text-purple-700 mt-1">장비 요청 건입니다. 요청 장비 및 긴급도는 description을 확인하세요.</p>
</div> </div>
)} )}
</div> </div>
+45 -17
View File
@@ -93,7 +93,7 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
extras.push(`\n\n[휴가 신청]\n시작일: ${extraFields.startDate}\n종료일: ${extraFields.endDate}`); extras.push(`\n\n[휴가 신청]\n시작일: ${extraFields.startDate}\n종료일: ${extraFields.endDate}`);
} }
if (formData.category === 'EXPENSE') { if (formData.category === 'EXPENSE') {
extras.push(`\n\n[비용 청구]\n금액: ${extraFields.amount}원`); extras.push(`\n\n[비용 청구]\n금액: ${extraFields.amount}원${extraFields.receiptUrl ? `\n영수증: ${extraFields.receiptUrl}` : ''}`);
} }
if (formData.category === 'EQUIPMENT_REQUEST') { if (formData.category === 'EQUIPMENT_REQUEST') {
extras.push(`\n\n[장비 요청]\n장비 유형: ${extraFields.equipmentType}\n긴급도: ${extraFields.urgency}`); extras.push(`\n\n[장비 요청]\n장비 유형: ${extraFields.equipmentType}\n긴급도: ${extraFields.urgency}`);
@@ -109,6 +109,8 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
category: formData.category as TicketCategory, category: formData.category as TicketCategory,
priority: formData.priority as TicketPriority, priority: formData.priority as TicketPriority,
projectId: formData.projectId ? parseInt(formData.projectId) : undefined, projectId: formData.projectId ? parseInt(formData.projectId) : undefined,
attachmentUrl: extraFields.receiptUrl || undefined,
attachmentName: extraFields.receiptName || undefined,
}); });
onSuccess(); onSuccess();
onClose(); onClose();
@@ -158,22 +160,6 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
{renderFieldError('title')} {renderFieldError('title')}
</div> </div>
<div>
<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) => 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}
/>
{renderFieldError('description')}
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-text-main mb-1.5">Category</label> <label className="block text-sm font-medium text-text-main mb-1.5">Category</label>
@@ -220,6 +206,8 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
</div> </div>
</div> </div>
{/* Project: only for project-related categories */}
{['BUG', 'FEATURE', 'SUPPORT', 'QA'].includes(formData.category) && (
<div> <div>
<label className="block text-sm font-medium text-text-main mb-1.5">Project</label> <label className="block text-sm font-medium text-text-main mb-1.5">Project</label>
<select <select
@@ -235,6 +223,7 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
))} ))}
</select> </select>
</div> </div>
)}
{formData.category === 'VACATION' && ( {formData.category === 'VACATION' && (
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@@ -270,6 +259,7 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
)} )}
{formData.category === 'EXPENSE' && ( {formData.category === 'EXPENSE' && (
<>
<div> <div>
<label className="block text-sm font-medium text-text-main mb-1.5"> <label className="block text-sm font-medium text-text-main mb-1.5">
금액 (원) <span className="text-red-500">*</span> 금액 (원) <span className="text-red-500">*</span>
@@ -285,6 +275,28 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
/> />
{renderFieldError('amount')} {renderFieldError('amount')}
</div> </div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">영수증</label>
<input type="file" accept="image/*,.pdf" onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
updateExtraField('receiptName', file.name);
try {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('accessToken');
const res = await fetch('/api/v1/files/upload', {
method: 'POST', headers: token ? {Authorization: `Bearer ${token}`} : {}, body: form,
});
const json = await res.json();
if (json.success) updateExtraField('receiptUrl', json.data);
} catch { updateExtraField('receiptUrl', ''); }
}} className="w-full text-sm" />
{extraFields.receiptName && (
<p className="mt-1 text-xs text-text-muted">📎 {extraFields.receiptName}</p>
)}
</div>
</>
)} )}
{formData.category === 'EQUIPMENT_REQUEST' && ( {formData.category === 'EQUIPMENT_REQUEST' && (
@@ -325,6 +337,22 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
</div> </div>
)} )}
<div>
<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) => 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}
/>
{renderFieldError('description')}
</div>
<div className="flex justify-end space-x-3 pt-4"> <div className="flex justify-end space-x-3 pt-4">
<button <button
type="button" type="button"
+3 -1
View File
@@ -128,7 +128,7 @@ export interface DispatchHistory {
// ============================================ // ============================================
export interface IssueTicket { export interface IssueTicket {
ticketId: number; ticketId: number;
projectId: number; projectId?: number;
projectName?: string; projectName?: string;
requesterId: number; requesterId: number;
requesterName?: string; requesterName?: string;
@@ -146,6 +146,8 @@ export interface IssueTicket {
isImminent?: boolean; isImminent?: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
attachmentUrl?: string;
attachmentName?: string;
} }
export interface TicketComment { export interface TicketComment {