- EquipmentPhotoService: GCS 기반 사진 업로드/삭제/조회 (최대 10장) - EquipmentImportService: 엑셀 파싱 → 설비 일괄 등록 (한글 헤더 자동 매핑) - API: 사진 업로드/목록/삭제, Import 미리보기/실행 엔드포인트 - 뷰: create/edit에 드래그앤드롭 사진 업로드, show에 갤러리 표시 - import.blade.php: 3단계 Import UI (파일선택 → 미리보기 → 결과) - phpoffice/phpspreadsheet 패키지 추가
305 lines
16 KiB
PHP
305 lines
16 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">
|
|
← 목록으로
|
|
</a>
|
|
</div>
|
|
|
|
<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" required placeholder="KD-M-001"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
<p class="mt-1 text-sm text-gray-500">예: KD-M-001, KD-S-002</p>
|
|
</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" required placeholder="포밍기#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>
|
|
<select name="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"
|
|
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"
|
|
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"
|
|
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"
|
|
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" placeholder="1공장-1F"
|
|
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"
|
|
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"
|
|
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"
|
|
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" 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" 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"
|
|
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"
|
|
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" selected>가동</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" 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" id="photoSection" style="display: none;">
|
|
<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" id="submitBtn" 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>
|
|
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
let createdEquipmentId = null;
|
|
const csrfToken = '{{ csrf_token() }}';
|
|
|
|
document.getElementById('equipmentForm').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(this);
|
|
const data = Object.fromEntries(formData.entries());
|
|
|
|
fetch('/api/admin/equipment', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
'Accept': 'application/json',
|
|
},
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
createdEquipmentId = data.data.id;
|
|
showToast('설비가 등록되었습니다. 사진을 추가할 수 있습니다.', 'success');
|
|
document.getElementById('submitBtn').textContent = '완료 (목록으로)';
|
|
document.getElementById('submitBtn').type = 'button';
|
|
document.getElementById('submitBtn').onclick = function() {
|
|
window.location.href = '{{ route("equipment.index") }}';
|
|
};
|
|
document.getElementById('photoSection').style.display = 'block';
|
|
} else {
|
|
showToast(data.message || '등록에 실패했습니다.', 'error');
|
|
}
|
|
})
|
|
.catch(() => showToast('서버 오류가 발생했습니다.', 'error'));
|
|
});
|
|
|
|
// 사진 드래그앤드롭
|
|
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) {
|
|
if (!createdEquipmentId) return;
|
|
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/${createdEquipmentId}/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/${createdEquipmentId}/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
|