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:
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api\Admin\ProjectManagement;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ProjectManagement\ImportIssuesRequest;
|
||||
use App\Http\Requests\ProjectManagement\ImportProjectRequest;
|
||||
use App\Services\ProjectManagement\ImportService;
|
||||
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 구조 사전 검증
|
||||
*/
|
||||
|
||||
44
app/Http/Requests/ProjectManagement/ImportIssuesRequest.php
Normal file
44
app/Http/Requests/ProjectManagement/ImportIssuesRequest.php
Normal 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은 필수입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@ public function rules(): array
|
||||
'tasks.*.status' => 'nullable|in:todo,in_progress,done',
|
||||
'tasks.*.priority' => 'nullable|in:low,medium,high',
|
||||
'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',
|
||||
|
||||
@@ -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 샘플 템플릿 반환
|
||||
*/
|
||||
|
||||
15
ms1-issues.json
Normal file
15
ms1-issues.json
Normal 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": "디자인팀"}
|
||||
]
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
{{-- Import 모드 선택 --}}
|
||||
<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>
|
||||
<div class="flex space-x-4">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center">
|
||||
<input type="radio" name="import_mode" value="new" checked
|
||||
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"
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{{-- 기존 프로젝트 선택 (existing 모드) --}}
|
||||
{{-- 기존 프로젝트 선택 (existing, task_issues 모드) --}}
|
||||
<div id="existingProjectSelect" class="hidden mt-4">
|
||||
<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>
|
||||
@foreach($projects as $project)
|
||||
<option value="{{ $project->id }}">{{ $project->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</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>
|
||||
|
||||
{{-- 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>
|
||||
<pre class="p-3 mt-2 overflow-x-auto text-xs bg-gray-50 rounded-lg"><code>{
|
||||
"project": {
|
||||
"name": "프로젝트명 (필수)",
|
||||
"description": "설명",
|
||||
"status": "active|completed|on_hold",
|
||||
"name": "SAM 시스템 개발",
|
||||
"description": "프로젝트 설명입니다",
|
||||
"status": "active",
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2025-03-31"
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"title": "작업 제목 (필수)",
|
||||
"description": "작업 설명",
|
||||
"status": "todo|in_progress|done",
|
||||
"priority": "low|medium|high",
|
||||
"title": "API 개발",
|
||||
"description": "REST API 엔드포인트 구현",
|
||||
"status": "in_progress",
|
||||
"priority": "high",
|
||||
"is_urgent": false,
|
||||
"due_date": "2025-01-15",
|
||||
"assignee_id": null,
|
||||
"assignee_name": "김개발",
|
||||
"issues": [
|
||||
{
|
||||
"title": "이슈 제목 (필수)",
|
||||
"description": "이슈 설명",
|
||||
"type": "bug|feature|improvement",
|
||||
"status": "open|in_progress|resolved|closed",
|
||||
"title": "사용자 인증 구현",
|
||||
"description": "JWT 기반 인증 시스템",
|
||||
"type": "feature",
|
||||
"status": "open",
|
||||
"start_date": "2025-01-01",
|
||||
"due_date": "2025-01-15",
|
||||
"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": "개발팀",
|
||||
"assignee_id": null,
|
||||
"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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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() {
|
||||
const mode = document.querySelector('input[name="import_mode"]:checked').value;
|
||||
const existingSelect = document.getElementById('existingProjectSelect');
|
||||
const taskSelect = document.getElementById('existingTaskSelect');
|
||||
|
||||
if (mode === 'existing') {
|
||||
if (mode === 'existing' || mode === 'task_issues') {
|
||||
existingSelect.classList.remove('hidden');
|
||||
} else {
|
||||
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') {
|
||||
// 기존 프로젝트 모드에서는 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);
|
||||
@@ -387,6 +489,13 @@ function loadFile(event) {
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
@@ -415,17 +524,30 @@ function loadFile(event) {
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showResult('success', result.message, [
|
||||
let resultItems = [];
|
||||
if (mode === 'task_issues') {
|
||||
resultItems = [
|
||||
`작업: ${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');
|
||||
|
||||
// 새 프로젝트면 상세 페이지로 이동 버튼 표시
|
||||
if (mode === 'new' && result.data.project_id) {
|
||||
appendViewButton(result.data.project_id);
|
||||
}
|
||||
// 작업 이슈 추가 모드면 프로젝트 상세 페이지로 이동 버튼 표시
|
||||
if (mode === 'task_issues' && result.data.project_id) {
|
||||
appendViewButton(result.data.project_id);
|
||||
}
|
||||
} else {
|
||||
showResult('error', 'Import 실패', [result.message]);
|
||||
}
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
Route::post('/validate', [PmImportController::class, 'validate'])->name('validate');
|
||||
Route::post('/', [PmImportController::class, 'import'])->name('import');
|
||||
Route::post('/project/{projectId}/tasks', [PmImportController::class, 'importTasks'])->name('importTasks');
|
||||
Route::post('/task/{taskId}/issues', [PmImportController::class, 'importIssues'])->name('importIssues');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user