feat: [pm] 작업/이슈 긴급(is_urgent) 토글 기능 추가
- Task, Issue 모델에 is_urgent 필드 추가 - TaskService, IssueService에 toggleUrgent() 메서드 추가 - TaskController, IssueController에 toggleUrgent 엔드포인트 추가 - API 라우트에 toggle-urgent 경로 추가 - 프로젝트 상세 페이지 UI 개선: - 작업/이슈 행에 긴급 토글 버튼(불꽃 아이콘) 추가 - 서브 row(아코디언 내 이슈)에도 긴급 토글 추가 - 서브 row 컬럼을 작업 row와 동일하게 8컬럼으로 정렬 - 진행중 작업의 이슈 아코디언 자동 열기 - 이슈 상태 버튼 항상 테두리 표시
This commit is contained in:
@@ -45,16 +45,9 @@ public function index(Request $request): View|JsonResponse
|
|||||||
/**
|
/**
|
||||||
* 프로젝트별 이슈 목록
|
* 프로젝트별 이슈 목록
|
||||||
*/
|
*/
|
||||||
public function byProject(int $projectId, Request $request): View|JsonResponse
|
public function byProject(int $projectId, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$issues = $this->issueService->getIssuesByProject($projectId);
|
$issues = $this->issueService->getIssuesByProject($projectId);
|
||||||
$types = AdminPmIssue::getTypes();
|
|
||||||
$statuses = AdminPmIssue::getStatuses();
|
|
||||||
|
|
||||||
// HTMX 요청이면 HTML 파셜 반환
|
|
||||||
if ($request->header('HX-Request')) {
|
|
||||||
return view('project-management.issues.partials.list', compact('issues', 'types', 'statuses'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -220,6 +213,20 @@ public function changeStatus(Request $request, int $id): JsonResponse
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이슈 긴급 토글
|
||||||
|
*/
|
||||||
|
public function toggleUrgent(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$issue = $this->issueService->toggleUrgent($id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => $issue->is_urgent ? '긴급으로 설정되었습니다.' : '긴급 해제되었습니다.',
|
||||||
|
'data' => $issue,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이슈 일괄 처리
|
* 이슈 일괄 처리
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -42,15 +42,10 @@ public function index(Request $request): View|JsonResponse
|
|||||||
/**
|
/**
|
||||||
* 프로젝트별 작업 목록 (칸반보드용)
|
* 프로젝트별 작업 목록 (칸반보드용)
|
||||||
*/
|
*/
|
||||||
public function byProject(int $projectId, Request $request): View|JsonResponse
|
public function byProject(int $projectId, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$tasks = $this->taskService->getTasksByProject($projectId);
|
$tasks = $this->taskService->getTasksByProject($projectId);
|
||||||
|
|
||||||
// HTMX 요청이면 HTML 파셜 반환
|
|
||||||
if ($request->header('HX-Request')) {
|
|
||||||
return view('project-management.tasks.partials.kanban', compact('tasks'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => $tasks,
|
'data' => $tasks,
|
||||||
@@ -175,6 +170,20 @@ public function changeStatus(Request $request, int $id): JsonResponse
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 긴급 토글
|
||||||
|
*/
|
||||||
|
public function toggleUrgent(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$task = $this->taskService->toggleUrgent($id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => $task->is_urgent ? '긴급으로 설정되었습니다.' : '긴급 해제되었습니다.',
|
||||||
|
'data' => $task,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 작업 순서 변경 (드래그앤드롭)
|
* 작업 순서 변경 (드래그앤드롭)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class AdminPmIssue extends Model
|
|||||||
'description',
|
'description',
|
||||||
'type',
|
'type',
|
||||||
'status',
|
'status',
|
||||||
|
'is_urgent',
|
||||||
'created_by',
|
'created_by',
|
||||||
'updated_by',
|
'updated_by',
|
||||||
'deleted_by',
|
'deleted_by',
|
||||||
@@ -45,6 +46,7 @@ class AdminPmIssue extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'project_id' => 'integer',
|
'project_id' => 'integer',
|
||||||
'task_id' => 'integer',
|
'task_id' => 'integer',
|
||||||
|
'is_urgent' => 'boolean',
|
||||||
'created_by' => 'integer',
|
'created_by' => 'integer',
|
||||||
'updated_by' => 'integer',
|
'updated_by' => 'integer',
|
||||||
'deleted_by' => 'integer',
|
'deleted_by' => 'integer',
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class AdminPmTask extends Model
|
|||||||
'description',
|
'description',
|
||||||
'status',
|
'status',
|
||||||
'priority',
|
'priority',
|
||||||
|
'is_urgent',
|
||||||
'due_date',
|
'due_date',
|
||||||
'sort_order',
|
'sort_order',
|
||||||
'assignee_id',
|
'assignee_id',
|
||||||
@@ -49,6 +50,7 @@ class AdminPmTask extends Model
|
|||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'project_id' => 'integer',
|
'project_id' => 'integer',
|
||||||
|
'is_urgent' => 'boolean',
|
||||||
'due_date' => 'date',
|
'due_date' => 'date',
|
||||||
'sort_order' => 'integer',
|
'sort_order' => 'integer',
|
||||||
'assignee_id' => 'integer',
|
'assignee_id' => 'integer',
|
||||||
|
|||||||
@@ -176,6 +176,20 @@ public function changeStatus(int $id, string $status): AdminPmIssue
|
|||||||
return $issue;
|
return $issue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이슈 긴급 토글
|
||||||
|
*/
|
||||||
|
public function toggleUrgent(int $id): AdminPmIssue
|
||||||
|
{
|
||||||
|
$issue = AdminPmIssue::findOrFail($id);
|
||||||
|
|
||||||
|
$issue->is_urgent = ! $issue->is_urgent;
|
||||||
|
$issue->updated_by = auth()->id();
|
||||||
|
$issue->save();
|
||||||
|
|
||||||
|
return $issue;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 다중 이슈 상태 일괄 변경
|
* 다중 이슈 상태 일괄 변경
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -82,7 +82,12 @@ public function getTasksByProject(int $projectId): Collection
|
|||||||
return AdminPmTask::query()
|
return AdminPmTask::query()
|
||||||
->where('project_id', $projectId)
|
->where('project_id', $projectId)
|
||||||
->with(['assignee', 'issues'])
|
->with(['assignee', 'issues'])
|
||||||
->withCount('issues')
|
->withCount([
|
||||||
|
'issues',
|
||||||
|
'issues as resolved_issues_count' => function ($query) {
|
||||||
|
$query->whereIn('status', ['resolved', 'closed']);
|
||||||
|
},
|
||||||
|
])
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get();
|
->get();
|
||||||
@@ -179,6 +184,20 @@ public function changeStatus(int $id, string $status): AdminPmTask
|
|||||||
return $task;
|
return $task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 긴급 토글
|
||||||
|
*/
|
||||||
|
public function toggleUrgent(int $id): AdminPmTask
|
||||||
|
{
|
||||||
|
$task = AdminPmTask::findOrFail($id);
|
||||||
|
|
||||||
|
$task->is_urgent = ! $task->is_urgent;
|
||||||
|
$task->updated_by = auth()->id();
|
||||||
|
$task->save();
|
||||||
|
|
||||||
|
return $task;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 작업 순서 변경 (드래그앤드롭)
|
* 작업 순서 변경 (드래그앤드롭)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -29,50 +29,49 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition
|
|||||||
|
|
||||||
<!-- 프로젝트 통계 카드 -->
|
<!-- 프로젝트 통계 카드 -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
<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">
|
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||||
<p class="text-sm text-gray-500 mb-2">진행률</p>
|
<p class="text-sm text-gray-500 mb-2">진행률</p>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex-1 bg-gray-200 rounded-full h-3">
|
<div class="flex-1 bg-gray-200 rounded-full h-3 relative overflow-hidden">
|
||||||
<div class="h-3 rounded-full transition-all duration-300
|
<!-- 진행중 (파란색, 뒤에 깔림) -->
|
||||||
{{ $project->progress >= 80 ? 'bg-green-500' : ($project->progress >= 50 ? 'bg-blue-500' : 'bg-yellow-500') }}"
|
<div id="progress-bar-inprogress" class="absolute h-3 rounded-full bg-blue-400 transition-all duration-300" style="width: 0%"></div>
|
||||||
style="width: {{ $project->progress }}%"></div>
|
<!-- 완료 (녹색, 앞에 표시) -->
|
||||||
|
<div id="progress-bar-done" class="absolute h-3 rounded-full bg-green-500 transition-all duration-300" style="width: 0%"></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xl font-bold text-gray-800">{{ $project->progress }}%</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- 작업 현황 -->
|
<!-- 작업 현황 -->
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||||
<p class="text-sm text-gray-500 mb-2">작업 현황</p>
|
<p class="text-sm text-gray-500 mb-2">작업 현황</p>
|
||||||
@php
|
|
||||||
$taskStats = $project->task_status_counts;
|
|
||||||
@endphp
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xl font-bold text-gray-800">{{ $project->tasks_count ?? 0 }}</span>
|
<span id="task-total" class="text-xl font-bold text-gray-800">0</span>
|
||||||
<span class="text-sm text-gray-500">개</span>
|
<span class="text-sm text-gray-500">개</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 mt-2 text-xs">
|
<div class="flex gap-2 mt-2 text-xs">
|
||||||
<span class="px-2 py-1 bg-gray-100 text-gray-600 rounded">할일 {{ $taskStats['todo'] ?? 0 }}</span>
|
<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">진행 {{ $taskStats['in_progress'] ?? 0 }}</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">완료 {{ $taskStats['done'] ?? 0 }}</span>
|
<span class="px-2 py-1 bg-green-100 text-green-600 rounded">완료 <span id="task-done">0</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 이슈 현황 -->
|
<!-- 이슈 현황 -->
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||||
<p class="text-sm text-gray-500 mb-2">이슈 현황</p>
|
<p class="text-sm text-gray-500 mb-2">이슈 현황</p>
|
||||||
@php
|
|
||||||
$issueStats = $project->issue_status_counts;
|
|
||||||
@endphp
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xl font-bold text-gray-800">{{ $project->issues_count ?? 0 }}</span>
|
<span id="issue-total" class="text-xl font-bold text-gray-800">0</span>
|
||||||
<span class="text-sm text-gray-500">개</span>
|
<span class="text-sm text-gray-500">개</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 mt-2 text-xs">
|
<div class="flex gap-2 mt-2 text-xs">
|
||||||
<span class="px-2 py-1 bg-red-100 text-red-600 rounded">열림 {{ $issueStats['open'] ?? 0 }}</span>
|
<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">진행 {{ $issueStats['in_progress'] ?? 0 }}</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">해결 {{ ($issueStats['resolved'] ?? 0) + ($issueStats['closed'] ?? 0) }}</span>
|
<span class="px-2 py-1 bg-green-100 text-green-600 rounded">해결 <span id="issue-resolved">0</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,12 +95,12 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition
|
|||||||
<div class="border-b border-gray-200">
|
<div class="border-b border-gray-200">
|
||||||
<nav class="flex -mb-px">
|
<nav class="flex -mb-px">
|
||||||
<button onclick="switchTab('tasks')" id="tab-tasks"
|
<button onclick="switchTab('tasks')" id="tab-tasks"
|
||||||
class="tab-btn px-6 py-4 text-sm font-medium border-b-2 border-blue-500 text-blue-600">
|
class="tab-btn px-6 py-3 text-sm font-medium border-b-2 border-blue-500 text-blue-600">
|
||||||
✅ 작업 ({{ $project->tasks_count ?? 0 }})
|
작업 ({{ $project->tasks_count ?? 0 }})
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('issues')" id="tab-issues"
|
<button onclick="switchTab('issues')" id="tab-issues"
|
||||||
class="tab-btn px-6 py-4 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
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 }})
|
이슈 ({{ $project->issues_count ?? 0 }})
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,11 +128,7 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 작업 목록 -->
|
<!-- 작업 목록 -->
|
||||||
<div id="task-list"
|
<div id="task-list" class="divide-y divide-gray-200">
|
||||||
hx-get="/api/admin/pm/tasks/project/{{ $project->id }}"
|
|
||||||
hx-trigger="load, taskRefresh from:body"
|
|
||||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
|
||||||
class="divide-y divide-gray-200">
|
|
||||||
<div class="flex justify-center items-center p-12">
|
<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 class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,11 +159,7 @@ class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition te
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 이슈 목록 -->
|
<!-- 이슈 목록 -->
|
||||||
<div id="issue-list"
|
<div id="issue-list" class="divide-y divide-gray-200">
|
||||||
hx-get="/api/admin/pm/issues/project/{{ $project->id }}"
|
|
||||||
hx-trigger="load, issueRefresh from:body"
|
|
||||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
|
||||||
class="divide-y divide-gray-200">
|
|
||||||
<div class="flex justify-center items-center p-12">
|
<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 class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,9 +255,9 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
|
|||||||
<label class="block text-sm font-medium text-gray-700 mb-2">타입</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">타입</label>
|
||||||
<select name="type" id="issueType"
|
<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">
|
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="bug">버그</option>
|
||||||
<option value="feature">✨ 기능</option>
|
<option value="feature">기능</option>
|
||||||
<option value="improvement">💡 개선</option>
|
<option value="improvement">개선</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -308,6 +299,10 @@ class="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg">저장</butt
|
|||||||
const projectId = {{ $project->id }};
|
const projectId = {{ $project->id }};
|
||||||
const csrfToken = '{{ csrf_token() }}';
|
const csrfToken = '{{ csrf_token() }}';
|
||||||
|
|
||||||
|
// 전역 데이터 저장 (대시보드 업데이트용)
|
||||||
|
let tasksData = [];
|
||||||
|
let issuesData = [];
|
||||||
|
|
||||||
// 탭 전환
|
// 탭 전환
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
@@ -323,128 +318,448 @@ function switchTab(tab) {
|
|||||||
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTMX 응답 처리
|
// 대시보드 업데이트
|
||||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
function updateDashboard() {
|
||||||
const targetId = event.detail.target.id;
|
// 작업 통계
|
||||||
|
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;
|
||||||
|
|
||||||
if (targetId === 'task-list' || targetId === 'issue-list') {
|
document.getElementById('task-total').textContent = taskTotal;
|
||||||
try {
|
document.getElementById('task-todo').textContent = taskTodo;
|
||||||
const response = JSON.parse(event.detail.xhr.response);
|
document.getElementById('task-inprogress').textContent = taskInProgress;
|
||||||
if (response.success && response.data) {
|
document.getElementById('task-done').textContent = taskDone;
|
||||||
if (targetId === 'task-list') {
|
|
||||||
renderTasks(event.detail.target, response.data);
|
// 이슈 통계
|
||||||
} else {
|
const issueTotal = issuesData.length;
|
||||||
renderIssues(event.detail.target, response.data);
|
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;
|
||||||
} catch (e) {
|
|
||||||
// HTML 응답인 경우 그대로 사용
|
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) {
|
function renderTasks(container, tasks) {
|
||||||
if (!tasks || tasks.length === 0) {
|
if (!tasks || tasks.length === 0) {
|
||||||
container.innerHTML = '<div class="p-12 text-center text-gray-500">등록된 작업이 없습니다.</div>';
|
container.innerHTML = '<div class="py-6 text-center text-gray-500 text-sm">등록된 작업이 없습니다.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors = {
|
const statusColors = {
|
||||||
todo: 'bg-gray-100 text-gray-700',
|
todo: 'bg-gray-100 text-gray-600',
|
||||||
in_progress: 'bg-blue-100 text-blue-700',
|
in_progress: 'bg-blue-100 text-blue-600',
|
||||||
done: 'bg-green-100 text-green-700'
|
done: 'bg-green-100 text-green-600'
|
||||||
};
|
};
|
||||||
const statusLabels = { todo: '할일', in_progress: '진행중', done: '완료' };
|
const statusLabels = { todo: '할일', in_progress: '진행중', done: '완료' };
|
||||||
const priorityColors = { low: 'text-gray-400', medium: 'text-yellow-500', high: 'text-red-500' };
|
const issueTypeLabels = { bug: '버그', feature: '기능', improvement: '개선' };
|
||||||
const priorityIcons = { low: '○', medium: '◐', high: '●' };
|
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>`;
|
||||||
|
|
||||||
let html = '';
|
|
||||||
tasks.forEach(task => {
|
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 = task.issues || [];
|
||||||
|
const hasIssues = issues.length > 0;
|
||||||
|
|
||||||
|
// 작업 Row
|
||||||
html += `
|
html += `
|
||||||
<div class="p-4 hover:bg-gray-50 flex items-center gap-4">
|
<tr class="hover:bg-gray-50 border-b border-gray-100 ${hasIssues ? 'cursor-pointer' : ''}" ${hasIssues ? `onclick="toggleTaskIssues(${task.id})"` : ''}>
|
||||||
<input type="checkbox" class="task-checkbox w-4 h-4 rounded border-gray-300" data-id="${task.id}">
|
<td class="px-2 py-1.5 text-center" onclick="event.stopPropagation()">
|
||||||
<div class="flex-1">
|
<input type="checkbox" class="task-checkbox w-4 h-4 rounded border-gray-300" data-id="${task.id}">
|
||||||
<div class="flex items-center gap-2">
|
</td>
|
||||||
<span class="font-medium text-gray-900 cursor-pointer hover:text-blue-600" onclick="editTask(${task.id})">${task.title}</span>
|
<td class="px-1 py-1.5 text-center" onclick="event.stopPropagation()">
|
||||||
<span class="px-2 py-0.5 text-xs rounded-full ${statusColors[task.status]}">${statusLabels[task.status]}</span>
|
<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 ? '긴급 해제' : '긴급 설정'}">
|
||||||
<span class="${priorityColors[task.priority]}" title="우선순위: ${task.priority}">${priorityIcons[task.priority]}</span>
|
<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>
|
</div>
|
||||||
${task.description ? `<p class="text-sm text-gray-500 mt-1">${task.description.substring(0, 100)}</p>` : ''}
|
</td>
|
||||||
<div class="flex gap-4 mt-2 text-xs text-gray-400">
|
<td class="px-2 py-1.5 text-center text-xs ${isOverdue ? 'text-red-500 font-medium' : 'text-gray-500'}">
|
||||||
${task.due_date ? `<span class="${task.is_overdue ? 'text-red-500' : ''}">${task.d_day_text || task.due_date}</span>` : ''}
|
${task.due_date ? formatDate(task.due_date) : '-'}
|
||||||
${task.issues_count ? `<span>이슈 ${task.issues_count}개</span>` : ''}
|
</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>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
<div class="flex gap-2">
|
<td class="px-2 py-1.5 text-center" onclick="event.stopPropagation()">
|
||||||
<button onclick="changeTaskStatus(${task.id}, 'todo')" class="px-2 py-1 text-xs rounded ${task.status === 'todo' ? 'bg-gray-200' : 'hover:bg-gray-100'}">할일</button>
|
<button onclick="editTask(${task.id})" class="text-gray-400 hover:text-blue-600" title="작업 수정">
|
||||||
<button onclick="changeTaskStatus(${task.id}, 'in_progress')" class="px-2 py-1 text-xs rounded ${task.status === 'in_progress' ? 'bg-blue-200' : 'hover:bg-gray-100'}">진행</button>
|
<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 onclick="changeTaskStatus(${task.id}, 'done')" class="px-2 py-1 text-xs rounded ${task.status === 'done' ? 'bg-green-200' : 'hover:bg-gray-100'}">완료</button>
|
</button>
|
||||||
</div>
|
</td>
|
||||||
</div>`;
|
</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 text-gray-400">-</td>
|
||||||
|
<td class="py-1 text-center text-xs text-gray-400">-</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 renderIssues(container, issues) {
|
||||||
|
if (!issues || issues.length === 0) {
|
||||||
|
container.innerHTML = '<div class="py-6 text-center text-gray-500 text-sm">등록된 이슈가 없습니다.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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-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">
|
||||||
|
<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;
|
container.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이슈 목록 렌더링
|
// 작업 긴급 토글
|
||||||
function renderIssues(container, issues) {
|
async function toggleTaskUrgent(taskId) {
|
||||||
if (!issues || issues.length === 0) {
|
await fetch(`/api/admin/pm/tasks/${taskId}/toggle-urgent`, {
|
||||||
container.innerHTML = '<div class="p-12 text-center text-gray-500">등록된 이슈가 없습니다.</div>';
|
method: 'POST',
|
||||||
return;
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }
|
||||||
}
|
|
||||||
|
|
||||||
const typeIcons = { bug: '🐛', feature: '✨', improvement: '💡' };
|
|
||||||
const statusColors = {
|
|
||||||
open: 'bg-red-100 text-red-700',
|
|
||||||
in_progress: 'bg-yellow-100 text-yellow-700',
|
|
||||||
resolved: 'bg-green-100 text-green-700',
|
|
||||||
closed: 'bg-gray-100 text-gray-700'
|
|
||||||
};
|
|
||||||
const statusLabels = { open: 'Open', in_progress: '진행중', resolved: '해결됨', closed: '종료' };
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
issues.forEach(issue => {
|
|
||||||
html += `
|
|
||||||
<div class="p-4 hover:bg-gray-50 flex items-center gap-4">
|
|
||||||
<input type="checkbox" class="issue-checkbox w-4 h-4 rounded border-gray-300" data-id="${issue.id}">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>${typeIcons[issue.type] || '📌'}</span>
|
|
||||||
<span class="font-medium text-gray-900 cursor-pointer hover:text-blue-600" onclick="editIssue(${issue.id})">${issue.title}</span>
|
|
||||||
<span class="px-2 py-0.5 text-xs rounded-full ${statusColors[issue.status]}">${statusLabels[issue.status]}</span>
|
|
||||||
</div>
|
|
||||||
${issue.description ? `<p class="text-sm text-gray-500 mt-1">${issue.description.substring(0, 100)}</p>` : ''}
|
|
||||||
${issue.task ? `<p class="text-xs text-gray-400 mt-1">연결: ${issue.task.title}</p>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button onclick="changeIssueStatus(${issue.id}, 'open')" class="px-2 py-1 text-xs rounded ${issue.status === 'open' ? 'bg-red-200' : 'hover:bg-gray-100'}">Open</button>
|
|
||||||
<button onclick="changeIssueStatus(${issue.id}, 'in_progress')" class="px-2 py-1 text-xs rounded ${issue.status === 'in_progress' ? 'bg-yellow-200' : 'hover:bg-gray-100'}">진행</button>
|
|
||||||
<button onclick="changeIssueStatus(${issue.id}, 'resolved')" class="px-2 py-1 text-xs rounded ${issue.status === 'resolved' ? 'bg-green-200' : 'hover:bg-gray-100'}">해결</button>
|
|
||||||
<button onclick="changeIssueStatus(${issue.id}, 'closed')" class="px-2 py-1 text-xs rounded ${issue.status === 'closed' ? 'bg-gray-200' : 'hover:bg-gray-100'}">종료</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
});
|
||||||
container.innerHTML = html;
|
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) {
|
async function changeTaskStatus(taskId, status) {
|
||||||
await fetch(`/api/admin/pm/tasks/${taskId}/status`, {
|
await fetch(`/api/admin/pm/tasks/${taskId}/status`, {
|
||||||
method: 'PUT',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
||||||
body: JSON.stringify({ status })
|
body: JSON.stringify({ status })
|
||||||
});
|
});
|
||||||
htmx.trigger(document.body, 'taskRefresh');
|
loadTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이슈 상태 변경
|
// 이슈 상태 변경 (이슈 탭)
|
||||||
async function changeIssueStatus(issueId, status) {
|
async function changeIssueStatus(issueId, status) {
|
||||||
await fetch(`/api/admin/pm/issues/${issueId}/status`, {
|
await fetch(`/api/admin/pm/issues/${issueId}/status`, {
|
||||||
method: 'PUT',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
||||||
body: JSON.stringify({ status })
|
body: JSON.stringify({ status })
|
||||||
});
|
});
|
||||||
htmx.trigger(document.body, 'issueRefresh');
|
|
||||||
|
// 이슈가 "진행"으로 변경될 때, 연결된 작업이 "할일"이면 자동으로 "진행중"으로 변경
|
||||||
|
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(); // 이슈 탭도 동기화
|
||||||
}
|
}
|
||||||
|
|
||||||
// 작업 모달
|
// 작업 모달
|
||||||
@@ -496,7 +811,7 @@ function closeTaskModal() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
closeTaskModal();
|
closeTaskModal();
|
||||||
htmx.trigger(document.body, 'taskRefresh');
|
loadTasks();
|
||||||
} else {
|
} else {
|
||||||
alert(result.message || '저장에 실패했습니다.');
|
alert(result.message || '저장에 실패했습니다.');
|
||||||
}
|
}
|
||||||
@@ -551,7 +866,7 @@ function closeIssueModal() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
closeIssueModal();
|
closeIssueModal();
|
||||||
htmx.trigger(document.body, 'issueRefresh');
|
loadIssues();
|
||||||
} else {
|
} else {
|
||||||
alert(result.message || '저장에 실패했습니다.');
|
alert(result.message || '저장에 실패했습니다.');
|
||||||
}
|
}
|
||||||
@@ -589,7 +904,7 @@ function getSelectedIssueIds() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
select.value = '';
|
select.value = '';
|
||||||
htmx.trigger(document.body, 'taskRefresh');
|
loadTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleIssueBulkAction() {
|
async function handleIssueBulkAction() {
|
||||||
@@ -615,7 +930,7 @@ function getSelectedIssueIds() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
select.value = '';
|
select.value = '';
|
||||||
htmx.trigger(document.body, 'issueRefresh');
|
loadIssues();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@endpush
|
@endpush
|
||||||
@@ -20,10 +20,12 @@
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
|
||||||
| HTMX 요청 시 HTML 반환, 일반 요청 시 JSON 반환
|
| HTMX 요청 시 HTML 반환, 일반 요청 시 JSON 반환
|
||||||
|
|
| - auth: 기본 인증 확인
|
||||||
|
| - hq.member: 본사(HQ) 테넌트 소속 확인
|
||||||
|
| - super.admin: 슈퍼관리자 전용 (복구, 영구삭제)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
Route::middleware(['web', 'auth'])->prefix('admin')->name('api.admin.')->group(function () {
|
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin')->name('api.admin.')->group(function () {
|
||||||
|
|
||||||
// 테넌트 관리 API
|
// 테넌트 관리 API
|
||||||
Route::prefix('tenants')->name('tenants.')->group(function () {
|
Route::prefix('tenants')->name('tenants.')->group(function () {
|
||||||
@@ -37,9 +39,11 @@
|
|||||||
Route::put('/{id}', [TenantController::class, 'update'])->name('update');
|
Route::put('/{id}', [TenantController::class, 'update'])->name('update');
|
||||||
Route::delete('/{id}', [TenantController::class, 'destroy'])->name('destroy');
|
Route::delete('/{id}', [TenantController::class, 'destroy'])->name('destroy');
|
||||||
|
|
||||||
// 추가 액션
|
// 슈퍼관리자 전용 액션
|
||||||
Route::post('/{id}/restore', [TenantController::class, 'restore'])->name('restore');
|
Route::middleware('super.admin')->group(function () {
|
||||||
Route::delete('/{id}/force', [TenantController::class, 'forceDestroy'])->name('forceDestroy');
|
Route::post('/{id}/restore', [TenantController::class, 'restore'])->name('restore');
|
||||||
|
Route::delete('/{id}/force', [TenantController::class, 'forceDestroy'])->name('forceDestroy');
|
||||||
|
});
|
||||||
|
|
||||||
// 모달 관련 API
|
// 모달 관련 API
|
||||||
Route::get('/{id}/modal', [TenantController::class, 'modal'])->name('modal');
|
Route::get('/{id}/modal', [TenantController::class, 'modal'])->name('modal');
|
||||||
@@ -66,8 +70,12 @@
|
|||||||
Route::get('/{id}', [DepartmentController::class, 'show'])->name('show');
|
Route::get('/{id}', [DepartmentController::class, 'show'])->name('show');
|
||||||
Route::put('/{id}', [DepartmentController::class, 'update'])->name('update');
|
Route::put('/{id}', [DepartmentController::class, 'update'])->name('update');
|
||||||
Route::delete('/{id}', [DepartmentController::class, 'destroy'])->name('destroy');
|
Route::delete('/{id}', [DepartmentController::class, 'destroy'])->name('destroy');
|
||||||
Route::post('/{id}/restore', [DepartmentController::class, 'restore'])->name('restore');
|
|
||||||
Route::delete('/{id}/force', [DepartmentController::class, 'forceDelete'])->name('forceDelete');
|
// 슈퍼관리자 전용 액션
|
||||||
|
Route::middleware('super.admin')->group(function () {
|
||||||
|
Route::post('/{id}/restore', [DepartmentController::class, 'restore'])->name('restore');
|
||||||
|
Route::delete('/{id}/force', [DepartmentController::class, 'forceDelete'])->name('forceDelete');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 사용자 관리 API
|
// 사용자 관리 API
|
||||||
@@ -78,9 +86,11 @@
|
|||||||
Route::put('/{id}', [UserController::class, 'update'])->name('update');
|
Route::put('/{id}', [UserController::class, 'update'])->name('update');
|
||||||
Route::delete('/{id}', [UserController::class, 'destroy'])->name('destroy');
|
Route::delete('/{id}', [UserController::class, 'destroy'])->name('destroy');
|
||||||
|
|
||||||
// 추가 액션
|
// 슈퍼관리자 전용 액션
|
||||||
Route::post('/{id}/restore', [UserController::class, 'restore'])->name('restore');
|
Route::middleware('super.admin')->group(function () {
|
||||||
Route::delete('/{id}/force', [UserController::class, 'forceDestroy'])->name('forceDestroy');
|
Route::post('/{id}/restore', [UserController::class, 'restore'])->name('restore');
|
||||||
|
Route::delete('/{id}/force', [UserController::class, 'forceDestroy'])->name('forceDestroy');
|
||||||
|
});
|
||||||
|
|
||||||
// 모달 관련 API
|
// 모달 관련 API
|
||||||
Route::get('/{id}/modal', [UserController::class, 'modal'])->name('modal');
|
Route::get('/{id}/modal', [UserController::class, 'modal'])->name('modal');
|
||||||
@@ -98,9 +108,13 @@
|
|||||||
Route::put('/{id}', [MenuController::class, 'update'])->name('update');
|
Route::put('/{id}', [MenuController::class, 'update'])->name('update');
|
||||||
Route::delete('/{id}', [MenuController::class, 'destroy'])->name('destroy');
|
Route::delete('/{id}', [MenuController::class, 'destroy'])->name('destroy');
|
||||||
|
|
||||||
|
// 슈퍼관리자 전용 액션
|
||||||
|
Route::middleware('super.admin')->group(function () {
|
||||||
|
Route::post('/{id}/restore', [MenuController::class, 'restore'])->name('restore');
|
||||||
|
Route::delete('/{id}/force', [MenuController::class, 'forceDestroy'])->name('forceDestroy');
|
||||||
|
});
|
||||||
|
|
||||||
// 추가 액션
|
// 추가 액션
|
||||||
Route::post('/{id}/restore', [MenuController::class, 'restore'])->name('restore');
|
|
||||||
Route::delete('/{id}/force', [MenuController::class, 'forceDestroy'])->name('forceDestroy');
|
|
||||||
Route::post('/{id}/toggle-active', [MenuController::class, 'toggleActive'])->name('toggleActive');
|
Route::post('/{id}/toggle-active', [MenuController::class, 'toggleActive'])->name('toggleActive');
|
||||||
Route::post('/{id}/toggle-hidden', [MenuController::class, 'toggleHidden'])->name('toggleHidden');
|
Route::post('/{id}/toggle-hidden', [MenuController::class, 'toggleHidden'])->name('toggleHidden');
|
||||||
});
|
});
|
||||||
@@ -126,9 +140,13 @@
|
|||||||
Route::put('/{id}', [BoardController::class, 'update'])->name('update');
|
Route::put('/{id}', [BoardController::class, 'update'])->name('update');
|
||||||
Route::delete('/{id}', [BoardController::class, 'destroy'])->name('destroy');
|
Route::delete('/{id}', [BoardController::class, 'destroy'])->name('destroy');
|
||||||
|
|
||||||
|
// 슈퍼관리자 전용 액션
|
||||||
|
Route::middleware('super.admin')->group(function () {
|
||||||
|
Route::post('/{id}/restore', [BoardController::class, 'restore'])->name('restore');
|
||||||
|
Route::delete('/{id}/force', [BoardController::class, 'forceDestroy'])->name('forceDestroy');
|
||||||
|
});
|
||||||
|
|
||||||
// 추가 액션
|
// 추가 액션
|
||||||
Route::post('/{id}/restore', [BoardController::class, 'restore'])->name('restore');
|
|
||||||
Route::delete('/{id}/force', [BoardController::class, 'forceDestroy'])->name('forceDestroy');
|
|
||||||
Route::post('/{id}/toggle-active', [BoardController::class, 'toggleActive'])->name('toggleActive');
|
Route::post('/{id}/toggle-active', [BoardController::class, 'toggleActive'])->name('toggleActive');
|
||||||
|
|
||||||
// 필드 관리 API
|
// 필드 관리 API
|
||||||
@@ -203,9 +221,13 @@
|
|||||||
Route::put('/{id}', [PmProjectController::class, 'update'])->name('update');
|
Route::put('/{id}', [PmProjectController::class, 'update'])->name('update');
|
||||||
Route::delete('/{id}', [PmProjectController::class, 'destroy'])->name('destroy');
|
Route::delete('/{id}', [PmProjectController::class, 'destroy'])->name('destroy');
|
||||||
|
|
||||||
|
// 슈퍼관리자 전용 액션
|
||||||
|
Route::middleware('super.admin')->group(function () {
|
||||||
|
Route::post('/{id}/restore', [PmProjectController::class, 'restore'])->name('restore');
|
||||||
|
Route::delete('/{id}/force', [PmProjectController::class, 'forceDestroy'])->name('forceDestroy');
|
||||||
|
});
|
||||||
|
|
||||||
// 추가 액션
|
// 추가 액션
|
||||||
Route::post('/{id}/restore', [PmProjectController::class, 'restore'])->name('restore');
|
|
||||||
Route::delete('/{id}/force', [PmProjectController::class, 'forceDestroy'])->name('forceDestroy');
|
|
||||||
Route::post('/{id}/status', [PmProjectController::class, 'changeStatus'])->name('changeStatus');
|
Route::post('/{id}/status', [PmProjectController::class, 'changeStatus'])->name('changeStatus');
|
||||||
Route::post('/{id}/duplicate', [PmProjectController::class, 'duplicate'])->name('duplicate');
|
Route::post('/{id}/duplicate', [PmProjectController::class, 'duplicate'])->name('duplicate');
|
||||||
});
|
});
|
||||||
@@ -223,10 +245,15 @@
|
|||||||
Route::put('/{id}', [PmTaskController::class, 'update'])->name('update');
|
Route::put('/{id}', [PmTaskController::class, 'update'])->name('update');
|
||||||
Route::delete('/{id}', [PmTaskController::class, 'destroy'])->name('destroy');
|
Route::delete('/{id}', [PmTaskController::class, 'destroy'])->name('destroy');
|
||||||
|
|
||||||
|
// 슈퍼관리자 전용 액션
|
||||||
|
Route::middleware('super.admin')->group(function () {
|
||||||
|
Route::post('/{id}/restore', [PmTaskController::class, 'restore'])->name('restore');
|
||||||
|
Route::delete('/{id}/force', [PmTaskController::class, 'forceDestroy'])->name('forceDestroy');
|
||||||
|
});
|
||||||
|
|
||||||
// 추가 액션
|
// 추가 액션
|
||||||
Route::post('/{id}/restore', [PmTaskController::class, 'restore'])->name('restore');
|
|
||||||
Route::delete('/{id}/force', [PmTaskController::class, 'forceDestroy'])->name('forceDestroy');
|
|
||||||
Route::post('/{id}/status', [PmTaskController::class, 'changeStatus'])->name('changeStatus');
|
Route::post('/{id}/status', [PmTaskController::class, 'changeStatus'])->name('changeStatus');
|
||||||
|
Route::post('/{id}/toggle-urgent', [PmTaskController::class, 'toggleUrgent'])->name('toggleUrgent');
|
||||||
|
|
||||||
// 프로젝트별
|
// 프로젝트별
|
||||||
Route::get('/project/{projectId}', [PmTaskController::class, 'byProject'])->name('byProject');
|
Route::get('/project/{projectId}', [PmTaskController::class, 'byProject'])->name('byProject');
|
||||||
@@ -248,10 +275,15 @@
|
|||||||
Route::put('/{id}', [PmIssueController::class, 'update'])->name('update');
|
Route::put('/{id}', [PmIssueController::class, 'update'])->name('update');
|
||||||
Route::delete('/{id}', [PmIssueController::class, 'destroy'])->name('destroy');
|
Route::delete('/{id}', [PmIssueController::class, 'destroy'])->name('destroy');
|
||||||
|
|
||||||
|
// 슈퍼관리자 전용 액션
|
||||||
|
Route::middleware('super.admin')->group(function () {
|
||||||
|
Route::post('/{id}/restore', [PmIssueController::class, 'restore'])->name('restore');
|
||||||
|
Route::delete('/{id}/force', [PmIssueController::class, 'forceDestroy'])->name('forceDestroy');
|
||||||
|
});
|
||||||
|
|
||||||
// 추가 액션
|
// 추가 액션
|
||||||
Route::post('/{id}/restore', [PmIssueController::class, 'restore'])->name('restore');
|
|
||||||
Route::delete('/{id}/force', [PmIssueController::class, 'forceDestroy'])->name('forceDestroy');
|
|
||||||
Route::post('/{id}/status', [PmIssueController::class, 'changeStatus'])->name('changeStatus');
|
Route::post('/{id}/status', [PmIssueController::class, 'changeStatus'])->name('changeStatus');
|
||||||
|
Route::post('/{id}/toggle-urgent', [PmIssueController::class, 'toggleUrgent'])->name('toggleUrgent');
|
||||||
|
|
||||||
// 연관별
|
// 연관별
|
||||||
Route::get('/project/{projectId}', [PmIssueController::class, 'byProject'])->name('byProject');
|
Route::get('/project/{projectId}', [PmIssueController::class, 'byProject'])->name('byProject');
|
||||||
|
|||||||
Reference in New Issue
Block a user