- InspectionCycle enum: 6종 점검주기 상수, 열 라벨, check_date 계산 - Equipment 모델: subManager 관계, canInspect() 권한 체크 - Template/Inspection 모델: inspection_cycle fillable 추가 - EquipmentInspectionService: 주기별 점검 조회/토글/권한 체크 - 점검표 UI: 주기 탭, 동적 필터(월/연도), 주기별 그리드 열 - 점검항목 템플릿: 주기별 탭 그룹핑, 모달에 주기 선택 - 설비 등록/수정/상세: 부 담당자 필드 추가 - 권한 없는 장비 셀 비활성(cursor-not-allowed, opacity-50)
367 lines
19 KiB
PHP
367 lines
19 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>
|
|
|
|
<!-- 로딩 상태 -->
|
|
<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="sub_manager_id" id="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" 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>
|
|
<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" 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', 'sub_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); });
|
|
|
|
// 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();
|
|
uploadPhotos(imageFiles);
|
|
}
|
|
});
|
|
|
|
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
|