diff --git a/resources/views/project-management/projects/show.blade.php b/resources/views/project-management/projects/show.blade.php index e4cb05bc..8ff82899 100644 --- a/resources/views/project-management/projects/show.blade.php +++ b/resources/views/project-management/projects/show.blade.php @@ -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 = ` + + + + + + + + + - - + + - - - - - + + + + `; @@ -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 = ` +
+ +
+ + ${isPoint ? + `
` : + `
+ ${taskDateLabel} +
` + } +
`; + } else { + timelineHtml = ` +
+
+ - +
`; + } + // 작업 Row html += ` @@ -540,43 +617,93 @@ function renderTasks(container, tasks) { - - `; - // 이슈 서브 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 = ` +
+ ${showTodayLine ? `
` : ''} + ${isIssuePoint ? + `
` : + `
+ ${dateLabel} +
` + } +
`; + } else { + issueTimelineHtml = ` +
+ - +
`; + } + html += ` @@ -587,31 +714,32 @@ function renderTasks(container, tasks) { - - - `; }); @@ -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 || '삭제에 실패했습니다.'); + } + } -@endpush \ No newline at end of file +@endpush diff --git a/tailwind.config.js b/tailwind.config.js index bf15f9cb..3819e153 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -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: {
긴급긴급 작업명마감일이슈상태변경타임라인이슈상태관리
- ${hasIssues ? `` : ''} - ${prioritySvg[task.priority]} - ${task.title} + ${hasIssues ? `` : ''} + ${prioritySvg[task.priority]} + ${task.title}
- ${task.due_date ? formatDate(task.due_date) : '-'} + + ${timelineHtml} ${issueTotal > 0 ? ` - - + + ${issueResolved}/${issueTotal} ` : '-'} - ${statusLabels[task.status]} -
- - - + + +
- +
+ + + +