feat: ticket form reorder, attachment, conditional project
This commit is contained in:
@@ -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);
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user