feat: 사진 업로드 기능 개선 및 카테고리 업데이트
- 사진 2장까지 업로드 지원 - 카메라 촬영 + 갤러리 선택 분리 - 이미지 압축 및 최적화 (ImageUtils) - iPhone .mpo 파일 JPEG 변환 지원 - 카테고리 변경: 치수불량 → 설계미스, 검사미스 추가 - KST 시간대 설정 - URL 해시 처리로 목록관리 페이지 이동 개선 - 로그인 OAuth2 form-data 형식 수정 - 업로드 속도 개선 및 프로그레스바 추가
This commit is contained in:
@@ -91,9 +91,50 @@
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 로딩 오버레이 -->
|
||||
<div id="loadingOverlay" class="loading-overlay">
|
||||
<div class="loading-spinner">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-spinner fa-spin text-5xl text-blue-500"></i>
|
||||
</div>
|
||||
<p class="text-gray-700 font-medium text-lg">처리 중입니다...</p>
|
||||
<p class="text-gray-500 text-sm mt-2">잠시만 기다려주세요</p>
|
||||
<div class="mt-4 w-full max-w-xs">
|
||||
<div class="bg-gray-200 rounded-full h-2 overflow-hidden">
|
||||
<div class="bg-blue-500 h-full rounded-full transition-all duration-300 upload-progress" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 화면 -->
|
||||
<div id="loginScreen" class="min-h-screen flex items-center justify-center p-4 bg-gray-50">
|
||||
<div class="bg-white rounded-xl shadow-lg p-8 w-full max-w-sm">
|
||||
@@ -184,28 +225,63 @@
|
||||
</h2>
|
||||
|
||||
<form id="reportForm" class="space-y-4">
|
||||
<!-- 사진 업로드 (선택사항) -->
|
||||
<!-- 사진 업로드 (선택사항, 최대 2장) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
사진 <span class="text-gray-500 text-xs">(선택사항)</span>
|
||||
사진 <span class="text-gray-500 text-xs">(선택사항, 최대 2장)</span>
|
||||
</label>
|
||||
<div
|
||||
id="photoUpload"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 transition-colors"
|
||||
onclick="document.getElementById('photoInput').click()"
|
||||
>
|
||||
<div id="photoPreview" class="hidden mb-4">
|
||||
<img id="previewImg" class="max-h-48 mx-auto rounded-lg">
|
||||
<button type="button" onclick="removePhoto(event)" class="mt-2 px-2 py-1 bg-red-500 text-white rounded text-xs hover:bg-red-600">
|
||||
<i class="fas fa-times mr-1"></i>사진 제거
|
||||
|
||||
<!-- 사진 미리보기 영역 -->
|
||||
<div id="photoPreviewContainer" class="grid grid-cols-2 gap-3 mb-3" style="display: none;">
|
||||
<!-- 첫 번째 사진 -->
|
||||
<div id="photo1Container" class="relative hidden">
|
||||
<img id="previewImg1" class="w-full h-32 object-cover rounded-lg">
|
||||
<button type="button" onclick="removePhoto(0)" class="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full text-xs hover:bg-red-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="photoPlaceholder">
|
||||
<i class="fas fa-camera text-4xl text-gray-400 mb-2"></i>
|
||||
<p class="text-gray-600">사진 촬영 또는 선택</p>
|
||||
<!-- 두 번째 사진 -->
|
||||
<div id="photo2Container" class="relative hidden">
|
||||
<img id="previewImg2" class="w-full h-32 object-cover rounded-lg">
|
||||
<button type="button" onclick="removePhoto(1)" class="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full text-xs hover:bg-red-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/*" capture="camera" class="hidden">
|
||||
|
||||
<!-- 업로드 버튼들 -->
|
||||
<div class="flex gap-3">
|
||||
<!-- 카메라 촬영 버튼 -->
|
||||
<div
|
||||
id="cameraUpload"
|
||||
class="flex-1 border-2 border-dashed border-blue-300 rounded-lg p-5 text-center cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition-all"
|
||||
onclick="openCamera()"
|
||||
>
|
||||
<i class="fas fa-camera text-4xl text-blue-500 mb-2"></i>
|
||||
<p class="text-gray-700 font-medium text-sm">📷 카메라</p>
|
||||
<p class="text-gray-500 text-xs mt-1">즉시 촬영</p>
|
||||
</div>
|
||||
|
||||
<!-- 갤러리 선택 버튼 -->
|
||||
<div
|
||||
id="galleryUpload"
|
||||
class="flex-1 border-2 border-dashed border-green-300 rounded-lg p-5 text-center cursor-pointer hover:border-green-500 hover:bg-green-50 transition-all"
|
||||
onclick="openGallery()"
|
||||
>
|
||||
<i class="fas fa-images text-4xl text-green-500 mb-2"></i>
|
||||
<p class="text-gray-700 font-medium text-sm">🖼️ 갤러리</p>
|
||||
<p class="text-gray-500 text-xs mt-1">사진 선택</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 현재 상태 표시 -->
|
||||
<div class="text-center mt-2">
|
||||
<p class="text-sm text-gray-500" id="photoUploadText">사진 추가 (0/2)</p>
|
||||
</div>
|
||||
|
||||
<!-- 숨겨진 입력 필드들 -->
|
||||
<input type="file" id="cameraInput" accept="image/*" capture="camera" class="hidden" multiple>
|
||||
<input type="file" id="galleryInput" accept="image/*" class="hidden" multiple>
|
||||
</div>
|
||||
|
||||
<!-- 카테고리 -->
|
||||
@@ -214,8 +290,9 @@
|
||||
<select id="category" class="input-field w-full px-4 py-2 rounded-lg" required>
|
||||
<option value="">선택하세요</option>
|
||||
<option value="material_missing">자재누락</option>
|
||||
<option value="dimension_defect">치수불량</option>
|
||||
<option value="design_error">설계미스</option>
|
||||
<option value="incoming_defect">입고자재 불량</option>
|
||||
<option value="inspection_miss">검사미스</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -264,10 +341,12 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/api.js?v=20250917"></script>
|
||||
<script src="/static/js/image-utils.js?v=20250917"></script>
|
||||
<script src="/static/js/date-utils.js?v=20250917"></script>
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let currentPhoto = null;
|
||||
let currentPhotos = [];
|
||||
let issues = [];
|
||||
|
||||
// 페이지 로드 시 인증 체크
|
||||
@@ -283,6 +362,9 @@
|
||||
updateNavigation();
|
||||
|
||||
loadIssues();
|
||||
|
||||
// URL 해시 처리
|
||||
handleUrlHash();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -303,6 +385,9 @@
|
||||
updateNavigation();
|
||||
|
||||
loadIssues();
|
||||
|
||||
// URL 해시 처리
|
||||
handleUrlHash();
|
||||
} catch (error) {
|
||||
alert(error.message || '로그인에 실패했습니다.');
|
||||
}
|
||||
@@ -335,6 +420,17 @@
|
||||
}
|
||||
|
||||
// 섹션 전환
|
||||
// URL 해시 처리
|
||||
function handleUrlHash() {
|
||||
const hash = window.location.hash.substring(1); // # 제거
|
||||
if (hash === 'list' || hash === 'summary') {
|
||||
showSection(hash);
|
||||
}
|
||||
}
|
||||
|
||||
// 해시 변경 감지
|
||||
window.addEventListener('hashchange', handleUrlHash);
|
||||
|
||||
function showSection(section) {
|
||||
// 모든 섹션 숨기기
|
||||
document.querySelectorAll('section').forEach(s => s.classList.add('hidden'));
|
||||
@@ -343,38 +439,191 @@
|
||||
|
||||
// 네비게이션 활성화 상태 변경
|
||||
document.querySelectorAll('.nav-link').forEach(link => link.classList.remove('active'));
|
||||
|
||||
// event가 있는 경우에만 활성화 처리
|
||||
if (typeof event !== 'undefined' && event.target && event.target.closest) {
|
||||
event.target.closest('.nav-link').classList.add('active');
|
||||
} else {
|
||||
// URL 해시로 접근한 경우 해당 버튼 찾아서 활성화
|
||||
const targetButton = document.querySelector(`[onclick="showSection('${section}')"]`);
|
||||
if (targetButton) {
|
||||
targetButton.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
// 섹션별 초기화
|
||||
if (section === 'list') {
|
||||
// 데이터가 없으면 먼저 로드
|
||||
if (issues.length === 0) {
|
||||
loadIssues().then(() => displayIssueList());
|
||||
} else {
|
||||
displayIssueList();
|
||||
}
|
||||
} else if (section === 'summary') {
|
||||
// 데이터가 없으면 먼저 로드
|
||||
if (issues.length === 0) {
|
||||
loadIssues().then(() => generateReport());
|
||||
} else {
|
||||
generateReport();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 사진 업로드
|
||||
document.getElementById('photoInput').addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
currentPhoto = e.target.result;
|
||||
document.getElementById('previewImg').src = currentPhoto;
|
||||
document.getElementById('photoPreview').classList.remove('hidden');
|
||||
document.getElementById('photoPlaceholder').classList.add('hidden');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
// 카메라 열기
|
||||
function openCamera() {
|
||||
if (currentPhotos.length >= 2) {
|
||||
alert('최대 2장까지 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
document.getElementById('cameraInput').click();
|
||||
}
|
||||
|
||||
// 갤러리 열기
|
||||
function openGallery() {
|
||||
if (currentPhotos.length >= 2) {
|
||||
alert('최대 2장까지 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
document.getElementById('galleryInput').click();
|
||||
}
|
||||
|
||||
// 사진 업로드 처리 함수
|
||||
async function handlePhotoUpload(files) {
|
||||
const filesArray = Array.from(files);
|
||||
|
||||
// 현재 사진 개수 확인
|
||||
if (currentPhotos.length >= 2) {
|
||||
alert('최대 2장까지 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 추가 가능한 개수만큼만 처리
|
||||
const availableSlots = 2 - currentPhotos.length;
|
||||
const filesToProcess = filesArray.slice(0, availableSlots);
|
||||
|
||||
// 로딩 표시
|
||||
showUploadProgress(true);
|
||||
|
||||
try {
|
||||
for (const file of filesToProcess) {
|
||||
if (currentPhotos.length >= 2) break;
|
||||
|
||||
// 원본 파일 크기 확인
|
||||
const originalSize = file.size;
|
||||
console.log(`원본 이미지 크기: ${ImageUtils.formatFileSize(originalSize)}`);
|
||||
|
||||
// 이미지 압축
|
||||
const compressedImage = await ImageUtils.compressImage(file, {
|
||||
maxWidth: 1280,
|
||||
maxHeight: 1280,
|
||||
quality: 0.75
|
||||
});
|
||||
|
||||
// 압축된 크기 확인
|
||||
const compressedSize = ImageUtils.getBase64Size(compressedImage);
|
||||
console.log(`압축된 이미지 크기: ${ImageUtils.formatFileSize(compressedSize)}`);
|
||||
console.log(`압축률: ${Math.round((1 - compressedSize/originalSize) * 100)}%`);
|
||||
|
||||
currentPhotos.push(compressedImage);
|
||||
updatePhotoPreview();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('이미지 압축 실패:', error);
|
||||
alert('이미지 처리 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
showUploadProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 업로드 진행 상태 표시
|
||||
function showUploadProgress(show) {
|
||||
const cameraBtn = document.getElementById('cameraUpload');
|
||||
const galleryBtn = document.getElementById('galleryUpload');
|
||||
const uploadText = document.getElementById('photoUploadText');
|
||||
|
||||
if (show) {
|
||||
cameraBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
galleryBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
uploadText.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>이미지 처리 중...';
|
||||
uploadText.classList.add('text-blue-600');
|
||||
} else {
|
||||
if (currentPhotos.length < 2) {
|
||||
cameraBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
galleryBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
uploadText.textContent = `사진 추가 (${currentPhotos.length}/2)`;
|
||||
uploadText.classList.remove('text-blue-600');
|
||||
}
|
||||
}
|
||||
|
||||
// 카메라 입력 이벤트
|
||||
document.getElementById('cameraInput').addEventListener('change', (e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handlePhotoUpload(e.target.files);
|
||||
}
|
||||
e.target.value = ''; // 입력 초기화
|
||||
});
|
||||
|
||||
// 갤러리 입력 이벤트
|
||||
document.getElementById('galleryInput').addEventListener('change', (e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handlePhotoUpload(e.target.files);
|
||||
}
|
||||
e.target.value = ''; // 입력 초기화
|
||||
});
|
||||
|
||||
// 사진 제거
|
||||
function removePhoto(event) {
|
||||
event.stopPropagation();
|
||||
currentPhoto = null;
|
||||
document.getElementById('photoInput').value = '';
|
||||
document.getElementById('photoPreview').classList.add('hidden');
|
||||
document.getElementById('photoPlaceholder').classList.remove('hidden');
|
||||
function removePhoto(index) {
|
||||
currentPhotos.splice(index, 1);
|
||||
updatePhotoPreview();
|
||||
}
|
||||
|
||||
// 사진 미리보기 업데이트
|
||||
function updatePhotoPreview() {
|
||||
const container = document.getElementById('photoPreviewContainer');
|
||||
const photo1Container = document.getElementById('photo1Container');
|
||||
const photo2Container = document.getElementById('photo2Container');
|
||||
const uploadText = document.getElementById('photoUploadText');
|
||||
const cameraUpload = document.getElementById('cameraUpload');
|
||||
const galleryUpload = document.getElementById('galleryUpload');
|
||||
|
||||
// 텍스트 업데이트
|
||||
uploadText.textContent = `사진 추가 (${currentPhotos.length}/2)`;
|
||||
|
||||
// 첫 번째 사진
|
||||
if (currentPhotos[0]) {
|
||||
document.getElementById('previewImg1').src = currentPhotos[0];
|
||||
photo1Container.classList.remove('hidden');
|
||||
container.style.display = 'grid';
|
||||
} else {
|
||||
photo1Container.classList.add('hidden');
|
||||
}
|
||||
|
||||
// 두 번째 사진
|
||||
if (currentPhotos[1]) {
|
||||
document.getElementById('previewImg2').src = currentPhotos[1];
|
||||
photo2Container.classList.remove('hidden');
|
||||
container.style.display = 'grid';
|
||||
} else {
|
||||
photo2Container.classList.add('hidden');
|
||||
}
|
||||
|
||||
// 미리보기 컨테이너 표시/숨김
|
||||
if (currentPhotos.length === 0) {
|
||||
container.style.display = 'none';
|
||||
}
|
||||
|
||||
// 2장이 모두 업로드되면 업로드 버튼 스타일 변경
|
||||
if (currentPhotos.length >= 2) {
|
||||
cameraUpload.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
galleryUpload.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
uploadText.textContent = '최대 2장 업로드 완료';
|
||||
uploadText.classList.add('text-green-600', 'font-medium');
|
||||
} else {
|
||||
cameraUpload.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
galleryUpload.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
uploadText.classList.remove('text-green-600', 'font-medium');
|
||||
}
|
||||
}
|
||||
|
||||
// 목록에서 사진 변경
|
||||
@@ -412,23 +661,83 @@
|
||||
document.getElementById('reportForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
const originalBtnContent = submitBtn.innerHTML;
|
||||
const description = document.getElementById('description').value.trim();
|
||||
|
||||
if (!description) {
|
||||
alert('설명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 오버레이 표시 및 상태 업데이트
|
||||
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||
const loadingText = loadingOverlay.querySelector('p.text-gray-700');
|
||||
const loadingSubtext = loadingOverlay.querySelector('p.text-gray-500');
|
||||
|
||||
loadingOverlay.classList.add('active');
|
||||
|
||||
// 단계별 메시지 표시
|
||||
const updateLoadingMessage = (message, subMessage = '') => {
|
||||
if (loadingText) loadingText.textContent = message;
|
||||
if (loadingSubtext) loadingSubtext.textContent = subMessage;
|
||||
};
|
||||
|
||||
// 로딩 상태 표시
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = `
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
등록 중...
|
||||
`;
|
||||
submitBtn.classList.add('opacity-75', 'cursor-not-allowed');
|
||||
|
||||
// 전체 폼 비활성화
|
||||
const formElements = e.target.querySelectorAll('input, textarea, select, button');
|
||||
formElements.forEach(el => el.disabled = true);
|
||||
|
||||
try {
|
||||
const issueData = {
|
||||
photo: currentPhoto,
|
||||
category: document.getElementById('category').value,
|
||||
description: document.getElementById('description').value
|
||||
// 프로그레스바 업데이트 함수
|
||||
const updateProgress = (percent) => {
|
||||
const progressBar = loadingOverlay.querySelector('.upload-progress');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${percent}%`;
|
||||
}
|
||||
};
|
||||
|
||||
// 이미지가 있는 경우 압축 상태 표시
|
||||
if (currentPhotos.length > 0) {
|
||||
updateLoadingMessage('이미지 최적화 중...', `${currentPhotos.length}장 처리 중`);
|
||||
updateProgress(30);
|
||||
await new Promise(resolve => setTimeout(resolve, 100)); // UI 업데이트를 위한 짧은 대기
|
||||
}
|
||||
|
||||
updateLoadingMessage('서버로 전송 중...', '네트워크 상태에 따라 시간이 걸릴 수 있습니다');
|
||||
updateProgress(60);
|
||||
|
||||
const issueData = {
|
||||
photos: currentPhotos, // 배열로 전달
|
||||
category: document.getElementById('category').value,
|
||||
description: description
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
await IssuesAPI.create(issueData);
|
||||
const uploadTime = Date.now() - startTime;
|
||||
|
||||
// 성공 메시지
|
||||
alert('부적합 사항이 등록되었습니다.');
|
||||
updateProgress(90);
|
||||
updateLoadingMessage('완료 처리 중...', '거의 다 되었습니다');
|
||||
|
||||
// 폼 초기화
|
||||
document.getElementById('reportForm').reset();
|
||||
currentPhoto = null;
|
||||
document.getElementById('photoPreview').classList.add('hidden');
|
||||
document.getElementById('photoPlaceholder').classList.remove('hidden');
|
||||
console.log(`업로드 완료: ${uploadTime}ms`);
|
||||
|
||||
updateProgress(100);
|
||||
|
||||
// 성공 메시지 (토스트)
|
||||
showToastMessage('부적합 사항이 등록되었습니다!', 'success');
|
||||
|
||||
// 폼 초기화
|
||||
document.getElementById('reportForm').reset();
|
||||
currentPhotos = [];
|
||||
updatePhotoPreview();
|
||||
|
||||
// 목록 다시 로드
|
||||
await loadIssues();
|
||||
@@ -437,6 +746,28 @@
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message || '등록에 실패했습니다.');
|
||||
} finally {
|
||||
// 로딩 오버레이 숨기기
|
||||
setTimeout(() => {
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
// 프로그레스바 초기화
|
||||
const progressBar = document.querySelector('.upload-progress');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = '0%';
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// 버튼 및 폼 원상 복구
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalBtnContent;
|
||||
submitBtn.classList.remove('opacity-75', 'cursor-not-allowed');
|
||||
|
||||
// 폼 요소 다시 활성화
|
||||
formElements.forEach(el => {
|
||||
if (el !== submitBtn) {
|
||||
el.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -468,8 +799,9 @@
|
||||
issues.forEach(issue => {
|
||||
const categoryNames = {
|
||||
material_missing: '자재누락',
|
||||
dimension_defect: '치수불량',
|
||||
incoming_defect: '입고자재 불량'
|
||||
design_error: '설계미스',
|
||||
incoming_defect: '입고자재 불량',
|
||||
inspection_miss: '검사미스'
|
||||
};
|
||||
|
||||
const div = document.createElement('div');
|
||||
@@ -479,22 +811,34 @@
|
||||
<div class="space-y-4">
|
||||
<!-- 사진과 기본 정보 -->
|
||||
<div class="flex gap-4">
|
||||
<div class="relative">
|
||||
${issue.photo_path ?
|
||||
`<img id="photo-${issue.id}" src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg shadow-sm">` :
|
||||
`<div id="photo-${issue.id}" class="w-32 h-32 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-image text-gray-400 text-3xl"></i>
|
||||
<!-- 사진들 표시 -->
|
||||
<div class="flex gap-2">
|
||||
${issue.photo_path || issue.photo_path2 ?
|
||||
`${issue.photo_path ?
|
||||
`<div class="relative">
|
||||
<img id="photo-${issue.id}" src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg shadow-sm cursor-pointer" onclick="showImageModal('${issue.photo_path}')">
|
||||
</div>` : ''
|
||||
}
|
||||
${issue.photo_path2 ?
|
||||
`<div class="relative">
|
||||
<img id="photo2-${issue.id}" src="${issue.photo_path2}" class="w-32 h-32 object-cover rounded-lg shadow-sm cursor-pointer" onclick="showImageModal('${issue.photo_path2}')">
|
||||
</div>` : ''
|
||||
}` :
|
||||
`<div class="relative">
|
||||
<div id="photo-${issue.id}" class="w-32 h-32 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-image text-gray-400 text-3xl"></i>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('photoEdit-${issue.id}').click()"
|
||||
class="absolute bottom-1 right-1 p-1 bg-blue-500 text-white rounded-full hover:bg-blue-600 text-xs"
|
||||
title="사진 추가"
|
||||
>
|
||||
<i class="fas fa-camera"></i>
|
||||
</button>
|
||||
</div>`
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('photoEdit-${issue.id}').click()"
|
||||
class="absolute bottom-1 right-1 p-1 bg-blue-500 text-white rounded-full hover:bg-blue-600 text-xs"
|
||||
title="사진 변경"
|
||||
>
|
||||
<i class="fas fa-camera"></i>
|
||||
</button>
|
||||
<input type="file" id="photoEdit-${issue.id}" accept="image/*" class="hidden" onchange="handlePhotoChange(${issue.id}, this)">
|
||||
<input type="file" id="photoEdit-${issue.id}" accept="image/*" class="hidden" onchange="handlePhotoChange(${issue.id}, this)" multiple>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-3">
|
||||
@@ -507,8 +851,9 @@
|
||||
onchange="markAsModified(${issue.id})"
|
||||
>
|
||||
<option value="material_missing" ${issue.category === 'material_missing' ? 'selected' : ''}>자재누락</option>
|
||||
<option value="dimension_defect" ${issue.category === 'dimension_defect' ? 'selected' : ''}>치수불량</option>
|
||||
<option value="design_error" ${issue.category === 'design_error' ? 'selected' : ''}>설계미스</option>
|
||||
<option value="incoming_defect" ${issue.category === 'incoming_defect' ? 'selected' : ''}>입고자재 불량</option>
|
||||
<option value="inspection_miss" ${issue.category === 'inspection_miss' ? 'selected' : ''}>검사미스</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -592,14 +937,14 @@
|
||||
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span><i class="fas fa-user mr-1"></i>${issue.reporter.full_name || issue.reporter.username}</span>
|
||||
<span><i class="fas fa-calendar mr-1"></i>${new Date(issue.report_date).toLocaleDateString()}</span>
|
||||
<span><i class="fas fa-calendar mr-1"></i>${DateUtils.formatKST(issue.report_date)}</span>
|
||||
<span class="px-2 py-1 rounded-full text-xs ${
|
||||
issue.status === 'complete' ? 'bg-green-100 text-green-700' :
|
||||
issue.status === 'progress' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}">
|
||||
${issue.status === 'complete' ? '완료' : issue.status === 'progress' ? '진행중' : '신규'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -646,7 +991,7 @@
|
||||
} else if (issue?.work_hours && !isChanged) {
|
||||
confirmBtn.className = 'px-3 py-1 rounded transition-colors text-sm font-medium bg-green-100 text-green-700 cursor-default';
|
||||
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-1"></i>완료';
|
||||
} else {
|
||||
} else {
|
||||
confirmBtn.className = 'px-3 py-1 rounded transition-colors text-sm font-medium bg-blue-500 text-white hover:bg-blue-600';
|
||||
confirmBtn.innerHTML = '<i class="fas fa-clock mr-1"></i>확인';
|
||||
}
|
||||
@@ -691,13 +1036,13 @@
|
||||
const category = document.getElementById(`category-${issueId}`).value;
|
||||
const description = document.getElementById(`description-${issueId}`).value.trim();
|
||||
const workHours = parseFloat(document.getElementById(`workHours-${issueId}`).value) || 0;
|
||||
|
||||
// 유효성 검사
|
||||
if (!description) {
|
||||
|
||||
// 유효성 검사
|
||||
if (!description) {
|
||||
alert('설명은 필수 입력 항목입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 업데이트 데이터 준비
|
||||
const updateData = {
|
||||
category: category,
|
||||
@@ -719,7 +1064,7 @@
|
||||
|
||||
// 데이터 다시 로드
|
||||
await loadIssues();
|
||||
displayIssueList();
|
||||
displayIssueList();
|
||||
|
||||
// 성공 메시지 (작은 토스트 메시지)
|
||||
showToastMessage('저장되었습니다!', 'success');
|
||||
@@ -740,6 +1085,30 @@
|
||||
displayIssueList();
|
||||
}
|
||||
|
||||
// 이미지 모달 표시
|
||||
function showImageModal(imagePath) {
|
||||
if (!imagePath) return;
|
||||
|
||||
// 모달 HTML 생성
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50';
|
||||
modal.onclick = () => modal.remove();
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="relative max-w-4xl max-h-[90vh]">
|
||||
<img src="${imagePath}" class="max-w-full max-h-[90vh] object-contain rounded-lg">
|
||||
<button
|
||||
onclick="this.parentElement.parentElement.remove()"
|
||||
class="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-full hover:bg-red-600"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// 토스트 메시지 표시
|
||||
function showToastMessage(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
@@ -861,18 +1230,22 @@
|
||||
<!-- 카테고리별 분석 -->
|
||||
<div class="mt-6">
|
||||
<h4 class="font-semibold text-gray-700 mb-3">부적합 카테고리별 분석</h4>
|
||||
<div class="grid md:grid-cols-3 gap-4">
|
||||
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-blue-600">${categoryCount.material_missing || 0}</p>
|
||||
<div class="grid md:grid-cols-4 gap-4">
|
||||
<div class="bg-yellow-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-yellow-600">${categoryCount.material_missing || 0}</p>
|
||||
<p class="text-sm text-gray-600">자재누락</p>
|
||||
</div>
|
||||
<div class="bg-orange-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-orange-600">${categoryCount.dimension_defect || 0}</p>
|
||||
<p class="text-sm text-gray-600">치수불량</p>
|
||||
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-blue-600">${categoryCount.design_error || 0}</p>
|
||||
<p class="text-sm text-gray-600">설계미스</p>
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-red-600">${categoryCount.incoming_defect || 0}</p>
|
||||
<p class="text-sm text-gray-600">입고자재 불량</p>
|
||||
</div>
|
||||
<div class="bg-purple-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-purple-600">${categoryCount.incoming_defect || 0}</p>
|
||||
<p class="text-sm text-gray-600">입고자재 불량</p>
|
||||
<p class="text-2xl font-bold text-purple-600">${categoryCount.inspection_miss || 0}</p>
|
||||
<p class="text-sm text-gray-600">검사미스</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -884,13 +1257,17 @@
|
||||
${issues.map(issue => {
|
||||
const categoryNames = {
|
||||
material_missing: '자재누락',
|
||||
dimension_defect: '치수불량',
|
||||
incoming_defect: '입고자재 불량'
|
||||
design_error: '설계미스',
|
||||
incoming_defect: '입고자재 불량',
|
||||
inspection_miss: '검사미스'
|
||||
};
|
||||
return `
|
||||
<div class="border rounded-lg p-4 mb-4 break-inside-avoid">
|
||||
<div class="flex gap-4">
|
||||
${issue.photo_path ? `<img src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg">` : ''}
|
||||
<div class="flex gap-2">
|
||||
${issue.photo_path ? `<img src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg">` : ''}
|
||||
${issue.photo_path2 ? `<img src="${issue.photo_path2}" class="w-32 h-32 object-cover rounded-lg">` : ''}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h5 class="font-semibold text-gray-800 mb-2">${categoryNames[issue.category] || issue.category}</h5>
|
||||
<p class="text-gray-600 mb-2">${issue.description}</p>
|
||||
@@ -901,7 +1278,7 @@
|
||||
}</p>
|
||||
<p><span class="font-medium">작업시간:</span> ${issue.work_hours}시간</p>
|
||||
<p><span class="font-medium">보고자:</span> ${issue.reporter.full_name || issue.reporter.username}</p>
|
||||
<p><span class="font-medium">보고일:</span> ${new Date(issue.report_date).toLocaleDateString()}</p>
|
||||
<p><span class="font-medium">보고일:</span> ${DateUtils.formatKST(issue.report_date, true)}</p>
|
||||
</div>
|
||||
${issue.detail_notes ? `
|
||||
<div class="mt-2 p-2 bg-gray-50 rounded">
|
||||
|
||||
Reference in New Issue
Block a user