Files
sam-manage/resources/views/project-management/import.blade.php
hskwon 82b9ac0ce3 feat: 기존 작업에 이슈 추가 Import 기능
- ImportService에 importIssuesToTask 메서드 추가
- ImportController에 importIssues 액션 추가
- ImportIssuesRequest FormRequest 생성
- POST /api/admin/pm/import/task/{taskId}/issues 라우트 추가
- import.blade.php UI에 '기존 작업에 이슈 추가' 모드 추가
- ImportProjectRequest에 tasks 레벨 검증 규칙 보완
2025-12-09 16:39:52 +09:00

632 lines
27 KiB
PHP

@extends('layouts.app')
@section('title', 'JSON Import')
@section('content')
<div class="space-y-6">
{{-- 헤더 --}}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900">JSON Import</h1>
<p class="mt-1 text-sm text-gray-500">JSON 파일로 프로젝트, 작업, 이슈를 일괄 등록합니다.</p>
</div>
<a href="{{ route('pm.index') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
대시보드로
</a>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
{{-- 왼쪽: Import 입력 --}}
<div class="space-y-6">
{{-- 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 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"
onchange="toggleImportMode()">
<span class="ml-2 text-sm text-gray-700"> 프로젝트 생성</span>
</label>
<label class="flex items-center">
<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>
</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, 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"
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 입력 --}}
<div class="p-6 bg-white border border-gray-200 rounded-lg shadow-sm">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium text-gray-900">JSON 입력</h2>
<div class="flex space-x-2">
<button type="button" onclick="loadTemplate()"
class="px-3 py-1.5 text-xs font-medium text-indigo-600 border border-indigo-300 rounded-lg hover:bg-indigo-50">
샘플 불러오기
</button>
<button type="button" onclick="formatJson()"
class="px-3 py-1.5 text-xs font-medium text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
포맷팅
</button>
<button type="button" onclick="clearJson()"
class="px-3 py-1.5 text-xs font-medium text-red-600 border border-red-300 rounded-lg hover:bg-red-50">
초기화
</button>
</div>
</div>
<div class="relative">
<textarea id="jsonInput"
rows="20"
class="block w-full px-4 py-3 font-mono text-sm border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500"
placeholder='{ "project": { "name": "프로젝트명" }, "tasks": [...] }'></textarea>
<div id="jsonError" class="hidden mt-2 text-sm text-red-600"></div>
</div>
{{-- 파일 업로드 --}}
<div class="mt-4">
<label class="block text-sm font-medium text-gray-700 mb-2">또는 파일 업로드</label>
<input type="file" id="jsonFile" accept=".json"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"
onchange="loadFile(event)">
</div>
</div>
{{-- 액션 버튼 --}}
<div class="flex space-x-3">
<button type="button" onclick="validateJson()"
class="flex-1 px-4 py-2.5 text-sm font-medium text-indigo-700 bg-indigo-100 rounded-lg hover:bg-indigo-200">
JSON 검증
</button>
<button type="button" onclick="importData()"
class="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700">
Import 실행
</button>
</div>
</div>
{{-- 오른쪽: 도움말 --}}
<div class="space-y-6">
{{-- 결과/상태 표시 --}}
<div id="resultCard" class="hidden p-6 bg-white border border-gray-200 rounded-lg shadow-sm">
<h2 class="mb-4 text-lg font-medium text-gray-900">결과</h2>
<div id="resultContent"></div>
</div>
{{-- JSON 구조 가이드 --}}
<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">JSON 구조 가이드</h2>
<div class="prose prose-sm max-w-none">
<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": "SAM 시스템 개발",
"description": "프로젝트 설명입니다",
"status": "active",
"start_date": "2025-01-01",
"end_date": "2025-03-31"
},
"tasks": [
{
"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": "JWT 기반 인증 시스템",
"type": "feature",
"status": "open",
"start_date": "2025-01-01",
"due_date": "2025-01-15",
"estimated_hours": 8,
"is_urgent": false,
"department_id": null,
"team": "개발팀",
"assignee_id": null,
"assignee_name": "홍길동",
"client": "내부"
}
]
}
]
}</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>{
"tasks": [
{
"title": "추가할 작업",
"priority": "high",
"is_urgent": true,
"issues": [...]
}
]
}</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 class="p-6 bg-white border border-gray-200 rounded-lg shadow-sm">
<h2 class="mb-4 text-lg font-medium text-gray-900">필드 설명</h2>
<div class="space-y-4 text-sm">
<div>
<h4 class="font-medium text-gray-900">프로젝트 상태</h4>
<ul class="mt-1 ml-4 text-gray-600 list-disc">
<li><code class="px-1 bg-gray-100 rounded">active</code> - 진행중</li>
<li><code class="px-1 bg-gray-100 rounded">completed</code> - 완료</li>
<li><code class="px-1 bg-gray-100 rounded">on_hold</code> - 보류</li>
</ul>
</div>
<div>
<h4 class="font-medium text-gray-900">작업 상태</h4>
<ul class="mt-1 ml-4 text-gray-600 list-disc">
<li><code class="px-1 bg-gray-100 rounded">todo</code> - 예정</li>
<li><code class="px-1 bg-gray-100 rounded">in_progress</code> - 진행중</li>
<li><code class="px-1 bg-gray-100 rounded">done</code> - 완료</li>
</ul>
</div>
<div>
<h4 class="font-medium text-gray-900">우선순위</h4>
<ul class="mt-1 ml-4 text-gray-600 list-disc">
<li><code class="px-1 bg-gray-100 rounded">low</code> - 낮음</li>
<li><code class="px-1 bg-gray-100 rounded">medium</code> - 보통</li>
<li><code class="px-1 bg-gray-100 rounded">high</code> - 높음</li>
</ul>
</div>
<div>
<h4 class="font-medium text-gray-900">이슈 타입</h4>
<ul class="mt-1 ml-4 text-gray-600 list-disc">
<li><code class="px-1 bg-gray-100 rounded">bug</code> - 버그</li>
<li><code class="px-1 bg-gray-100 rounded">feature</code> - 기능</li>
<li><code class="px-1 bg-gray-100 rounded">improvement</code> - 개선</li>
</ul>
</div>
<div>
<h4 class="font-medium text-gray-900">이슈 상태</h4>
<ul class="mt-1 ml-4 text-gray-600 list-disc">
<li><code class="px-1 bg-gray-100 rounded">open</code> - 대기중</li>
<li><code class="px-1 bg-gray-100 rounded">in_progress</code> - 처리중</li>
<li><code class="px-1 bg-gray-100 rounded">resolved</code> - 해결됨</li>
<li><code class="px-1 bg-gray-100 rounded">closed</code> - 종료</li>
</ul>
</div>
<div>
<h4 class="font-medium text-gray-900">담당자/ (하이브리드)</h4>
<ul class="mt-1 ml-4 text-gray-600 list-disc">
<li><code class="px-1 bg-gray-100 rounded">department_id</code> - 부서 ID (DB 연동)</li>
<li><code class="px-1 bg-gray-100 rounded">team</code> - 팀명 (문자열 직접 입력)</li>
<li><code class="px-1 bg-gray-100 rounded">assignee_id</code> - 담당자 ID (DB 연동)</li>
<li><code class="px-1 bg-gray-100 rounded">assignee_name</code> - 담당자명 (문자열)</li>
<li><code class="px-1 bg-gray-100 rounded">client</code> - 고객사명</li>
</ul>
</div>
<div>
<h4 class="font-medium text-gray-900">기타 필드</h4>
<ul class="mt-1 ml-4 text-gray-600 list-disc">
<li><code class="px-1 bg-gray-100 rounded">is_urgent</code> - 긴급 여부 (true/false)</li>
<li><code class="px-1 bg-gray-100 rounded">estimated_hours</code> - 예상 소요시간 (시간)</li>
<li><code class="px-1 bg-gray-100 rounded">start_date</code> - 시작일 (YYYY-MM-DD)</li>
<li><code class="px-1 bg-gray-100 rounded">due_date</code> - 마감일 (YYYY-MM-DD)</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
{{-- 알림 토스트 --}}
<div id="toast" class="fixed hidden px-6 py-3 text-white transition-opacity duration-300 transform -translate-x-1/2 rounded-lg shadow-lg bottom-6 left-1/2"></div>
@endsection
@push('scripts')
<script>
// 샘플 템플릿
const sampleTemplate = @json($template);
// Import 모드 토글
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' || 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);
}
}
// 샘플 템플릿 불러오기
function loadTemplate() {
const mode = document.querySelector('input[name="import_mode"]:checked').value;
let template = sampleTemplate;
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);
hideError();
}
// JSON 포맷팅
function formatJson() {
const input = document.getElementById('jsonInput');
try {
const json = JSON.parse(input.value);
input.value = JSON.stringify(json, null, 2);
hideError();
} catch (e) {
showError('JSON 파싱 오류: ' + e.message);
}
}
// JSON 초기화
function clearJson() {
document.getElementById('jsonInput').value = '';
hideError();
hideResult();
}
// 파일 로드
function loadFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const json = JSON.parse(e.target.result);
document.getElementById('jsonInput').value = JSON.stringify(json, null, 2);
hideError();
} catch (err) {
showError('파일 파싱 오류: ' + err.message);
}
};
reader.readAsText(file);
}
// JSON 검증
async function validateJson() {
const input = document.getElementById('jsonInput').value;
if (!input.trim()) {
showError('JSON을 입력해주세요.');
return;
}
let data;
try {
data = JSON.parse(input);
} catch (e) {
showError('JSON 파싱 오류: ' + e.message);
return;
}
try {
const response = await fetch('/api/admin/pm/import/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify(data),
});
if (!response.ok) {
const text = await response.text();
try {
const errorJson = JSON.parse(text);
showError('검증 실패: ' + (errorJson.message || '서버 오류'));
return;
} catch {
showError(`서버 오류 (${response.status}): ${text.substring(0, 200)}`);
return;
}
}
const result = await response.json();
if (result.success) {
showResult('success', 'JSON 구조가 유효합니다!');
hideError();
} else {
showResult('error', '검증 실패', result.errors || [result.message]);
}
} catch (e) {
showError('요청 실패: ' + e.message);
}
}
// Import 실행
async function importData() {
const input = document.getElementById('jsonInput').value;
const mode = document.querySelector('input[name="import_mode"]:checked').value;
if (!input.trim()) {
showError('JSON을 입력해주세요.');
return;
}
let data;
try {
data = JSON.parse(input);
} catch (e) {
showError('JSON 파싱 오류: ' + e.message);
return;
}
let url = '/api/admin/pm/import';
if (mode === 'existing') {
const projectId = document.getElementById('targetProject').value;
if (!projectId) {
showError('대상 프로젝트를 선택해주세요.');
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 {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify(data),
});
if (!response.ok) {
const text = await response.text();
try {
const errorJson = JSON.parse(text);
showError('Import 실패: ' + (errorJson.message || '서버 오류'));
return;
} catch {
showError(`서버 오류 (${response.status}): ${text.substring(0, 200)}`);
return;
}
}
const result = await response.json();
if (result.success) {
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]);
}
} catch (e) {
showError('요청 실패: ' + e.message);
}
}
// 에러 표시
function showError(message) {
const errorDiv = document.getElementById('jsonError');
errorDiv.textContent = message;
errorDiv.classList.remove('hidden');
}
// 에러 숨기기
function hideError() {
document.getElementById('jsonError').classList.add('hidden');
}
// 결과 표시
function showResult(type, title, items = []) {
const card = document.getElementById('resultCard');
const content = document.getElementById('resultContent');
const bgColor = type === 'success' ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200';
const textColor = type === 'success' ? 'text-green-800' : 'text-red-800';
const iconPath = type === 'success'
? 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'
: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z';
let itemsHtml = items.length ? '<ul class="mt-2 ml-6 text-sm list-disc">' + items.map(i => `<li>${i}</li>`).join('') + '</ul>' : '';
content.innerHTML = `
<div class="p-4 border rounded-lg ${bgColor}">
<div class="flex items-center ${textColor}">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="${iconPath}"/>
</svg>
<span class="font-medium">${title}</span>
</div>
${itemsHtml}
<div id="viewButtonContainer"></div>
</div>
`;
card.classList.remove('hidden');
}
// 결과 숨기기
function hideResult() {
document.getElementById('resultCard').classList.add('hidden');
}
// 프로젝트 보기 버튼 추가
function appendViewButton(projectId) {
const container = document.getElementById('viewButtonContainer');
if (container) {
container.innerHTML = `
<a href="/project-management/projects/${projectId}"
class="inline-flex items-center px-3 py-1.5 mt-3 text-sm font-medium text-green-700 bg-green-100 rounded-lg hover:bg-green-200">
프로젝트 보기 &rarr;
</a>
`;
}
}
// 토스트 표시
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
const bgColor = type === 'success' ? 'bg-green-600' : type === 'error' ? 'bg-red-600' : 'bg-gray-800';
toast.className = `fixed px-6 py-3 text-white rounded-lg shadow-lg bottom-6 left-1/2 transform -translate-x-1/2 ${bgColor}`;
toast.textContent = message;
toast.classList.remove('hidden');
setTimeout(() => {
toast.classList.add('hidden');
}, 3000);
}
</script>
@endpush