- Models: AdminPmProject, AdminPmTask, AdminPmIssue - Services: ProjectService, TaskService, IssueService, ImportService - API Controllers: ProjectController, TaskController, IssueController, ImportController - FormRequests: Store/Update/BulkAction 요청 검증 - Views: 대시보드, 프로젝트 CRUD, JSON Import 화면 - Routes: API 42개 + Web 6개 엔드포인트 주요 기능: - 프로젝트/작업/이슈 계층 구조 관리 - 상태 변경, 우선순위, 마감일 추적 - 작업 순서 드래그앤드롭 (reorder API) - JSON Import로 일괄 등록 - Soft Delete 및 복원
444 lines
18 KiB
PHP
444 lines
18 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 space-x-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>
|
|
</div>
|
|
|
|
{{-- 기존 프로젝트 선택 (existing 모드) --}}
|
|
<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">
|
|
<option value="">프로젝트 선택...</option>
|
|
@foreach($projects as $project)
|
|
<option value="{{ $project->id }}">{{ $project->name }}</option>
|
|
@endforeach
|
|
</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": "프로젝트명 (필수)",
|
|
"description": "설명",
|
|
"status": "active|completed|on_hold",
|
|
"start_date": "2025-01-01",
|
|
"end_date": "2025-03-31"
|
|
},
|
|
"tasks": [
|
|
{
|
|
"title": "작업 제목 (필수)",
|
|
"description": "작업 설명",
|
|
"status": "todo|in_progress|done",
|
|
"priority": "low|medium|high",
|
|
"due_date": "2025-01-15",
|
|
"issues": [
|
|
{
|
|
"title": "이슈 제목 (필수)",
|
|
"description": "이슈 설명",
|
|
"type": "bug|feature|improvement",
|
|
"status": "open|in_progress|resolved|closed"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}</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",
|
|
"issues": [...]
|
|
}
|
|
]
|
|
}</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>
|
|
</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');
|
|
|
|
if (mode === 'existing') {
|
|
existingSelect.classList.remove('hidden');
|
|
} else {
|
|
existingSelect.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// 샘플 템플릿 불러오기
|
|
function loadTemplate() {
|
|
const mode = document.querySelector('input[name="import_mode"]:checked').value;
|
|
let template = sampleTemplate;
|
|
|
|
if (mode === 'existing') {
|
|
// 기존 프로젝트 모드에서는 tasks만 표시
|
|
template = { tasks: sampleTemplate.tasks };
|
|
}
|
|
|
|
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',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
|
},
|
|
body: JSON.stringify(data),
|
|
});
|
|
|
|
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`;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
|
},
|
|
body: JSON.stringify(data),
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showResult('success', result.message, [
|
|
`프로젝트: ${result.data.project_name || result.data.project_id}`,
|
|
`작업: ${result.data.tasks_count}개`,
|
|
`이슈: ${result.data.issues_count}개`,
|
|
]);
|
|
showToast('Import 완료!', 'success');
|
|
|
|
// 새 프로젝트면 상세 페이지로 이동 버튼 표시
|
|
if (mode === 'new' && 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">
|
|
프로젝트 보기 →
|
|
</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 |