Files
sam-manage/resources/views/project-management/projects/show.blade.php
hskwon 2846d6c034 style: 관리자 패널 UI 개선 및 스크럼 모달 통합
- 테이블 헤더 스타일 통일 (menus, roles, permissions, boards 등)
- 권한 매트릭스 체크박스/버튼 크기 20x20으로 표준화
- 스크럼 항목 추가/수정 모달 통합 (코드 중복 제거)
- daily-logs API URL 경로 수정 (/pm/ 제거)
- 타임존 Asia/Seoul로 변경
- flow-tester 액션 아이콘 크기 조정
2025-12-03 16:47:57 +09:00

1015 lines
53 KiB
PHP

@extends('layouts.app')
@section('title', $project->name)
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-start mb-6">
<div class="flex items-center gap-4">
<a href="{{ route('pm.projects.index') }}" class="text-gray-500 hover:text-gray-700">
프로젝트 목록
</a>
<div>
<h1 class="text-2xl font-bold text-gray-800">{{ $project->name }}</h1>
@if($project->description)
<p class="text-gray-500 mt-1">{{ $project->description }}</p>
@endif
</div>
</div>
<div class="flex items-center gap-2">
<span class="px-3 py-1.5 text-sm font-medium rounded-lg {{ $project->status_color }}">
{{ $project->status_label }}
</span>
<a href="{{ route('pm.projects.edit', $project->id) }}"
class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg transition text-sm font-medium">
수정
</a>
</div>
</div>
<!-- 프로젝트 통계 카드 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<!-- 진행률 (2색상: 완료 + 진행중) -->
<div class="bg-white rounded-lg shadow-sm p-4">
<p class="text-sm text-gray-500 mb-2">진행률</p>
<div class="flex items-center gap-3">
<div class="flex-1 bg-gray-200 rounded-full h-3 relative overflow-hidden">
<!-- 진행중 (파란색, 뒤에 깔림) -->
<div id="progress-bar-inprogress" class="absolute h-3 rounded-full bg-blue-400 transition-all duration-300" style="width: 0%"></div>
<!-- 완료 (녹색, 앞에 표시) -->
<div id="progress-bar-done" class="absolute h-3 rounded-full bg-green-500 transition-all duration-300" style="width: 0%"></div>
</div>
<span id="progress-text" class="text-xl font-bold text-gray-800">0%</span>
</div>
<div class="flex gap-2 mt-1 text-xs text-gray-500">
<span>완료 <span id="progress-done-pct" class="text-green-600 font-medium">0%</span></span>
<span>+ 진행 <span id="progress-inprogress-pct" class="text-blue-600 font-medium">0%</span></span>
</div>
</div>
<!-- 작업 현황 -->
<div class="bg-white rounded-lg shadow-sm p-4">
<p class="text-sm text-gray-500 mb-2">작업 현황</p>
<div class="flex items-center gap-2">
<span id="task-total" class="text-xl font-bold text-gray-800">0</span>
<span class="text-sm text-gray-500"></span>
</div>
<div class="flex gap-2 mt-2 text-xs">
<span class="px-2 py-1 bg-gray-100 text-gray-600 rounded">할일 <span id="task-todo">0</span></span>
<span class="px-2 py-1 bg-blue-100 text-blue-600 rounded">진행 <span id="task-inprogress">0</span></span>
<span class="px-2 py-1 bg-green-100 text-green-600 rounded">완료 <span id="task-done">0</span></span>
</div>
</div>
<!-- 이슈 현황 -->
<div class="bg-white rounded-lg shadow-sm p-4">
<p class="text-sm text-gray-500 mb-2">이슈 현황</p>
<div class="flex items-center gap-2">
<span id="issue-total" class="text-xl font-bold text-gray-800">0</span>
<span class="text-sm text-gray-500"></span>
</div>
<div class="flex gap-2 mt-2 text-xs">
<span class="px-2 py-1 bg-red-100 text-red-600 rounded">열림 <span id="issue-open">0</span></span>
<span class="px-2 py-1 bg-yellow-100 text-yellow-600 rounded">진행 <span id="issue-inprogress">0</span></span>
<span class="px-2 py-1 bg-green-100 text-green-600 rounded">해결 <span id="issue-resolved">0</span></span>
</div>
</div>
<!-- 기간 -->
<div class="bg-white rounded-lg shadow-sm p-4">
<p class="text-sm text-gray-500 mb-2">기간</p>
@if($project->start_date || $project->end_date)
<div class="text-sm text-gray-800">
<p>시작: {{ $project->start_date?->format('Y-m-d') ?? '-' }}</p>
<p>종료: {{ $project->end_date?->format('Y-m-d') ?? '-' }}</p>
</div>
@else
<p class="text-gray-400">미설정</p>
@endif
</div>
</div>
<!-- 영역 -->
<div class="bg-white rounded-lg shadow-sm">
<!-- 헤더 -->
<div class="border-b border-gray-200">
<nav class="flex -mb-px">
<button onclick="switchTab('tasks')" id="tab-tasks"
class="tab-btn px-6 py-3 text-sm font-medium border-b-2 border-blue-500 text-blue-600">
작업 ({{ $project->tasks_count ?? 0 }})
</button>
<button onclick="switchTab('issues')" id="tab-issues"
class="tab-btn px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700">
이슈 ({{ $project->issues_count ?? 0 }})
</button>
</nav>
</div>
<!-- 컨텐츠: 작업 -->
<div id="content-tasks" class="tab-content">
<!-- 작업 추가 버튼 -->
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<div class="flex gap-2">
<select id="taskBulkAction" class="px-3 py-2 border border-gray-300 rounded-lg text-sm" onchange="handleTaskBulkAction()">
<option value="">일괄 작업</option>
<option value="change_status:todo">상태: 할일로 변경</option>
<option value="change_status:in_progress">상태: 진행중으로 변경</option>
<option value="change_status:done">상태: 완료로 변경</option>
<option value="change_priority:high">우선순위: 높음</option>
<option value="change_priority:medium">우선순위: 중간</option>
<option value="change_priority:low">우선순위: 낮음</option>
<option value="delete">삭제</option>
</select>
</div>
<button onclick="openTaskModal()"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-sm">
+ 작업 추가
</button>
</div>
<!-- 작업 목록 -->
<div id="task-list" class="divide-y divide-gray-200">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</div>
</div>
<!-- 컨텐츠: 이슈 -->
<div id="content-issues" class="tab-content hidden">
<!-- 이슈 추가 버튼 -->
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<div class="flex gap-2">
<select id="issueBulkAction" class="px-3 py-2 border border-gray-300 rounded-lg text-sm" onchange="handleIssueBulkAction()">
<option value="">일괄 작업</option>
<option value="change_status:open">상태: Open</option>
<option value="change_status:in_progress">상태: 진행중</option>
<option value="change_status:resolved">상태: 해결됨</option>
<option value="change_status:closed">상태: 종료</option>
<option value="change_type:bug">타입: 버그</option>
<option value="change_type:feature">타입: 기능</option>
<option value="change_type:improvement">타입: 개선</option>
<option value="delete">삭제</option>
</select>
</div>
<button onclick="openIssueModal()"
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition text-sm">
+ 이슈 추가
</button>
</div>
<!-- 이슈 목록 -->
<div id="issue-list" class="divide-y divide-gray-200">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</div>
</div>
</div>
<!-- 작업 모달 -->
<div id="taskModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-lg font-semibold" id="taskModalTitle">작업 추가</h3>
<button onclick="closeTaskModal()" class="text-gray-400 hover:text-gray-600"></button>
</div>
<form id="taskForm" class="p-6">
<input type="hidden" name="task_id" id="taskId">
<input type="hidden" name="project_id" value="{{ $project->id }}">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">제목 *</label>
<input type="text" name="title" id="taskTitle" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea name="description" id="taskDescription" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">상태</label>
<select name="status" id="taskStatus"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
<option value="todo">할일</option>
<option value="in_progress">진행중</option>
<option value="done">완료</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">우선순위</label>
<select name="priority" id="taskPriority"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
<option value="low">낮음</option>
<option value="medium" selected>중간</option>
<option value="high">높음</option>
</select>
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">마감일</label>
<input type="date" name="due_date" id="taskDueDate"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div class="flex justify-end gap-3">
<button type="button" onclick="closeTaskModal()"
class="px-4 py-2 text-gray-600 hover:text-gray-800">취소</button>
<button type="submit"
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">저장</button>
</div>
</form>
</div>
</div>
<!-- 이슈 모달 -->
<div id="issueModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-lg font-semibold" id="issueModalTitle">이슈 추가</h3>
<button onclick="closeIssueModal()" class="text-gray-400 hover:text-gray-600"></button>
</div>
<form id="issueForm" class="p-6">
<input type="hidden" name="issue_id" id="issueId">
<input type="hidden" name="project_id" value="{{ $project->id }}">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">제목 *</label>
<input type="text" name="title" id="issueTitle" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea name="description" id="issueDescription" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">타입</label>
<select name="type" id="issueType"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
<option value="bug">버그</option>
<option value="feature">기능</option>
<option value="improvement">개선</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">상태</label>
<select name="status" id="issueStatus"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
<option value="open">Open</option>
<option value="in_progress">진행중</option>
<option value="resolved">해결됨</option>
<option value="closed">종료</option>
</select>
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">연결된 작업</label>
<select name="task_id" id="issueTaskId"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
<option value="">없음</option>
@foreach($project->tasks as $task)
<option value="{{ $task->id }}">{{ $task->title }}</option>
@endforeach
</select>
</div>
<div class="grid grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">시작일</label>
<input type="date" name="start_date" id="issueStartDate"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">마감일</label>
<input type="date" name="due_date" id="issueDueDate"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">예상 시간</label>
<input type="number" name="estimated_hours" id="issueEstimatedHours" min="0" max="9999"
placeholder="시간"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
</div>
<!-- /담당자/고객사 -->
<div class="grid grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">/부서</label>
<input type="text" name="team" id="issueTeam" placeholder="예: 개발팀"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">담당자</label>
<input type="text" name="assignee_name" id="issueAssigneeName" placeholder="예: 홍길동"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">고객사</label>
<input type="text" name="client" id="issueClient" placeholder="예: 경동기업"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div class="flex justify-end gap-3">
<button type="button" onclick="closeIssueModal()"
class="px-4 py-2 text-gray-600 hover:text-gray-800">취소</button>
<button type="submit"
class="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg">저장</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
const projectId = {{ $project->id }};
const csrfToken = '{{ csrf_token() }}';
// 전역 데이터 저장 (대시보드 업데이트용)
let tasksData = [];
let issuesData = [];
// 탭 전환
function switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('border-blue-500', 'text-blue-600');
btn.classList.add('border-transparent', 'text-gray-500');
});
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
});
document.getElementById(`tab-${tab}`).classList.add('border-blue-500', 'text-blue-600');
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-gray-500');
document.getElementById(`content-${tab}`).classList.remove('hidden');
}
// 대시보드 업데이트
function updateDashboard() {
// 작업 통계
const taskTotal = tasksData.length;
const taskTodo = tasksData.filter(t => t.status === 'todo').length;
const taskInProgress = tasksData.filter(t => t.status === 'in_progress').length;
const taskDone = tasksData.filter(t => t.status === 'done').length;
document.getElementById('task-total').textContent = taskTotal;
document.getElementById('task-todo').textContent = taskTodo;
document.getElementById('task-inprogress').textContent = taskInProgress;
document.getElementById('task-done').textContent = taskDone;
// 이슈 통계
const issueTotal = issuesData.length;
const issueOpen = issuesData.filter(i => i.status === 'open').length;
const issueInProgress = issuesData.filter(i => i.status === 'in_progress').length;
const issueResolved = issuesData.filter(i => i.status === 'resolved' || i.status === 'closed').length;
document.getElementById('issue-total').textContent = issueTotal;
document.getElementById('issue-open').textContent = issueOpen;
document.getElementById('issue-inprogress').textContent = issueInProgress;
document.getElementById('issue-resolved').textContent = issueResolved;
// 진행률 계산 (2색상)
if (taskTotal > 0) {
const donePct = Math.round((taskDone / taskTotal) * 100);
const inProgressPct = Math.round(((taskDone + taskInProgress) / taskTotal) * 100);
document.getElementById('progress-bar-inprogress').style.width = inProgressPct + '%';
document.getElementById('progress-bar-done').style.width = donePct + '%';
document.getElementById('progress-text').textContent = inProgressPct + '%'; // 전체 진행률 표시
document.getElementById('progress-done-pct').textContent = donePct + '%';
document.getElementById('progress-inprogress-pct').textContent = (inProgressPct - donePct) + '%';
} else {
document.getElementById('progress-bar-inprogress').style.width = '0%';
document.getElementById('progress-bar-done').style.width = '0%';
document.getElementById('progress-text').textContent = '0%';
document.getElementById('progress-done-pct').textContent = '0%';
document.getElementById('progress-inprogress-pct').textContent = '0%';
}
// 탭 카운트 업데이트
document.getElementById('tab-tasks').innerHTML = `작업 (${taskTotal})`;
document.getElementById('tab-issues').innerHTML = `이슈 (${issueTotal})`;
}
// 작업 목록 로드
async function loadTasks() {
try {
const response = await fetch(`/api/admin/pm/tasks/project/${projectId}`, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }
});
const result = await response.json();
if (result.success) {
tasksData = result.data;
renderTasks(document.getElementById('task-list'), result.data);
updateDashboard();
}
} catch (e) {
console.error('Failed to load tasks:', e);
}
}
// 이슈 목록 로드
async function loadIssues() {
try {
const response = await fetch(`/api/admin/pm/issues/project/${projectId}`, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }
});
const result = await response.json();
if (result.success) {
issuesData = result.data;
renderIssues(document.getElementById('issue-list'), result.data);
updateDashboard();
}
} catch (e) {
console.error('Failed to load issues:', e);
}
}
// 페이지 로드 시 데이터 로드
document.addEventListener('DOMContentLoaded', function() {
loadTasks();
loadIssues();
});
// 날짜 포맷 함수 (YYYY-MM-DD)
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toISOString().split('T')[0];
}
// 우선순위 SVG 아이콘
const prioritySvg = {
low: '<svg class="w-3 h-3 text-gray-400" fill="currentColor" viewBox="0 0 16 16"><path d="M8 12a4 4 0 100-8 4 4 0 000 8z"/></svg>',
medium: '<svg class="w-3 h-3 text-yellow-500" fill="currentColor" viewBox="0 0 16 16"><path d="M8 12a4 4 0 100-8 4 4 0 000 8z"/></svg>',
high: '<svg class="w-3 h-3 text-red-500" fill="currentColor" viewBox="0 0 16 16"><path d="M8 12a4 4 0 100-8 4 4 0 000 8z"/></svg>'
};
const priorityLabels = { low: '낮음', medium: '보통', high: '높음' };
// 열린 아코디언 상태 저장
const openAccordions = new Set();
// 아코디언 토글
function toggleTaskIssues(taskId) {
const issueRows = document.querySelectorAll(`.task-issues-${taskId}`);
const icon = document.getElementById(`toggle-icon-${taskId}`);
const isCurrentlyHidden = issueRows[0]?.classList.contains('hidden');
issueRows.forEach(row => row.classList.toggle('hidden'));
if (icon) {
icon.classList.toggle('rotate-90');
}
// 상태 저장
if (isCurrentlyHidden) {
openAccordions.add(taskId);
} else {
openAccordions.delete(taskId);
}
}
// 작업 목록 렌더링 (테이블 형식 + 아코디언)
function renderTasks(container, tasks) {
if (!tasks || tasks.length === 0) {
container.innerHTML = '<div class="py-6 text-center text-gray-500 text-sm">등록된 작업이 없습니다.</div>';
return;
}
const statusColors = {
todo: 'bg-gray-100 text-gray-600',
in_progress: 'bg-blue-100 text-blue-600',
done: 'bg-green-100 text-green-600'
};
const statusLabels = { todo: '할일', in_progress: '진행중', done: '완료' };
const issueTypeLabels = { bug: '버그', feature: '기능', improvement: '개선' };
const issueStatusColors = {
open: 'bg-red-100 text-red-600',
in_progress: 'bg-yellow-100 text-yellow-600',
resolved: 'bg-green-100 text-green-600',
closed: 'bg-gray-100 text-gray-600'
};
const issueStatusLabels = { open: '열림', in_progress: '진행중', resolved: '해결됨', closed: '종료' };
// 테이블 헤더
let html = `
<table class="w-full text-sm">
<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 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>
</tr>
</thead>
<tbody>`;
tasks.forEach(task => {
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;
// 작업 Row
html += `
<tr class="hover:bg-gray-50 border-b border-gray-100 ${hasIssues ? 'cursor-pointer' : ''}" ${hasIssues ? `onclick="toggleTaskIssues(${task.id})"` : ''}>
<td class="px-2 py-1.5 text-center" onclick="event.stopPropagation()">
<input type="checkbox" class="task-checkbox w-4 h-4 rounded border-gray-300" data-id="${task.id}">
</td>
<td class="px-1 py-1.5 text-center" onclick="event.stopPropagation()">
<button onclick="toggleTaskUrgent(${task.id})" class="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 transition ${task.is_urgent ? 'text-red-500' : 'text-gray-300 hover:text-gray-400'}" title="${task.is_urgent ? '긴급 해제' : '긴급 설정'}">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z" clip-rule="evenodd"/></svg>
</button>
</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>
</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>
<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="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>
</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>
</td>
</tr>`;
// 이슈 서브 Rows (아코디언) - 8컬럼: 체크박스, 긴급, 작업명, 마감일, 이슈, 상태, 변경, 수정
issues.forEach(issue => {
html += `
<tr class="task-issues-${task.id} hidden bg-blue-50/30 border-b border-gray-100">
<td class="py-1"></td>
<td class="py-1 text-center" onclick="event.stopPropagation()">
<button onclick="toggleSubIssueUrgent(${issue.id}, ${task.id})" class="w-5 h-5 flex items-center justify-center rounded hover:bg-gray-100 transition ${issue.is_urgent ? 'text-red-500' : 'text-gray-300 hover:text-gray-400'}" title="${issue.is_urgent ? '긴급 해제' : '긴급 설정'}">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z" clip-rule="evenodd"/></svg>
</button>
</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>
</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>
<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>
<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>` : ''}
</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>
</td>
</tr>`;
});
});
html += '</tbody></table>';
container.innerHTML = html;
// 진행중 작업의 아코디언 자동 열기 (최초 로드시만)
if (openAccordions.size === 0) {
tasks.forEach(task => {
if (task.status === 'in_progress' && task.issues && task.issues.length > 0) {
openAccordions.add(task.id);
}
});
}
// 열린 아코디언 상태 복원
openAccordions.forEach(taskId => {
const issueRows = document.querySelectorAll(`.task-issues-${taskId}`);
const icon = document.getElementById(`toggle-icon-${taskId}`);
issueRows.forEach(row => row.classList.remove('hidden'));
if (icon) {
icon.classList.add('rotate-90');
}
});
}
// 이슈 정렬 함수 (마감일 → 상태)
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);
if (diff !== 0) return diff;
} else if (a.due_date && !b.due_date) {
return -1;
} else if (!a.due_date && b.due_date) {
return 1;
}
// 2. 마감일이 같거나 없으면 상태로 정렬
return (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99);
});
}
// 이슈 목록 렌더링 (테이블 형식)
function renderIssues(container, issues) {
if (!issues || issues.length === 0) {
container.innerHTML = '<div class="py-6 text-center text-gray-500 text-sm">등록된 이슈가 없습니다.</div>';
return;
}
// 정렬 적용
issues = sortIssues(issues);
const typeLabels = { bug: '버그', feature: '기능', improvement: '개선' };
const statusColors = {
open: 'bg-red-100 text-red-600',
in_progress: 'bg-yellow-100 text-yellow-600',
resolved: 'bg-green-100 text-green-600',
closed: 'bg-gray-100 text-gray-600'
};
const statusLabels = { open: '열림', in_progress: '진행중', resolved: '해결됨', closed: '종료' };
// 테이블 헤더
let html = `
<table class="w-full text-sm">
<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="w-16 px-2 py-2 text-center font-medium">타입</th>
<th class="px-2 py-2 text-left font-medium">이슈명</th>
<th class="w-32 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-40 px-2 py-2 text-center font-medium">변경</th>
<th class="w-12 px-2 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">`;
issues.forEach(issue => {
html += `
<tr class="hover:bg-gray-50">
<td class="px-2 py-1.5 text-center">
<input type="checkbox" class="issue-checkbox w-4 h-4 rounded border-gray-300" data-id="${issue.id}">
</td>
<td class="px-1 py-1.5 text-center">
<button onclick="toggleIssueUrgent(${issue.id})" class="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 transition ${issue.is_urgent ? 'text-red-500' : 'text-gray-300 hover:text-gray-400'}" title="${issue.is_urgent ? '긴급 해제' : '긴급 설정'}">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z" clip-rule="evenodd"/></svg>
</button>
</td>
<td class="px-2 py-1.5 text-center">
<span class="text-xs text-gray-600">${typeLabels[issue.type] || issue.type}</span>
</td>
<td class="px-2 py-1.5">
<span class="font-medium text-gray-800 ${issue.is_urgent ? 'text-red-600' : ''}">${issue.title}</span>
</td>
<td class="px-2 py-1.5 text-xs text-gray-500 truncate max-w-[120px]" title="${issue.task?.title || ''}">
${issue.task ? issue.task.title : '-'}
</td>
<td class="px-2 py-1.5 text-center text-xs text-gray-500">
${issue.start_date ? formatDate(issue.start_date) : '-'}
</td>
<td class="px-2 py-1.5 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>
<td class="px-2 py-1.5 text-center">
<span class="px-1.5 py-0.5 text-xs rounded ${statusColors[issue.status]}">${statusLabels[issue.status]}</span>
</td>
<td class="px-2 py-1.5 text-center">
<div class="flex justify-center gap-0.5">
<button onclick="changeIssueStatus(${issue.id}, 'in_progress')" class="px-1.5 py-0.5 text-xs rounded border ${issue.status === 'in_progress' ? 'bg-yellow-500 text-white border-yellow-500' : 'border-gray-300 text-gray-600 hover:bg-gray-100'}" title="진행중으로 변경">진행</button>
<button onclick="changeIssueStatus(${issue.id}, 'resolved')" class="px-1.5 py-0.5 text-xs rounded border ${issue.status === 'resolved' ? 'bg-green-500 text-white border-green-500' : 'border-gray-300 text-gray-600 hover:bg-gray-100'}" title="해결됨으로 변경">해결</button>
<button onclick="changeIssueStatus(${issue.id}, 'closed')" class="px-1.5 py-0.5 text-xs rounded border ${issue.status === 'closed' ? 'bg-gray-500 text-white border-gray-500' : 'border-gray-300 text-gray-600 hover:bg-gray-100'}" title="종료로 변경">종료</button>
</div>
</td>
<td class="px-2 py-1.5 text-center">
<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>
</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
// 작업 긴급 토글
async function toggleTaskUrgent(taskId) {
await fetch(`/api/admin/pm/tasks/${taskId}/toggle-urgent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }
});
loadTasks();
}
// 이슈 긴급 토글
async function toggleIssueUrgent(issueId) {
await fetch(`/api/admin/pm/issues/${issueId}/toggle-urgent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }
});
loadIssues();
}
// 서브 이슈 긴급 토글 (작업 탭 아코디언 내)
async function toggleSubIssueUrgent(issueId, taskId) {
await fetch(`/api/admin/pm/issues/${issueId}/toggle-urgent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }
});
loadTasks(); // 작업 탭 아코디언 업데이트
loadIssues(); // 이슈 탭도 동기화
}
// 작업 상태 변경
async function changeTaskStatus(taskId, status) {
await fetch(`/api/admin/pm/tasks/${taskId}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({ status })
});
loadTasks();
}
// 이슈 상태 변경 (이슈 탭)
async function changeIssueStatus(issueId, status) {
await fetch(`/api/admin/pm/issues/${issueId}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({ status })
});
// 이슈가 "진행"으로 변경될 때, 연결된 작업이 "할일"이면 자동으로 "진행중"으로 변경
if (status === 'in_progress') {
const issue = issuesData.find(i => i.id === issueId);
if (issue && issue.task_id) {
const task = tasksData.find(t => t.id === issue.task_id);
if (task && task.status === 'todo') {
await fetch(`/api/admin/pm/tasks/${issue.task_id}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({ status: 'in_progress' })
});
}
}
}
loadIssues();
loadTasks(); // 작업 탭 진행률도 업데이트
}
// 서브 이슈 상태 변경 (작업 탭 아코디언 내)
async function changeSubIssueStatus(issueId, status, taskId = null) {
await fetch(`/api/admin/pm/issues/${issueId}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({ status })
});
// 이슈가 "진행"으로 변경될 때, 연결된 작업이 "할일"이면 자동으로 "진행중"으로 변경
if (status === 'in_progress' && taskId) {
const task = tasksData.find(t => t.id === taskId);
if (task && task.status === 'todo') {
await fetch(`/api/admin/pm/tasks/${taskId}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({ status: 'in_progress' })
});
}
}
loadTasks(); // 진행률 즉시 반영
loadIssues(); // 이슈 탭도 동기화
}
// 작업 모달
function openTaskModal() {
document.getElementById('taskModalTitle').textContent = '작업 추가';
document.getElementById('taskForm').reset();
document.getElementById('taskId').value = '';
document.getElementById('taskModal').classList.remove('hidden');
}
function closeTaskModal() {
document.getElementById('taskModal').classList.add('hidden');
}
async function editTask(taskId) {
const response = await fetch(`/api/admin/pm/tasks/${taskId}`, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }
});
const result = await response.json();
if (result.success) {
const task = result.data;
document.getElementById('taskModalTitle').textContent = '작업 수정';
document.getElementById('taskId').value = task.id;
document.getElementById('taskTitle').value = task.title;
document.getElementById('taskDescription').value = task.description || '';
document.getElementById('taskStatus').value = task.status;
document.getElementById('taskPriority').value = task.priority;
document.getElementById('taskDueDate').value = task.due_date || '';
document.getElementById('taskModal').classList.remove('hidden');
}
}
document.getElementById('taskForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const taskId = formData.get('task_id');
const data = Object.fromEntries(formData.entries());
delete data.task_id;
const url = taskId ? `/api/admin/pm/tasks/${taskId}` : '/api/admin/pm/tasks';
const method = taskId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
closeTaskModal();
loadTasks();
} else {
alert(result.message || '저장에 실패했습니다.');
}
});
// 이슈 모달
function openIssueModal() {
document.getElementById('issueModalTitle').textContent = '이슈 추가';
document.getElementById('issueForm').reset();
document.getElementById('issueId').value = '';
document.getElementById('issueModal').classList.remove('hidden');
}
function closeIssueModal() {
document.getElementById('issueModal').classList.add('hidden');
}
async function editIssue(issueId) {
const response = await fetch(`/api/admin/pm/issues/${issueId}`, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }
});
const result = await response.json();
if (result.success) {
const issue = result.data;
document.getElementById('issueModalTitle').textContent = '이슈 수정';
document.getElementById('issueId').value = issue.id;
document.getElementById('issueTitle').value = issue.title;
document.getElementById('issueDescription').value = issue.description || '';
document.getElementById('issueType').value = issue.type;
document.getElementById('issueStatus').value = issue.status;
document.getElementById('issueTaskId').value = issue.task_id || '';
document.getElementById('issueStartDate').value = issue.start_date ? formatDate(issue.start_date) : '';
document.getElementById('issueDueDate').value = issue.due_date ? formatDate(issue.due_date) : '';
document.getElementById('issueEstimatedHours').value = issue.estimated_hours || '';
// 팀/담당자/고객사
document.getElementById('issueTeam').value = issue.team || '';
document.getElementById('issueAssigneeName').value = issue.assignee_name || '';
document.getElementById('issueClient').value = issue.client || '';
document.getElementById('issueModal').classList.remove('hidden');
}
}
document.getElementById('issueForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const issueId = formData.get('issue_id');
const data = Object.fromEntries(formData.entries());
delete data.issue_id;
const url = issueId ? `/api/admin/pm/issues/${issueId}` : '/api/admin/pm/issues';
const method = issueId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
closeIssueModal();
loadIssues();
loadTasks(); // 작업 탭 아코디언도 업데이트
} else {
alert(result.message || '저장에 실패했습니다.');
}
});
// 일괄 작업
function getSelectedTaskIds() {
return Array.from(document.querySelectorAll('.task-checkbox:checked')).map(cb => parseInt(cb.dataset.id));
}
function getSelectedIssueIds() {
return Array.from(document.querySelectorAll('.issue-checkbox:checked')).map(cb => parseInt(cb.dataset.id));
}
async function handleTaskBulkAction() {
const select = document.getElementById('taskBulkAction');
const [action, value] = select.value.split(':');
const ids = getSelectedTaskIds();
if (!action || ids.length === 0) {
select.value = '';
if (action) alert('선택된 작업이 없습니다.');
return;
}
if (action === 'delete' && !confirm(`${ids.length}개 작업을 삭제하시겠습니까?`)) {
select.value = '';
return;
}
await fetch('/api/admin/pm/tasks/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({ ids, action, value })
});
select.value = '';
loadTasks();
}
async function handleIssueBulkAction() {
const select = document.getElementById('issueBulkAction');
const [action, value] = select.value.split(':');
const ids = getSelectedIssueIds();
if (!action || ids.length === 0) {
select.value = '';
if (action) alert('선택된 이슈가 없습니다.');
return;
}
if (action === 'delete' && !confirm(`${ids.length}개 이슈를 삭제하시겠습니까?`)) {
select.value = '';
return;
}
await fetch('/api/admin/pm/issues/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({ ids, action, value })
});
select.value = '';
loadIssues();
}
</script>
@endpush