feat(pm): 프로젝트 상세 페이지 타임라인 및 UI 개선
- 타임라인 진행바에 날짜 표시 (MM/DD~MM/DD 형식) - Task/Issue 타임라인 바 색상 및 가시성 개선 - 이슈 상태 "열림" 버튼 색상 통일 (bg-red-100) - 아코디언 내 이슈 수정/삭제 아이콘 크기 통일 (w-4 h-4) - 아코디언 내 이슈 라인에 팀/부서 + 담당자 함께 표시 - Tailwind safelist에 동적 타임라인 색상 추가
This commit is contained in:
@@ -502,19 +502,38 @@ function renderTasks(container, tasks) {
|
||||
};
|
||||
const issueStatusLabels = { open: '열림', in_progress: '진행중', resolved: '해결됨', closed: '종료' };
|
||||
|
||||
// 프로젝트 기간 계산 (타임라인 기준)
|
||||
const projectStart = new Date('{{ $project->start_date?->format("Y-m-d") ?? now()->format("Y-m-d") }}');
|
||||
const projectEnd = new Date('{{ $project->end_date?->format("Y-m-d") ?? now()->addMonths(3)->format("Y-m-d") }}');
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const projectDuration = Math.max(1, (projectEnd - projectStart) / (1000 * 60 * 60 * 24));
|
||||
const todayPosition = Math.min(100, Math.max(0, ((today - projectStart) / (1000 * 60 * 60 * 24)) / projectDuration * 100));
|
||||
|
||||
// 짧은 날짜 포맷 (MM/DD)
|
||||
const shortDate = (d) => d ? `${(d.getMonth()+1).toString().padStart(2,'0')}/${d.getDate().toString().padStart(2,'0')}` : '';
|
||||
|
||||
// 테이블 헤더
|
||||
let html = `
|
||||
<table class="w-full text-sm">
|
||||
<colgroup>
|
||||
<col style="width: 32px;">
|
||||
<col style="width: 40px;">
|
||||
<col style="width: 180px;">
|
||||
<col>
|
||||
<col style="width: 64px;">
|
||||
<col style="width: 120px;">
|
||||
<col style="width: 80px;">
|
||||
</colgroup>
|
||||
<thead class="bg-gray-50 text-xs text-gray-500 uppercase">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"></th>
|
||||
<th class="w-10 px-1 py-2 text-center font-medium">긴급</th>
|
||||
<th class="px-2 py-2"></th>
|
||||
<th class="px-1 py-2 text-center font-medium">긴급</th>
|
||||
<th class="px-2 py-2 text-left font-medium">작업명</th>
|
||||
<th class="w-24 px-2 py-2 text-center font-medium">마감일</th>
|
||||
<th class="w-24 px-2 py-2 text-center font-medium">이슈</th>
|
||||
<th class="w-16 px-2 py-2 text-center font-medium">상태</th>
|
||||
<th class="w-32 px-2 py-2 text-center font-medium">변경</th>
|
||||
<th class="w-12 px-2 py-2"></th>
|
||||
<th class="px-2 py-2 text-left font-medium">타임라인</th>
|
||||
<th class="px-2 py-2 text-center font-medium">이슈</th>
|
||||
<th class="px-2 py-2 text-center font-medium">상태</th>
|
||||
<th class="px-2 py-2 text-center font-medium">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
@@ -523,10 +542,68 @@ function renderTasks(container, tasks) {
|
||||
const issueTotal = task.issues_count || 0;
|
||||
const issueResolved = task.resolved_issues_count || 0;
|
||||
const issueProgress = issueTotal > 0 ? Math.round((issueResolved / issueTotal) * 100) : 0;
|
||||
const isOverdue = task.due_date && new Date(task.due_date) < new Date() && task.status !== 'done';
|
||||
const issues = sortIssues(task.issues || []);
|
||||
const hasIssues = issues.length > 0;
|
||||
|
||||
// Task 타임라인 계산 (이슈들의 min(start_date) ~ max(due_date))
|
||||
let taskStartDate = null;
|
||||
let taskEndDate = task.due_date ? new Date(task.due_date) : null;
|
||||
|
||||
if (hasIssues) {
|
||||
issues.forEach(issue => {
|
||||
if (issue.start_date) {
|
||||
const issueStart = new Date(issue.start_date);
|
||||
if (!taskStartDate || issueStart < taskStartDate) taskStartDate = issueStart;
|
||||
}
|
||||
if (issue.due_date) {
|
||||
const issueEnd = new Date(issue.due_date);
|
||||
if (!taskEndDate || issueEnd > taskEndDate) taskEndDate = issueEnd;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 타임라인 바 위치/너비 계산
|
||||
let timelineHtml = '';
|
||||
const isOverdue = taskEndDate && taskEndDate < today && task.status !== 'done';
|
||||
|
||||
if (taskStartDate || taskEndDate) {
|
||||
const barStart = taskStartDate ? Math.max(0, Math.min(100, ((taskStartDate - projectStart) / (1000 * 60 * 60 * 24)) / projectDuration * 100)) :
|
||||
(taskEndDate ? Math.max(0, Math.min(100, ((taskEndDate - projectStart) / (1000 * 60 * 60 * 24)) / projectDuration * 100)) : 0);
|
||||
const barEnd = taskEndDate ? Math.max(0, Math.min(100, ((taskEndDate - projectStart) / (1000 * 60 * 60 * 24)) / projectDuration * 100)) : barStart;
|
||||
const barWidth = Math.max(2, barEnd - barStart);
|
||||
|
||||
// 상태별 색상 (더 진하게)
|
||||
const barColor = task.status === 'done' ? 'bg-green-600' :
|
||||
task.status === 'in_progress' ? 'bg-blue-600' :
|
||||
isOverdue ? 'bg-red-500' : 'bg-gray-500';
|
||||
|
||||
// 시작일/마감일 없이 포인트만 표시하는 경우
|
||||
const isPoint = !taskStartDate || barWidth < 5;
|
||||
|
||||
// Task 날짜 레이블
|
||||
const taskDateLabel = taskStartDate && taskEndDate ? `${shortDate(taskStartDate)}~${shortDate(taskEndDate)}` :
|
||||
taskEndDate ? shortDate(taskEndDate) : shortDate(taskStartDate);
|
||||
|
||||
timelineHtml = `
|
||||
<div class="relative h-7 bg-gray-100 rounded overflow-hidden">
|
||||
<!-- 오늘 표시선 -->
|
||||
<div class="absolute top-0 bottom-0 w-0.5 bg-red-500 z-10" style="left: ${todayPosition}%;" title="오늘"></div>
|
||||
<!-- 타임라인 바 -->
|
||||
${isPoint ?
|
||||
`<div class="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full ${barColor} shadow-sm" style="left: calc(${barStart}% - 6px);" title="${taskDateLabel}"></div>` :
|
||||
`<div class="absolute top-1 bottom-1 rounded ${barColor} flex items-center overflow-hidden shadow-sm" style="left: ${barStart}%; width: ${barWidth}%;">
|
||||
<span class="text-[11px] text-white font-semibold whitespace-nowrap px-1.5 truncate w-full text-center drop-shadow-md">${taskDateLabel}</span>
|
||||
</div>`
|
||||
}
|
||||
</div>`;
|
||||
} else {
|
||||
timelineHtml = `
|
||||
<div class="relative h-7 bg-gray-100 rounded overflow-hidden">
|
||||
<div class="absolute top-0 bottom-0 w-0.5 bg-red-500 z-10" style="left: ${todayPosition}%;" title="오늘"></div>
|
||||
<span class="absolute inset-0 flex items-center justify-center text-xs text-gray-400">-</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 작업 Row
|
||||
html += `
|
||||
<tr class="hover:bg-gray-50 border-b border-gray-100 ${hasIssues ? 'cursor-pointer' : ''}" ${hasIssues ? `onclick="toggleTaskIssues(${task.id})"` : ''}>
|
||||
@@ -540,43 +617,93 @@ function renderTasks(container, tasks) {
|
||||
</td>
|
||||
<td class="px-2 py-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
${hasIssues ? `<svg id="toggle-icon-${task.id}" class="w-4 h-4 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>` : '<span class="w-4"></span>'}
|
||||
<span title="우선순위: ${priorityLabels[task.priority]}">${prioritySvg[task.priority]}</span>
|
||||
<span class="font-medium text-gray-800 ${task.is_urgent ? 'text-red-600' : ''}">${task.title}</span>
|
||||
${hasIssues ? `<svg id="toggle-icon-${task.id}" class="w-4 h-4 text-gray-400 transition-transform flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>` : '<span class="w-4 flex-shrink-0"></span>'}
|
||||
<span title="우선순위: ${priorityLabels[task.priority]}" class="flex-shrink-0">${prioritySvg[task.priority]}</span>
|
||||
<span class="font-medium text-gray-800 truncate ${task.is_urgent ? 'text-red-600' : ''}" title="${task.title}">${task.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-center text-xs ${isOverdue ? 'text-red-500 font-medium' : 'text-gray-500'}">
|
||||
${task.due_date ? formatDate(task.due_date) : '-'}
|
||||
<td class="px-2 py-1.5">
|
||||
${timelineHtml}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-center">
|
||||
${issueTotal > 0 ? `
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<span class="inline-block w-10 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<span class="inline-flex items-center gap-1 text-xs text-gray-600">
|
||||
<span class="inline-block w-8 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<span class="block h-full ${issueProgress === 100 ? 'bg-green-500' : 'bg-blue-500'}" style="width: ${issueProgress}%"></span>
|
||||
</span>
|
||||
<span>${issueResolved}/${issueTotal}</span>
|
||||
</span>
|
||||
` : '<span class="text-xs text-gray-400">-</span>'}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-center">
|
||||
<span class="px-1.5 py-0.5 text-xs rounded ${statusColors[task.status]}">${statusLabels[task.status]}</span>
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-center" onclick="event.stopPropagation()">
|
||||
<div class="flex justify-center gap-0.5">
|
||||
<button onclick="changeTaskStatus(${task.id}, 'todo')" class="px-1.5 py-0.5 text-xs rounded ${task.status === 'todo' ? 'bg-gray-300 text-gray-700' : 'text-gray-400 hover:bg-gray-200'}" title="할일로 변경">할일</button>
|
||||
<button onclick="changeTaskStatus(${task.id}, 'in_progress')" class="px-1.5 py-0.5 text-xs rounded ${task.status === 'in_progress' ? 'bg-blue-500 text-white' : 'text-gray-400 hover:bg-gray-200'}" title="진행중으로 변경">진행</button>
|
||||
<button onclick="changeTaskStatus(${task.id}, 'done')" class="px-1.5 py-0.5 text-xs rounded ${task.status === 'done' ? 'bg-green-500 text-white' : 'text-gray-400 hover:bg-gray-200'}" title="완료로 변경">완료</button>
|
||||
<button onclick="changeTaskStatus(${task.id}, 'todo')" class="px-1.5 py-0.5 text-xs rounded ${task.status === 'todo' ? 'bg-gray-500 text-white' : 'text-gray-400 hover:bg-gray-200'}" title="할일">할일</button>
|
||||
<button onclick="changeTaskStatus(${task.id}, 'in_progress')" class="px-1.5 py-0.5 text-xs rounded ${task.status === 'in_progress' ? 'bg-blue-500 text-white' : 'text-gray-400 hover:bg-gray-200'}" title="진행중">진행</button>
|
||||
<button onclick="changeTaskStatus(${task.id}, 'done')" class="px-1.5 py-0.5 text-xs rounded ${task.status === 'done' ? 'bg-green-500 text-white' : 'text-gray-400 hover:bg-gray-200'}" title="완료">완료</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-center" onclick="event.stopPropagation()">
|
||||
<button onclick="editTask(${task.id})" class="text-gray-400 hover:text-blue-600" title="작업 수정">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</button>
|
||||
<div class="flex justify-center gap-1">
|
||||
<button onclick="openIssueModalForTask(${task.id})" class="text-gray-400 hover:text-green-600" title="이슈 추가">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
</button>
|
||||
<button onclick="editTask(${task.id})" class="text-gray-400 hover:text-blue-600" title="작업 수정">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</button>
|
||||
<button onclick="deleteTask(${task.id})" class="text-gray-400 hover:text-red-600" title="작업 삭제">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
// 이슈 서브 Rows (아코디언) - 8컬럼: 체크박스, 긴급, 작업명, 마감일, 이슈, 상태, 변경, 수정
|
||||
// Task 기준 타임라인 범위 계산 (이슈들의 범위)
|
||||
const taskTimelineStart = taskStartDate || taskEndDate || today;
|
||||
const taskTimelineEnd = taskEndDate || taskStartDate || today;
|
||||
const taskDuration = Math.max(1, (taskTimelineEnd - taskTimelineStart) / (1000 * 60 * 60 * 24));
|
||||
const taskTodayPos = ((today - taskTimelineStart) / (1000 * 60 * 60 * 24)) / taskDuration * 100;
|
||||
const showTodayLine = taskTodayPos >= 0 && taskTodayPos <= 100; // 오늘이 범위 내인지
|
||||
|
||||
// 이슈 서브 Rows (아코디언) - 7컬럼에 맞춤: 체크박스, 긴급, 이슈명, 타임라인, 담당, 상태버튼, 관리
|
||||
issues.forEach(issue => {
|
||||
// 이슈 타임라인 계산 (Task 기준)
|
||||
const issueStart = issue.start_date ? new Date(issue.start_date) : null;
|
||||
const issueEnd = issue.due_date ? new Date(issue.due_date) : null;
|
||||
const issueOverdue = issueEnd && issueEnd < today && issue.status !== 'resolved' && issue.status !== 'closed';
|
||||
|
||||
let issueTimelineHtml = '';
|
||||
if ((issueStart || issueEnd) && taskStartDate) {
|
||||
const iBarStart = issueStart ? Math.max(0, Math.min(100, ((issueStart - taskTimelineStart) / (1000 * 60 * 60 * 24)) / taskDuration * 100)) :
|
||||
(issueEnd ? Math.max(0, Math.min(100, ((issueEnd - taskTimelineStart) / (1000 * 60 * 60 * 24)) / taskDuration * 100)) : 0);
|
||||
const iBarEnd = issueEnd ? Math.max(0, Math.min(100, ((issueEnd - taskTimelineStart) / (1000 * 60 * 60 * 24)) / taskDuration * 100)) : iBarStart;
|
||||
const iBarWidth = Math.max(5, iBarEnd - iBarStart);
|
||||
|
||||
// 이슈 상태별 색상
|
||||
const iBarColor = issue.status === 'resolved' || issue.status === 'closed' ? 'bg-green-400' :
|
||||
issue.status === 'in_progress' ? 'bg-yellow-400' :
|
||||
issueOverdue ? 'bg-red-300' : 'bg-blue-200';
|
||||
|
||||
const isIssuePoint = !issueStart || iBarWidth < 5;
|
||||
const dateLabel = issueStart && issueEnd ? `${shortDate(issueStart)}~${shortDate(issueEnd)}` :
|
||||
issueEnd ? shortDate(issueEnd) : shortDate(issueStart);
|
||||
|
||||
issueTimelineHtml = `
|
||||
<div class="relative h-5 bg-gray-50 rounded overflow-hidden">
|
||||
${showTodayLine ? `<div class="absolute top-0 bottom-0 w-0.5 bg-red-400 z-10" style="left: ${taskTodayPos}%;" title="오늘"></div>` : ''}
|
||||
${isIssuePoint ?
|
||||
`<div class="absolute top-1/2 -translate-y-1/2 w-2 h-2 rounded-full ${iBarColor}" style="left: calc(${iBarStart}% - 4px);" title="${dateLabel}"></div>` :
|
||||
`<div class="absolute top-0.5 bottom-0.5 rounded ${iBarColor} flex items-center overflow-hidden" style="left: ${iBarStart}%; width: ${iBarWidth}%;">
|
||||
<span class="text-[10px] text-white font-medium whitespace-nowrap px-1 truncate w-full text-center drop-shadow-sm">${dateLabel}</span>
|
||||
</div>`
|
||||
}
|
||||
</div>`;
|
||||
} else {
|
||||
issueTimelineHtml = `
|
||||
<div class="relative h-5 bg-gray-50 rounded overflow-hidden">
|
||||
<span class="absolute inset-0 flex items-center justify-center text-xs text-gray-300">-</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<tr class="task-issues-${task.id} hidden bg-blue-50/30 border-b border-gray-100">
|
||||
<td class="py-1"></td>
|
||||
@@ -587,31 +714,32 @@ function renderTasks(container, tasks) {
|
||||
</td>
|
||||
<td class="py-1 pl-4">
|
||||
<div class="flex items-center gap-2 border-l-2 border-blue-300 pl-3">
|
||||
<span class="text-xs px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded">${issueTypeLabels[issue.type] || issue.type}</span>
|
||||
<span class="text-sm ${issue.is_urgent ? 'text-red-600' : 'text-gray-700'}">${issue.title}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded flex-shrink-0">${issueTypeLabels[issue.type] || issue.type}</span>
|
||||
<span class="text-sm truncate ${issue.is_urgent ? 'text-red-600' : 'text-gray-700'}" title="${issue.title}">${issue.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-1 text-center text-xs ${issue.due_date && new Date(issue.due_date) < new Date() && issue.status !== 'resolved' && issue.status !== 'closed' ? 'text-red-500 font-medium' : 'text-gray-500'}">
|
||||
${issue.due_date ? formatDate(issue.due_date) : '-'}
|
||||
<td class="py-1 px-2">
|
||||
${issueTimelineHtml}
|
||||
</td>
|
||||
<td class="py-1 text-center text-xs text-gray-500">
|
||||
${[issue.client, issue.team, issue.assignee_name].filter(Boolean).join(' · ') || '-'}
|
||||
</td>
|
||||
<td class="py-1 text-center">
|
||||
<span class="px-1.5 py-0.5 text-xs rounded ${issueStatusColors[issue.status]}">${issueStatusLabels[issue.status]}</span>
|
||||
<td class="py-1 text-center text-xs text-gray-500 truncate" title="${[issue.client, issue.team, issue.assignee_name].filter(Boolean).join(' · ')}">
|
||||
${[issue.team, issue.assignee_name].filter(Boolean).join(' · ') || '-'}
|
||||
</td>
|
||||
<td class="py-1 text-center whitespace-nowrap" onclick="event.stopPropagation()">
|
||||
<div class="inline-flex gap-px">
|
||||
${issue.status !== 'open' ? `<button onclick="changeSubIssueStatus(${issue.id}, 'open', ${task.id})" class="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-500 hover:bg-gray-200" title="열림으로 변경">열림</button>` : ''}
|
||||
${issue.status !== 'in_progress' ? `<button onclick="changeSubIssueStatus(${issue.id}, 'in_progress', ${task.id})" class="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-500 hover:bg-gray-200" title="진행중으로 변경">진행</button>` : ''}
|
||||
${issue.status !== 'resolved' ? `<button onclick="changeSubIssueStatus(${issue.id}, 'resolved', ${task.id})" class="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-500 hover:bg-gray-200" title="해결됨으로 변경">해결</button>` : ''}
|
||||
${issue.status !== 'closed' ? `<button onclick="changeSubIssueStatus(${issue.id}, 'closed', ${task.id})" class="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-500 hover:bg-gray-200" title="종료로 변경">종료</button>` : ''}
|
||||
<button onclick="changeSubIssueStatus(${issue.id}, 'open', ${task.id})" class="px-1 py-0.5 text-xs rounded ${issue.status === 'open' ? 'bg-red-100 text-red-600' : 'text-gray-400 hover:bg-gray-200'}" title="열림">열림</button>
|
||||
<button onclick="changeSubIssueStatus(${issue.id}, 'in_progress', ${task.id})" class="px-1 py-0.5 text-xs rounded ${issue.status === 'in_progress' ? 'bg-yellow-500 text-white' : 'text-gray-400 hover:bg-gray-200'}" title="진행">진행</button>
|
||||
<button onclick="changeSubIssueStatus(${issue.id}, 'resolved', ${task.id})" class="px-1 py-0.5 text-xs rounded ${issue.status === 'resolved' ? 'bg-green-500 text-white' : 'text-gray-400 hover:bg-gray-200'}" title="해결">해결</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-1 text-center" onclick="event.stopPropagation()">
|
||||
<button onclick="editIssue(${issue.id})" class="text-gray-400 hover:text-blue-600" title="이슈 수정">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</button>
|
||||
<div class="flex justify-center gap-1">
|
||||
<button onclick="editIssue(${issue.id})" class="text-gray-400 hover:text-blue-600" title="이슈 수정">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</button>
|
||||
<button onclick="deleteIssue(${issue.id}, ${task.id})" class="text-gray-400 hover:text-red-600" title="이슈 삭제">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
@@ -640,20 +768,20 @@ function renderTasks(container, tasks) {
|
||||
});
|
||||
}
|
||||
|
||||
// 이슈 정렬 함수 (마감일 → 상태)
|
||||
// 이슈 정렬 함수 (시작일 → 상태)
|
||||
function sortIssues(issues) {
|
||||
const statusPriority = { open: 0, in_progress: 1, resolved: 2, closed: 3 };
|
||||
return [...issues].sort((a, b) => {
|
||||
// 1. 마감일 기준 정렬 (null은 맨 뒤)
|
||||
if (a.due_date && b.due_date) {
|
||||
const diff = new Date(a.due_date) - new Date(b.due_date);
|
||||
// 1. 시작일 기준 정렬 (null은 맨 뒤)
|
||||
if (a.start_date && b.start_date) {
|
||||
const diff = new Date(a.start_date) - new Date(b.start_date);
|
||||
if (diff !== 0) return diff;
|
||||
} else if (a.due_date && !b.due_date) {
|
||||
} else if (a.start_date && !b.start_date) {
|
||||
return -1;
|
||||
} else if (!a.due_date && b.due_date) {
|
||||
} else if (!a.start_date && b.start_date) {
|
||||
return 1;
|
||||
}
|
||||
// 2. 마감일이 같거나 없으면 상태로 정렬
|
||||
// 2. 시작일이 같거나 없으면 상태로 정렬
|
||||
return (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99);
|
||||
});
|
||||
}
|
||||
@@ -900,6 +1028,15 @@ function closeIssueModal() {
|
||||
document.getElementById('issueModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 작업에 연결된 이슈 추가 (작업 탭에서 호출)
|
||||
function openIssueModalForTask(taskId) {
|
||||
document.getElementById('issueModalTitle').textContent = '이슈 추가';
|
||||
document.getElementById('issueForm').reset();
|
||||
document.getElementById('issueId').value = '';
|
||||
document.getElementById('issueTaskId').value = taskId; // 작업 ID 미리 설정
|
||||
document.getElementById('issueModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function editIssue(issueId) {
|
||||
const response = await fetch(`/api/admin/pm/issues/${issueId}`, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }
|
||||
@@ -1011,5 +1148,51 @@ function getSelectedIssueIds() {
|
||||
select.value = '';
|
||||
loadIssues();
|
||||
}
|
||||
|
||||
// 작업 삭제
|
||||
async function deleteTask(taskId) {
|
||||
const task = tasksData.find(t => t.id === taskId);
|
||||
const taskName = task ? task.title : '이 작업';
|
||||
|
||||
if (!confirm(`"${taskName}"을(를) 삭제하시겠습니까?\n연결된 이슈의 작업 연결이 해제됩니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/admin/pm/tasks/${taskId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
loadTasks();
|
||||
loadIssues(); // 연결된 이슈도 업데이트
|
||||
} else {
|
||||
alert(result.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 이슈 삭제
|
||||
async function deleteIssue(issueId, taskId = null) {
|
||||
const issue = issuesData.find(i => i.id === issueId);
|
||||
const issueName = issue ? issue.title : '이 이슈';
|
||||
|
||||
if (!confirm(`"${issueName}"을(를) 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/admin/pm/issues/${issueId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
loadTasks(); // 작업 탭 아코디언 업데이트
|
||||
loadIssues(); // 이슈 탭 업데이트
|
||||
} else {
|
||||
alert(result.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@endpush
|
||||
|
||||
@@ -5,6 +5,14 @@ export default {
|
||||
"./resources/**/*.js",
|
||||
"./resources/**/*.vue",
|
||||
],
|
||||
safelist: [
|
||||
// 동적으로 생성되는 타임라인 바 색상
|
||||
'bg-blue-200', 'bg-blue-600',
|
||||
'bg-green-400', 'bg-green-600',
|
||||
'bg-yellow-400',
|
||||
'bg-red-300', 'bg-red-500',
|
||||
'bg-gray-300', 'bg-gray-500',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
|
||||
Reference in New Issue
Block a user