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 TicketPriority priority,
@NotBlank String title,
@NotBlank String description
@NotBlank String description,
@Nullable String attachmentUrl,
@Nullable String attachmentName
) {
}
@@ -20,6 +20,8 @@ public record IssueTicket(
LocalDateTime resolvedAt,
String resolutionNote,
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")
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)
private char slaWarnFlag = 'N';
}
@@ -66,7 +66,9 @@ public class TicketService {
.title(request.title())
.description(request.description())
.status(TicketStatus.OPEN)
.slaWarnFlag('N');
.slaWarnFlag('N')
.attachmentUrl(request.attachmentUrl())
.attachmentName(request.attachmentName());
if (request.projectId() != null) {
Project project = projectRepository.findById(request.projectId())
@@ -186,7 +188,7 @@ public class TicketService {
private IssueTicket toIssueTicket(Ticket ticket) {
return new IssueTicket(
ticket.getTicketId(),
ticket.getProject().getProjectName(),
ticket.getProject() != null ? ticket.getProject().getProjectName() : null,
ticket.getRequester().getName(),
ticket.getAssignee() != null ? ticket.getAssignee().getName() : null,
ticket.getCategory(),
@@ -198,7 +200,9 @@ public class TicketService {
ticket.getResolvedAt(),
ticket.getResolutionNote(),
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>
</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' && (
<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>
<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>
<p className="text-green-700 mt-1">비용 청구 건입니다. 금액 및 영수증은 description과 첨부파일을 확인하세요.</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>
<p className="text-purple-700 mt-1">장비 요청 건입니다. 요청 장비 및 긴급도는 description을 확인하세요.</p>
</div>
)}
</div>
+75 -47
View File
@@ -93,7 +93,7 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
extras.push(`\n\n[휴가 신청]\n시작일: ${extraFields.startDate}\n종료일: ${extraFields.endDate}`);
}
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') {
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,
priority: formData.priority as TicketPriority,
projectId: formData.projectId ? parseInt(formData.projectId) : undefined,
attachmentUrl: extraFields.receiptUrl || undefined,
attachmentName: extraFields.receiptName || undefined,
});
onSuccess();
onClose();
@@ -158,22 +160,6 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
{renderFieldError('title')}
</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>
<label className="block text-sm font-medium text-text-main mb-1.5">Category</label>
@@ -220,21 +206,24 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Project</label>
<select
value={formData.projectId}
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>
{/* Project: only for project-related categories */}
{['BUG', 'FEATURE', 'SUPPORT', 'QA'].includes(formData.category) && (
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Project</label>
<select
value={formData.projectId}
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">
@@ -270,21 +259,44 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
)}
{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>
<>
<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>
<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' && (
@@ -325,6 +337,22 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
</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">
<button
type="button"
+3 -1
View File
@@ -128,7 +128,7 @@ export interface DispatchHistory {
// ============================================
export interface IssueTicket {
ticketId: number;
projectId: number;
projectId?: number;
projectName?: string;
requesterId: number;
requesterName?: string;
@@ -146,6 +146,8 @@ export interface IssueTicket {
isImminent?: boolean;
createdAt: string;
updatedAt: string;
attachmentUrl?: string;
attachmentName?: string;
}
export interface TicketComment {