- 자료실 하위 3개 메뉴: 자료보관함, 매뉴얼, 공지사항 - 자료보관함: 폴더 트리 + 파일 업로드/다운로드/삭제 - 매뉴얼/공지사항: 게시판형 CRUD + 첨부파일 - 안전관리: 안전보건교육, TBM현황, 위험성평가, 재해예방조치 - 품질관리: 시정조치 UI 페이지 - 대시보드: 슈퍼관리자 전용 레거시 사이트 참고 카드 - 작업일보/출면일보 오류 수정 및 기능 개선 - 설비 사진 업로드, 근로계약서 종료일 수정
444 lines
22 KiB
PHP
444 lines
22 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="sub_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">
|
|
<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>
|
|
<p class="text-xs text-gray-400 mt-1">Ctrl+V로 클립보드 이미지 붙여넣기 가능</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;
|
|
let pendingFiles = []; // 설비 등록 전 대기 중인 사진 파일
|
|
const csrfToken = '{{ csrf_token() }}';
|
|
|
|
document.getElementById('equipmentForm').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
|
|
const submitBtn = document.getElementById('submitBtn');
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = '등록 중...';
|
|
|
|
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(async (data) => {
|
|
if (data.success) {
|
|
createdEquipmentId = data.data.id;
|
|
|
|
// 대기 중인 사진이 있으면 자동 업로드
|
|
if (pendingFiles.length > 0) {
|
|
showToast('설비가 등록되었습니다. 사진 업로드 중...', 'success');
|
|
await uploadPendingPhotos();
|
|
} else {
|
|
showToast('설비가 등록되었습니다.', 'success');
|
|
}
|
|
|
|
// 등록 완료 후 버튼 변경
|
|
submitBtn.textContent = '완료 (목록으로)';
|
|
submitBtn.type = 'button';
|
|
submitBtn.disabled = false;
|
|
submitBtn.onclick = function() {
|
|
window.location.href = '{{ route("equipment.index") }}';
|
|
};
|
|
} else {
|
|
showToast(data.message || '등록에 실패했습니다.', 'error');
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = '등록';
|
|
}
|
|
})
|
|
.catch(() => {
|
|
showToast('서버 오류가 발생했습니다.', 'error');
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = '등록';
|
|
});
|
|
});
|
|
|
|
// 사진 드래그앤드롭
|
|
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) handleFiles(e.dataTransfer.files);
|
|
});
|
|
photoInput.addEventListener('change', (e) => { if (e.target.files.length) handleFiles(e.target.files); });
|
|
|
|
// Ctrl+V 클립보드 이미지 붙여넣기
|
|
document.addEventListener('paste', (e) => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items || !items.length) return;
|
|
const imageFiles = [];
|
|
for (let i = 0; i < items.length; i++) {
|
|
if (items[i].kind === 'file' && items[i].type.startsWith('image/')) {
|
|
const file = items[i].getAsFile();
|
|
if (file) imageFiles.push(file);
|
|
}
|
|
}
|
|
if (imageFiles.length > 0) {
|
|
e.preventDefault();
|
|
handleFiles(imageFiles);
|
|
}
|
|
});
|
|
|
|
// 파일 처리: 설비 등록 전이면 대기열에 추가, 등록 후면 바로 업로드
|
|
function handleFiles(files) {
|
|
if (createdEquipmentId) {
|
|
uploadPhotos(files);
|
|
} else {
|
|
addToPending(files);
|
|
}
|
|
}
|
|
|
|
// 대기열에 파일 추가 및 미리보기 렌더링
|
|
function addToPending(files) {
|
|
const totalCount = pendingFiles.length + files.length;
|
|
if (totalCount > 10) {
|
|
showToast(`사진은 최대 10장까지 등록할 수 있습니다. (현재 ${pendingFiles.length}장)`, 'error');
|
|
return;
|
|
}
|
|
for (let f of files) {
|
|
pendingFiles.push(f);
|
|
}
|
|
renderPendingPhotos();
|
|
showToast(`사진 ${files.length}장이 추가되었습니다. 설비 등록 시 함께 업로드됩니다.`, 'info');
|
|
}
|
|
|
|
// 대기 중인 사진 미리보기 렌더링
|
|
function renderPendingPhotos() {
|
|
const gallery = document.getElementById('photoGallery');
|
|
gallery.innerHTML = '';
|
|
pendingFiles.forEach((file, idx) => {
|
|
const div = document.createElement('div');
|
|
div.className = 'relative group';
|
|
const url = URL.createObjectURL(file);
|
|
div.innerHTML = `
|
|
<img src="${url}" alt="${file.name}" class="w-full h-32 object-cover rounded-lg border">
|
|
<button type="button" onclick="removePending(${idx})"
|
|
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">${file.name}</p>
|
|
<span class="absolute bottom-7 left-1 bg-yellow-100 text-yellow-800 text-xs px-1.5 py-0.5 rounded">대기</span>
|
|
`;
|
|
gallery.appendChild(div);
|
|
});
|
|
}
|
|
|
|
// 대기열에서 사진 제거
|
|
function removePending(idx) {
|
|
pendingFiles.splice(idx, 1);
|
|
renderPendingPhotos();
|
|
}
|
|
|
|
// 대기 중인 사진 서버에 업로드
|
|
function uploadPendingPhotos() {
|
|
return new Promise((resolve) => {
|
|
if (pendingFiles.length === 0) { resolve(); return; }
|
|
|
|
const formData = new FormData();
|
|
for (let f of pendingFiles) 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('사진이 업로드되었습니다.', 'success');
|
|
pendingFiles = [];
|
|
renderPhotos(res.data.photos);
|
|
} else {
|
|
showToast(res.message || '사진 업로드 실패', 'error');
|
|
}
|
|
resolve();
|
|
});
|
|
xhr.addEventListener('error', () => {
|
|
document.getElementById('photoUploadProgress').style.display = 'none';
|
|
showToast('사진 업로드 중 오류가 발생했습니다.', 'error');
|
|
resolve();
|
|
});
|
|
xhr.open('POST', `/api/admin/equipment/${createdEquipmentId}/photos`);
|
|
xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken);
|
|
xhr.setRequestHeader('Accept', 'application/json');
|
|
xhr.send(formData);
|
|
});
|
|
}
|
|
|
|
// 설비 등록 후 직접 업로드 (추가 사진)
|
|
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
|