feat: 기존 작업에 이슈 추가 Import 기능

- ImportService에 importIssuesToTask 메서드 추가
- ImportController에 importIssues 액션 추가
- ImportIssuesRequest FormRequest 생성
- POST /api/admin/pm/import/task/{taskId}/issues 라우트 추가
- import.blade.php UI에 '기존 작업에 이슈 추가' 모드 추가
- ImportProjectRequest에 tasks 레벨 검증 규칙 보완
This commit is contained in:
2025-12-09 16:39:52 +09:00
parent 1b18e2fd31
commit 82b9ac0ce3
7 changed files with 271 additions and 22 deletions

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Admin\ProjectManagement; namespace App\Http\Controllers\Api\Admin\ProjectManagement;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\ProjectManagement\ImportIssuesRequest;
use App\Http\Requests\ProjectManagement\ImportProjectRequest; use App\Http\Requests\ProjectManagement\ImportProjectRequest;
use App\Services\ProjectManagement\ImportService; use App\Services\ProjectManagement\ImportService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -75,6 +76,27 @@ public function importTasks(Request $request, int $projectId): JsonResponse
} }
} }
/**
* 기존 작업에 이슈만 추가
*/
public function importIssues(ImportIssuesRequest $request, int $taskId): JsonResponse
{
try {
$result = $this->importService->importIssuesToTask($taskId, $request->validated()['issues']);
return response()->json([
'success' => true,
'message' => "이슈가 추가되었습니다. ({$result['issues_count']}개)",
'data' => $result,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '가져오기 실패: '.$e->getMessage(),
], 500);
}
}
/** /**
* JSON 구조 사전 검증 * JSON 구조 사전 검증
*/ */

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests\ProjectManagement;
use Illuminate\Foundation\Http\FormRequest;
class ImportIssuesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'issues' => 'required|array|min:1',
'issues.*.title' => 'required|string|max:255',
'issues.*.description' => 'nullable|string',
'issues.*.type' => 'nullable|in:bug,feature,improvement',
'issues.*.status' => 'nullable|in:open,in_progress,resolved,closed',
// 일정 관련
'issues.*.start_date' => 'nullable|date',
'issues.*.due_date' => 'nullable|date|after_or_equal:issues.*.start_date',
'issues.*.estimated_hours' => 'nullable|integer|min:0',
'issues.*.is_urgent' => 'nullable|boolean',
// 팀/담당자/고객사 (하이브리드)
'issues.*.department_id' => 'nullable|integer|exists:departments,id',
'issues.*.team' => 'nullable|string|max:100',
'issues.*.assignee_id' => 'nullable|integer|exists:users,id',
'issues.*.assignee_name' => 'nullable|string|max:100',
'issues.*.client' => 'nullable|string|max:100',
];
}
public function messages(): array
{
return [
'issues.required' => 'issues 배열은 필수입니다.',
'issues.min' => '최소 1개 이상의 이슈가 필요합니다.',
'issues.*.title.required' => '각 이슈의 title은 필수입니다.',
];
}
}

View File

@@ -29,6 +29,9 @@ public function rules(): array
'tasks.*.status' => 'nullable|in:todo,in_progress,done', 'tasks.*.status' => 'nullable|in:todo,in_progress,done',
'tasks.*.priority' => 'nullable|in:low,medium,high', 'tasks.*.priority' => 'nullable|in:low,medium,high',
'tasks.*.due_date' => 'nullable|date', 'tasks.*.due_date' => 'nullable|date',
'tasks.*.is_urgent' => 'nullable|boolean',
'tasks.*.assignee_id' => 'nullable|integer|exists:users,id',
'tasks.*.assignee_name' => 'nullable|string|max:100',
// 작업별 이슈 목록 // 작업별 이슈 목록
'tasks.*.issues' => 'nullable|array', 'tasks.*.issues' => 'nullable|array',

View File

@@ -139,6 +139,48 @@ public function importTasksToProject(int $projectId, array $tasks): array
}); });
} }
/**
* 기존 작업에 이슈만 추가
*/
public function importIssuesToTask(int $taskId, array $issues): array
{
return DB::transaction(function () use ($taskId, $issues) {
$task = AdminPmTask::findOrFail($taskId);
$result = [
'task_id' => $taskId,
'task_title' => $task->title,
'project_id' => $task->project_id,
'issues_count' => 0,
];
foreach ($issues as $issueData) {
AdminPmIssue::create([
'project_id' => $task->project_id,
'task_id' => $task->id,
'title' => $issueData['title'],
'description' => $issueData['description'] ?? null,
'type' => $issueData['type'] ?? AdminPmIssue::TYPE_FEATURE,
'status' => $issueData['status'] ?? AdminPmIssue::STATUS_OPEN,
'start_date' => $issueData['start_date'] ?? null,
'due_date' => $issueData['due_date'] ?? null,
'estimated_hours' => $issueData['estimated_hours'] ?? null,
'is_urgent' => $issueData['is_urgent'] ?? false,
// 팀/담당자/고객사 (하이브리드)
'department_id' => $issueData['department_id'] ?? null,
'team' => $issueData['team'] ?? null,
'assignee_id' => $issueData['assignee_id'] ?? null,
'assignee_name' => $issueData['assignee_name'] ?? null,
'client' => $issueData['client'] ?? null,
'created_by' => auth()->id(),
]);
$result['issues_count']++;
}
return $result;
});
}
/** /**
* JSON 샘플 템플릿 반환 * JSON 샘플 템플릿 반환
*/ */

15
ms1-issues.json Normal file
View File

@@ -0,0 +1,15 @@
{
"issues": [
{"title": "견적관리", "description": "공수: 1.75일", "type": "feature", "start_date": "2025-11-27", "due_date": "2025-11-28", "team": "디자인팀"},
{"title": "기준정보-견적수식", "description": "공수: 0.3일", "type": "feature", "start_date": "2025-11-28", "due_date": "2025-12-01", "team": "디자인팀"},
{"title": "수주관리", "description": "공수: 3.25일", "type": "feature", "start_date": "2025-12-01", "due_date": "2025-12-04", "team": "디자인팀"},
{"title": "생산관리", "description": "공수: 1.95일", "type": "feature", "start_date": "2025-12-04", "due_date": "2025-12-08", "team": "디자인팀"},
{"title": "기준정보-공정", "description": "공수: 1.45일", "type": "feature", "start_date": "2025-12-08", "due_date": "2025-12-10", "team": "디자인팀"},
{"title": "출하관리", "description": "공수: 2.25일", "type": "feature", "start_date": "2025-12-10", "due_date": "2025-12-12", "team": "디자인팀"},
{"title": "거래처관리", "description": "공수: 1.1일", "type": "feature", "start_date": "2025-12-15", "due_date": "2025-12-16", "team": "디자인팀"},
{"title": "품질관리", "description": "공수: 3.5일", "type": "feature", "start_date": "2025-12-16", "due_date": "2025-12-19", "team": "디자인팀"},
{"title": "자재관리", "description": "공수: 3.3일", "type": "feature", "start_date": "2025-12-19", "due_date": "2025-12-24", "team": "디자인팀"},
{"title": "단가관리", "description": "공수: 1일", "type": "feature", "start_date": "2025-12-24", "due_date": "2025-12-24", "team": "디자인팀"},
{"title": "회계관리", "description": "공수: 1일", "type": "feature", "start_date": "2025-12-26", "due_date": "2025-12-26", "team": "디자인팀"}
]
}

View File

@@ -24,7 +24,7 @@
{{-- Import 모드 선택 --}} {{-- Import 모드 선택 --}}
<div class="p-6 bg-white border border-gray-200 rounded-lg shadow-sm"> <div class="p-6 bg-white border border-gray-200 rounded-lg shadow-sm">
<h2 class="mb-4 text-lg font-medium text-gray-900">Import 모드</h2> <h2 class="mb-4 text-lg font-medium text-gray-900">Import 모드</h2>
<div class="flex space-x-4"> <div class="flex flex-wrap gap-4">
<label class="flex items-center"> <label class="flex items-center">
<input type="radio" name="import_mode" value="new" checked <input type="radio" name="import_mode" value="new" checked
class="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500" class="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
@@ -35,20 +35,35 @@ class="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
<input type="radio" name="import_mode" value="existing" <input type="radio" name="import_mode" value="existing"
class="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500" class="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
onchange="toggleImportMode()"> onchange="toggleImportMode()">
<span class="ml-2 text-sm text-gray-700">기존 프로젝트에 추가</span> <span class="ml-2 text-sm text-gray-700">기존 프로젝트에 작업 추가</span>
</label>
<label class="flex items-center">
<input type="radio" name="import_mode" value="task_issues"
class="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
onchange="toggleImportMode()">
<span class="ml-2 text-sm text-gray-700">기존 작업에 이슈 추가</span>
</label> </label>
</div> </div>
{{-- 기존 프로젝트 선택 (existing 모드) --}} {{-- 기존 프로젝트 선택 (existing, task_issues 모드) --}}
<div id="existingProjectSelect" class="hidden mt-4"> <div id="existingProjectSelect" class="hidden mt-4">
<label class="block text-sm font-medium text-gray-700">대상 프로젝트</label> <label class="block text-sm font-medium text-gray-700">대상 프로젝트</label>
<select id="targetProject" class="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500"> <select id="targetProject" class="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500"
onchange="loadTasks()">
<option value="">프로젝트 선택...</option> <option value="">프로젝트 선택...</option>
@foreach($projects as $project) @foreach($projects as $project)
<option value="{{ $project->id }}">{{ $project->name }}</option> <option value="{{ $project->id }}">{{ $project->name }}</option>
@endforeach @endforeach
</select> </select>
</div> </div>
{{-- 작업 선택 (task_issues 모드) --}}
<div id="existingTaskSelect" class="hidden mt-4">
<label class="block text-sm font-medium text-gray-700">대상 작업</label>
<select id="targetTask" class="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500">
<option value="">작업 선택...</option>
</select>
</div>
</div> </div>
{{-- JSON 입력 --}} {{-- JSON 입력 --}}
@@ -116,27 +131,28 @@ class="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-indigo-600 rounded-l
<h4 class="text-sm font-medium text-gray-900"> 프로젝트 생성</h4> <h4 class="text-sm font-medium text-gray-900"> 프로젝트 생성</h4>
<pre class="p-3 mt-2 overflow-x-auto text-xs bg-gray-50 rounded-lg"><code>{ <pre class="p-3 mt-2 overflow-x-auto text-xs bg-gray-50 rounded-lg"><code>{
"project": { "project": {
"name": "프로젝트명 (필수)", "name": "SAM 시스템 개발",
"description": "설명", "description": "프로젝트 설명입니다",
"status": "active|completed|on_hold", "status": "active",
"start_date": "2025-01-01", "start_date": "2025-01-01",
"end_date": "2025-03-31" "end_date": "2025-03-31"
}, },
"tasks": [ "tasks": [
{ {
"title": "작업 제목 (필수)", "title": "API 개발",
"description": "작업 설명", "description": "REST API 엔드포인트 구현",
"status": "todo|in_progress|done", "status": "in_progress",
"priority": "low|medium|high", "priority": "high",
"is_urgent": false, "is_urgent": false,
"due_date": "2025-01-15", "due_date": "2025-01-15",
"assignee_id": null, "assignee_id": null,
"assignee_name": "김개발",
"issues": [ "issues": [
{ {
"title": "이슈 제목 (필수)", "title": "사용자 인증 구현",
"description": "이슈 설명", "description": "JWT 기반 인증 시스템",
"type": "bug|feature|improvement", "type": "feature",
"status": "open|in_progress|resolved|closed", "status": "open",
"start_date": "2025-01-01", "start_date": "2025-01-01",
"due_date": "2025-01-15", "due_date": "2025-01-15",
"estimated_hours": 8, "estimated_hours": 8,
@@ -145,7 +161,7 @@ class="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-indigo-600 rounded-l
"team": "개발팀", "team": "개발팀",
"assignee_id": null, "assignee_id": null,
"assignee_name": "홍길동", "assignee_name": "홍길동",
"client": "고객사명" "client": "내부"
} }
] ]
} }
@@ -163,6 +179,30 @@ class="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-indigo-600 rounded-l
} }
] ]
}</code></pre> }</code></pre>
<h4 class="mt-6 text-sm font-medium text-gray-900">기존 작업에 이슈 추가</h4>
<pre class="p-3 mt-2 overflow-x-auto text-xs bg-gray-50 rounded-lg"><code>{
"issues": [
{
"title": "Phase 1: 견적관리",
"description": "공수: 1.75일",
"type": "feature",
"status": "open",
"start_date": "2024-11-27",
"due_date": "2024-11-28",
"estimated_hours": 14,
"team": "개발팀"
},
{
"title": "Phase 2: 기준정보-견적수식",
"description": "공수: 0.3일",
"type": "feature",
"status": "open",
"start_date": "2024-11-28",
"due_date": "2024-12-01"
}
]
}</code></pre>
</div> </div>
</div> </div>
@@ -249,12 +289,58 @@ class="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-indigo-600 rounded-l
function toggleImportMode() { function toggleImportMode() {
const mode = document.querySelector('input[name="import_mode"]:checked').value; const mode = document.querySelector('input[name="import_mode"]:checked').value;
const existingSelect = document.getElementById('existingProjectSelect'); const existingSelect = document.getElementById('existingProjectSelect');
const taskSelect = document.getElementById('existingTaskSelect');
if (mode === 'existing') { if (mode === 'existing' || mode === 'task_issues') {
existingSelect.classList.remove('hidden'); existingSelect.classList.remove('hidden');
} else { } else {
existingSelect.classList.add('hidden'); existingSelect.classList.add('hidden');
} }
if (mode === 'task_issues') {
taskSelect.classList.remove('hidden');
loadTasks(); // 작업 목록 로드
} else {
taskSelect.classList.add('hidden');
}
}
// 프로젝트별 작업 목록 로드
async function loadTasks() {
const mode = document.querySelector('input[name="import_mode"]:checked').value;
if (mode !== 'task_issues') return;
const projectId = document.getElementById('targetProject').value;
const taskSelect = document.getElementById('targetTask');
if (!projectId) {
taskSelect.innerHTML = '<option value="">작업 선택...</option>';
return;
}
try {
const response = await fetch(`/api/admin/pm/tasks/project/${projectId}`, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
});
if (!response.ok) throw new Error('작업 목록 로드 실패');
const result = await response.json();
const tasks = result.data || [];
taskSelect.innerHTML = '<option value="">작업 선택...</option>';
tasks.forEach(task => {
const option = document.createElement('option');
option.value = task.id;
option.textContent = task.title;
taskSelect.appendChild(option);
});
} catch (e) {
showError('작업 목록 로드 실패: ' + e.message);
}
} }
// 샘플 템플릿 불러오기 // 샘플 템플릿 불러오기
@@ -265,6 +351,22 @@ function loadTemplate() {
if (mode === 'existing') { if (mode === 'existing') {
// 기존 프로젝트 모드에서는 tasks만 표시 // 기존 프로젝트 모드에서는 tasks만 표시
template = { tasks: sampleTemplate.tasks }; template = { tasks: sampleTemplate.tasks };
} else if (mode === 'task_issues') {
// 기존 작업에 이슈 추가 모드에서는 issues만 표시
template = {
issues: [
{
title: "이슈 제목",
description: "이슈 설명",
type: "feature",
status: "open",
start_date: new Date().toISOString().split('T')[0],
due_date: new Date(Date.now() + 7*24*60*60*1000).toISOString().split('T')[0],
estimated_hours: 8,
team: "개발팀"
}
]
};
} }
document.getElementById('jsonInput').value = JSON.stringify(template, null, 2); document.getElementById('jsonInput').value = JSON.stringify(template, null, 2);
@@ -387,6 +489,13 @@ function loadFile(event) {
return; return;
} }
url = `/api/admin/pm/import/project/${projectId}/tasks`; url = `/api/admin/pm/import/project/${projectId}/tasks`;
} else if (mode === 'task_issues') {
const taskId = document.getElementById('targetTask').value;
if (!taskId) {
showError('대상 작업을 선택해주세요.');
return;
}
url = `/api/admin/pm/import/task/${taskId}/issues`;
} }
try { try {
@@ -415,17 +524,30 @@ function loadFile(event) {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
showResult('success', result.message, [ let resultItems = [];
`프로젝트: ${result.data.project_name || result.data.project_id}`, if (mode === 'task_issues') {
`작업: ${result.data.tasks_count}개`, resultItems = [
`이슈: ${result.data.issues_count}개`, `작업: ${result.data.task_title}`,
]); `이슈: ${result.data.issues_count}개 추가`,
];
} else {
resultItems = [
`프로젝트: ${result.data.project_name || result.data.project_id}`,
`작업: ${result.data.tasks_count}개`,
`이슈: ${result.data.issues_count}개`,
];
}
showResult('success', result.message, resultItems);
showToast('Import 완료!', 'success'); showToast('Import 완료!', 'success');
// 새 프로젝트면 상세 페이지로 이동 버튼 표시 // 새 프로젝트면 상세 페이지로 이동 버튼 표시
if (mode === 'new' && result.data.project_id) { if (mode === 'new' && result.data.project_id) {
appendViewButton(result.data.project_id); appendViewButton(result.data.project_id);
} }
// 작업 이슈 추가 모드면 프로젝트 상세 페이지로 이동 버튼 표시
if (mode === 'task_issues' && result.data.project_id) {
appendViewButton(result.data.project_id);
}
} else { } else {
showResult('error', 'Import 실패', [result.message]); showResult('error', 'Import 실패', [result.message]);
} }

View File

@@ -351,6 +351,7 @@
Route::post('/validate', [PmImportController::class, 'validate'])->name('validate'); Route::post('/validate', [PmImportController::class, 'validate'])->name('validate');
Route::post('/', [PmImportController::class, 'import'])->name('import'); Route::post('/', [PmImportController::class, 'import'])->name('import');
Route::post('/project/{projectId}/tasks', [PmImportController::class, 'importTasks'])->name('importTasks'); Route::post('/project/{projectId}/tasks', [PmImportController::class, 'importTasks'])->name('importTasks');
Route::post('/task/{taskId}/issues', [PmImportController::class, 'importIssues'])->name('importIssues');
}); });
}); });