- EquipmentPhotoService: GCS 기반 사진 업로드/삭제/조회 (최대 10장) - EquipmentImportService: 엑셀 파싱 → 설비 일괄 등록 (한글 헤더 자동 매핑) - API: 사진 업로드/목록/삭제, Import 미리보기/실행 엔드포인트 - 뷰: create/edit에 드래그앤드롭 사진 업로드, show에 갤러리 표시 - import.blade.php: 3단계 Import UI (파일선택 → 미리보기 → 결과) - phpoffice/phpspreadsheet 패키지 추가
272 lines
13 KiB
PHP
272 lines
13 KiB
PHP
@extends('layouts.app')
|
|
@section('title', '설비 엑셀 Import')
|
|
@section('content')
|
|
|
|
<div class="max-w-4xl mx-auto">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h1 class="text-2xl font-bold text-gray-800">설비 엑셀 Import</h1>
|
|
<a href="{{ route('equipment.index') }}" class="text-gray-600 hover:text-gray-800">
|
|
← 목록으로
|
|
</a>
|
|
</div>
|
|
|
|
<!-- 단계 표시 -->
|
|
<div class="flex items-center gap-2 mb-6">
|
|
<span id="step1Badge" class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">1. 파일 업로드</span>
|
|
<svg class="w-4 h-4 text-gray-400" 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 id="step2Badge" class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-500">2. 미리보기</span>
|
|
<svg class="w-4 h-4 text-gray-400" 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 id="step3Badge" class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-500">3. 결과</span>
|
|
</div>
|
|
|
|
<!-- Step 1: 파일 업로드 -->
|
|
<div id="step1" class="bg-white rounded-lg shadow-sm p-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">엑셀 파일 선택</h2>
|
|
<p class="text-sm text-gray-500 mb-4">
|
|
지원 형식: .xlsx, .xls (최대 10MB)<br>
|
|
첫 번째 행은 헤더로 인식합니다 (설비코드, 설비명, 설비유형, 규격, 제조사, 모델명, 제조번호, 위치, 생산라인, 구입일, 설치일, 구입가격, 내용연수, 상태, 담당자, 비고)
|
|
</p>
|
|
|
|
<div id="fileDropzone"
|
|
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 transition">
|
|
<svg class="mx-auto h-12 w-12 text-gray-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
</svg>
|
|
<p class="text-gray-500">클릭하거나 엑셀 파일을 드래그하세요</p>
|
|
<input type="file" id="fileInput" accept=".xlsx,.xls" class="hidden">
|
|
</div>
|
|
<div id="selectedFile" class="mt-3" style="display: none;">
|
|
<p class="text-sm text-gray-700"><span id="fileName"></span></p>
|
|
</div>
|
|
|
|
<div class="mt-4">
|
|
<label class="block text-sm font-semibold text-gray-700 mb-2">중복 설비코드 처리</label>
|
|
<select id="duplicateAction" class="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
<option value="skip">건너뛰기 (기존 데이터 유지)</option>
|
|
<option value="overwrite">덮어쓰기 (기존 데이터 갱신)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<button id="previewBtn" onclick="doPreview()" disabled
|
|
class="mt-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-6 py-2 rounded-lg transition">
|
|
미리보기
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Step 2: 미리보기 -->
|
|
<div id="step2" class="bg-white rounded-lg shadow-sm p-6 mt-4" style="display: none;">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h2 class="text-lg font-semibold text-gray-800">미리보기</h2>
|
|
<p class="text-sm text-gray-500" id="previewInfo"></p>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full text-sm" id="previewTable">
|
|
<thead id="previewHead" class="bg-gray-50"></thead>
|
|
<tbody id="previewBody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div class="flex gap-3 mt-4">
|
|
<button onclick="doImport()" class="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg transition">
|
|
Import 실행
|
|
</button>
|
|
<button onclick="resetForm()" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
|
|
다시 선택
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: 결과 -->
|
|
<div id="step3" class="bg-white rounded-lg shadow-sm p-6 mt-4" style="display: none;">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">Import 결과</h2>
|
|
<div class="grid grid-cols-3 gap-4 mb-4">
|
|
<div class="text-center p-4 bg-green-50 rounded-lg">
|
|
<p class="text-2xl font-bold text-green-600" id="resultSuccess">0</p>
|
|
<p class="text-sm text-green-700">성공</p>
|
|
</div>
|
|
<div class="text-center p-4 bg-yellow-50 rounded-lg">
|
|
<p class="text-2xl font-bold text-yellow-600" id="resultSkipped">0</p>
|
|
<p class="text-sm text-yellow-700">건너뜀</p>
|
|
</div>
|
|
<div class="text-center p-4 bg-red-50 rounded-lg">
|
|
<p class="text-2xl font-bold text-red-600" id="resultFailed">0</p>
|
|
<p class="text-sm text-red-700">실패</p>
|
|
</div>
|
|
</div>
|
|
<div id="resultErrors" style="display: none;" class="mb-4">
|
|
<h3 class="text-sm font-semibold text-red-700 mb-2">오류 상세</h3>
|
|
<ul id="errorList" class="text-sm text-red-600 list-disc pl-5"></ul>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<a href="{{ route('equipment.index') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
|
|
설비 목록으로
|
|
</a>
|
|
<button onclick="resetForm()" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
|
|
추가 Import
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 로딩 -->
|
|
<div id="loadingOverlay" style="display: none;" class="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
|
|
<div class="bg-white rounded-lg p-6 text-center">
|
|
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600 mx-auto mb-3"></div>
|
|
<p class="text-gray-700" id="loadingText">처리 중...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
const csrfToken = '{{ csrf_token() }}';
|
|
let selectedFile = null;
|
|
|
|
// 파일 선택
|
|
const dropzone = document.getElementById('fileDropzone');
|
|
const fileInput = document.getElementById('fileInput');
|
|
|
|
dropzone.addEventListener('click', () => fileInput.click());
|
|
dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('border-blue-400', 'bg-blue-50'); });
|
|
dropzone.addEventListener('dragleave', () => { dropzone.classList.remove('border-blue-400', 'bg-blue-50'); });
|
|
dropzone.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
dropzone.classList.remove('border-blue-400', 'bg-blue-50');
|
|
if (e.dataTransfer.files.length) selectFile(e.dataTransfer.files[0]);
|
|
});
|
|
fileInput.addEventListener('change', (e) => { if (e.target.files.length) selectFile(e.target.files[0]); });
|
|
|
|
function selectFile(file) {
|
|
if (!file.name.match(/\.(xlsx|xls)$/i)) {
|
|
showToast('엑셀 파일(.xlsx, .xls)만 지원합니다.', 'error');
|
|
return;
|
|
}
|
|
selectedFile = file;
|
|
document.getElementById('fileName').textContent = file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)';
|
|
document.getElementById('selectedFile').style.display = 'block';
|
|
document.getElementById('previewBtn').disabled = false;
|
|
}
|
|
|
|
function setStep(step) {
|
|
['step1Badge', 'step2Badge', 'step3Badge'].forEach((id, i) => {
|
|
const el = document.getElementById(id);
|
|
if (i + 1 <= step) {
|
|
el.classList.remove('bg-gray-100', 'text-gray-500');
|
|
el.classList.add('bg-blue-100', 'text-blue-800');
|
|
} else {
|
|
el.classList.remove('bg-blue-100', 'text-blue-800');
|
|
el.classList.add('bg-gray-100', 'text-gray-500');
|
|
}
|
|
});
|
|
}
|
|
|
|
function doPreview() {
|
|
if (!selectedFile) return;
|
|
document.getElementById('loadingOverlay').style.display = 'flex';
|
|
document.getElementById('loadingText').textContent = '파일 분석 중...';
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', selectedFile);
|
|
|
|
fetch('/api/admin/equipment/import/preview', {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
|
|
body: formData
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
document.getElementById('loadingOverlay').style.display = 'none';
|
|
if (data.success) {
|
|
showPreview(data.data);
|
|
setStep(2);
|
|
} else {
|
|
showToast(data.message || '미리보기 실패', 'error');
|
|
}
|
|
})
|
|
.catch(() => {
|
|
document.getElementById('loadingOverlay').style.display = 'none';
|
|
showToast('서버 오류가 발생했습니다.', 'error');
|
|
});
|
|
}
|
|
|
|
function showPreview(data) {
|
|
document.getElementById('step2').style.display = 'block';
|
|
document.getElementById('previewInfo').textContent =
|
|
`전체 ${data.total_rows}행 중 ${data.preview.length}행 미리보기 | 매핑된 컬럼: ${data.mapped_columns}개`;
|
|
|
|
const head = document.getElementById('previewHead');
|
|
const body = document.getElementById('previewBody');
|
|
head.innerHTML = '<tr>' + data.headers.map(h => `<th class="px-3 py-2 text-left font-medium text-gray-700">${h}</th>`).join('') + '</tr>';
|
|
body.innerHTML = '';
|
|
|
|
data.preview.forEach(row => {
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'border-t';
|
|
tr.innerHTML = data.headers.map(h => `<td class="px-3 py-2 text-gray-600">${row[h] ?? '-'}</td>`).join('');
|
|
body.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function doImport() {
|
|
if (!selectedFile) return;
|
|
document.getElementById('loadingOverlay').style.display = 'flex';
|
|
document.getElementById('loadingText').textContent = 'Import 실행 중...';
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', selectedFile);
|
|
formData.append('duplicate_action', document.getElementById('duplicateAction').value);
|
|
|
|
fetch('/api/admin/equipment/import', {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
|
|
body: formData
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
document.getElementById('loadingOverlay').style.display = 'none';
|
|
if (data.success) {
|
|
showResult(data.data);
|
|
setStep(3);
|
|
showToast(data.message, 'success');
|
|
} else {
|
|
showToast(data.message || 'Import 실패', 'error');
|
|
}
|
|
})
|
|
.catch(() => {
|
|
document.getElementById('loadingOverlay').style.display = 'none';
|
|
showToast('서버 오류가 발생했습니다.', 'error');
|
|
});
|
|
}
|
|
|
|
function showResult(data) {
|
|
document.getElementById('step2').style.display = 'none';
|
|
document.getElementById('step3').style.display = 'block';
|
|
document.getElementById('resultSuccess').textContent = data.success;
|
|
document.getElementById('resultSkipped').textContent = data.skipped;
|
|
document.getElementById('resultFailed').textContent = data.failed;
|
|
|
|
if (data.errors && data.errors.length > 0) {
|
|
document.getElementById('resultErrors').style.display = 'block';
|
|
const list = document.getElementById('errorList');
|
|
list.innerHTML = '';
|
|
data.errors.forEach(err => {
|
|
const li = document.createElement('li');
|
|
li.textContent = err;
|
|
list.appendChild(li);
|
|
});
|
|
}
|
|
}
|
|
|
|
function resetForm() {
|
|
selectedFile = null;
|
|
fileInput.value = '';
|
|
document.getElementById('selectedFile').style.display = 'none';
|
|
document.getElementById('previewBtn').disabled = true;
|
|
document.getElementById('step2').style.display = 'none';
|
|
document.getElementById('step3').style.display = 'none';
|
|
document.getElementById('resultErrors').style.display = 'none';
|
|
setStep(1);
|
|
}
|
|
</script>
|
|
@endpush
|