Files
sam-manage/resources/views/equipment/edit.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

339 lines
18 KiB
PHP

@extends('layouts.app')
@section('title', '설비 수정')
@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">설비 수정</h1>
<a href="{{ route('equipment.index') }}" class="text-gray-600 hover:text-gray-800">
&larr; 목록으로
</a>
</div>
<!-- 로딩 상태 -->
<div id="loadingState" class="bg-white rounded-lg shadow-sm p-6">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
<!-- -->
<div id="formContainer" style="display: none;">
<form id="equipmentForm" class="space-y-6">
@csrf
<!-- 기본정보 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">기본정보</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">
설비코드 <span class="text-red-500">*</span>
</label>
<input type="text" name="equipment_code" id="equipment_code" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">
설비명 <span class="text-red-500">*</span>
</label>
<input type="text" name="name" id="name" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">설비유형</label>
<select name="equipment_type" id="equipment_type"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택</option>
<option value="포밍기">포밍기</option>
<option value="미싱기">미싱기</option>
<option value="샤링기">샤링기</option>
<option value="V컷팅기">V컷팅기</option>
<option value="절곡기">절곡기</option>
<option value="프레스">프레스</option>
<option value="드릴">드릴</option>
<option value="기타">기타</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">규격</label>
<input type="text" name="specification" id="specification"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
<!-- 제조사 정보 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">제조사 정보</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">제조사</label>
<input type="text" name="manufacturer" id="manufacturer"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">모델명</label>
<input type="text" name="model_name" id="model_name"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">제조번호</label>
<input type="text" name="serial_no" id="serial_no"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
<!-- 설치 정보 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">설치 정보</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">위치</label>
<input type="text" name="location" id="location"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">생산라인</label>
<select name="production_line" id="production_line"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택</option>
<option value="스라트">스라트</option>
<option value="스크린">스크린</option>
<option value="절곡">절곡</option>
<option value="기타">기타</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">구입일</label>
<input type="date" name="purchase_date" id="purchase_date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">설치일</label>
<input type="date" name="install_date" id="install_date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">구입가격 ()</label>
<input type="number" name="purchase_price" id="purchase_price" min="0" step="1"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">내용연수 ()</label>
<input type="number" name="useful_life" id="useful_life" min="0"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
<!-- 담당자/비고 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">담당자 / 비고</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">담당자</label>
<select name="manager_id" id="manager_id"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택</option>
@foreach($users as $user)
<option value="{{ $user->id }}">{{ $user->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">상태</label>
<select name="status" id="status"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="active">가동</option>
<option value="idle">유휴</option>
<option value="disposed">폐기</option>
</select>
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-semibold text-gray-700 mb-2">비고</label>
<textarea name="memo" id="memo" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
</div>
<!-- 사진 관리 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">설비 사진</h2>
<p class="text-sm text-gray-500 mb-3">최대 10, 10MB 이하 이미지 파일</p>
<div id="photoDropzone"
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer hover:border-blue-400 transition">
<svg class="mx-auto h-10 w-10 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-gray-500">클릭하거나 파일을 드래그하세요</p>
<input type="file" id="photoInput" multiple accept="image/*" class="hidden">
</div>
<div id="photoUploadProgress" class="mt-3" style="display: none;">
<div class="w-full bg-gray-200 rounded-full h-2">
<div id="photoProgressBar" class="bg-blue-600 h-2 rounded-full transition-all" style="width: 0%"></div>
</div>
<p class="text-sm text-gray-500 mt-1" id="photoProgressText">업로드 ...</p>
</div>
<div id="photoGallery" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mt-4"></div>
</div>
<!-- 버튼 -->
<div class="flex gap-3">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
수정
</button>
<a href="{{ route('equipment.index') }}"
class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
취소
</a>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
const equipmentId = {{ $id }};
const csrfToken = '{{ csrf_token() }}';
const fields = ['equipment_code', 'name', 'equipment_type', 'specification', 'manufacturer',
'model_name', 'serial_no', 'location', 'production_line', 'purchase_date', 'install_date',
'purchase_price', 'useful_life', 'status', 'manager_id', 'memo'];
// 설비 데이터 로드
fetch(`/api/admin/equipment/${equipmentId}`, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }
})
.then(r => r.json())
.then(data => {
if (data.success) {
const eq = data.data;
fields.forEach(f => {
const el = document.getElementById(f);
if (el && eq[f] != null) el.value = eq[f];
});
document.getElementById('loadingState').style.display = 'none';
document.getElementById('formContainer').style.display = 'block';
loadPhotos();
} else {
showToast('설비 정보를 불러올 수 없습니다.', 'error');
window.location.href = '{{ route("equipment.index") }}';
}
});
document.getElementById('equipmentForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
fetch(`/api/admin/equipment/${equipmentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json',
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
window.location.href = '{{ route("equipment.index") }}';
} else {
showToast(data.message || '수정에 실패했습니다.', 'error');
}
})
.catch(() => showToast('서버 오류가 발생했습니다.', 'error'));
});
// 사진 로드
function loadPhotos() {
fetch(`/api/admin/equipment/${equipmentId}/photos`, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }
})
.then(r => r.json())
.then(data => { if (data.success) renderPhotos(data.data); });
}
// 사진 드래그앤드롭
const dropzone = document.getElementById('photoDropzone');
const photoInput = document.getElementById('photoInput');
dropzone.addEventListener('click', () => photoInput.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) uploadPhotos(e.dataTransfer.files);
});
photoInput.addEventListener('change', (e) => { if (e.target.files.length) uploadPhotos(e.target.files); });
function uploadPhotos(files) {
const formData = new FormData();
for (let f of files) formData.append('photos[]', f);
document.getElementById('photoUploadProgress').style.display = 'block';
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
document.getElementById('photoProgressBar').style.width = pct + '%';
document.getElementById('photoProgressText').textContent = pct + '% 업로드 중...';
}
});
xhr.addEventListener('load', () => {
document.getElementById('photoUploadProgress').style.display = 'none';
const res = JSON.parse(xhr.responseText);
if (res.success) {
showToast(res.message, 'success');
renderPhotos(res.data.photos);
} else {
showToast(res.message || '업로드 실패', 'error');
}
photoInput.value = '';
});
xhr.open('POST', `/api/admin/equipment/${equipmentId}/photos`);
xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken);
xhr.setRequestHeader('Accept', 'application/json');
xhr.send(formData);
}
function renderPhotos(photos) {
const gallery = document.getElementById('photoGallery');
gallery.innerHTML = '';
photos.forEach(p => {
const div = document.createElement('div');
div.className = 'relative group';
div.innerHTML = `
<img src="${p.url}" alt="${p.original_name}" class="w-full h-32 object-cover rounded-lg border">
<button type="button" onclick="deletePhoto(${p.id}, this)"
class="absolute top-1 right-1 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition">X</button>
<p class="text-xs text-gray-500 mt-1 truncate">${p.original_name}</p>
`;
gallery.appendChild(div);
});
}
function deletePhoto(fileId, btn) {
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
fetch(`/api/admin/equipment/${equipmentId}/photos/${fileId}`, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
if (data.success) {
btn.closest('.relative').remove();
showToast('사진이 삭제되었습니다.', 'success');
}
});
}
</script>
@endpush