Files
sam-manage/resources/views/equipment/import.blade.php
김보곤 0c9d2fd441 feat: [equipment] 사진 멀티 업로드(GCS) + 엑셀 Import 기능 추가
- EquipmentPhotoService: GCS 기반 사진 업로드/삭제/조회 (최대 10장)
- EquipmentImportService: 엑셀 파싱 → 설비 일괄 등록 (한글 헤더 자동 매핑)
- API: 사진 업로드/목록/삭제, Import 미리보기/실행 엔드포인트
- 뷰: create/edit에 드래그앤드롭 사진 업로드, show에 갤러리 표시
- import.blade.php: 3단계 Import UI (파일선택 → 미리보기 → 결과)
- phpoffice/phpspreadsheet 패키지 추가
2026-02-26 13:28:40 +09:00

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">
&larr; 목록으로
</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