feat: 완료 사진 HEIC 지원 및 관리함 수정 기능 개선

 새로운 기능:
- iPhone HEIC 사진 업로드 지원 (pillow-heif 라이브러리 추가)
- 완료 사진 업로드/교체 기능
- 완료 코멘트 수정 기능
- 통합 이슈 수정 모달 (진행 중/완료 대기 공통)

🔧 기술적 개선:
- HEIC 파일 자동 감지 및 원본 저장
- Base64 이미지 처리 로직 강화
- 상세한 디버깅 로그 추가
- 프론트엔드 파일 정보 로깅

📝 문서화:
- 배포 가이드 (DEPLOYMENT_GUIDE_20251026.md) 추가
- DB 변경사항 로그 업데이트
- 마이그레이션 스크립트 (020_add_management_completion_fields.sql)

🐛 버그 수정:
- loadManagementData -> initializeManagement 함수명 통일
- 모달 저장 후 즉시 닫히는 문제 해결
- 422 Unprocessable Entity 오류 해결
This commit is contained in:
Hyungi Ahn
2025-10-26 14:18:30 +09:00
parent 20965f8a42
commit e7b51f80a0
11 changed files with 798 additions and 53 deletions

View File

@@ -789,9 +789,6 @@
<button onclick="rejectCompletion(${issue.id})" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
<i class="fas fa-times mr-1"></i>반려
</button>
<button onclick="openIssueEditModal(${issue.id})" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-edit mr-1"></i>수정
</button>
<button onclick="confirmCompletion(${issue.id})" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-check-circle mr-1"></i>최종확인
</button>
@@ -800,10 +797,7 @@
<button onclick="saveIssueChanges(${issue.id})" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-save mr-1"></i>저장
</button>
<button onclick="openIssueEditModal(${issue.id})" class="px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors">
<i class="fas fa-eye mr-1"></i>확인
</button>
<button onclick="completeIssue(${issue.id})" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<button onclick="confirmCompletion(${issue.id})" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-check mr-1"></i>완료처리
</button>
`}
@@ -1748,8 +1742,13 @@
}
}
// 완료 확인 모달 열기 (진행 중 -> 완료 처리용)
function openCompletionConfirmModal(issueId) {
openIssueEditModal(issueId, true); // 완료 처리 모드로 열기
}
// 이슈 수정 모달 열기 (모든 진행 중 상태에서 사용)
function openIssueEditModal(issueId) {
function openIssueEditModal(issueId, isCompletionMode = false) {
const issue = issues.find(i => i.id === issueId);
if (!issue) return;
@@ -1793,7 +1792,14 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">원인분류</label>
<input type="text" value="${getCategoryText(issue.final_category || issue.category)}" class="w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-lg text-sm" readonly>
<select id="edit-category-${issue.id}" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
<option value="material_missing" ${(issue.final_category || issue.category) === 'material_missing' ? 'selected' : ''}>자재 누락</option>
<option value="process_error" ${(issue.final_category || issue.category) === 'process_error' ? 'selected' : ''}>공정 오류</option>
<option value="quality_defect" ${(issue.final_category || issue.category) === 'quality_defect' ? 'selected' : ''}>품질 결함</option>
<option value="design_issue" ${(issue.final_category || issue.category) === 'design_issue' ? 'selected' : ''}>설계 문제</option>
<option value="safety_violation" ${(issue.final_category || issue.category) === 'safety_violation' ? 'selected' : ''}>안전 위반</option>
<option value="etc" ${(issue.final_category || issue.category) === 'etc' ? 'selected' : ''}>기타</option>
</select>
</div>
</div>
</div>
@@ -1834,32 +1840,66 @@
<label class="block text-sm font-medium text-gray-700 mb-1">조치예상일</label>
<input type="date" id="edit-date-${issue.id}" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-sm" value="${issue.expected_completion_date ? issue.expected_completion_date.split('T')[0] : ''}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">원인부서</label>
<select id="edit-cause-department-${issue.id}" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-sm">
${getDepartmentOptions().map(opt =>
`<option value="${opt.value}" ${opt.value === (issue.cause_department || '') ? 'selected' : ''}>${opt.text}</option>`
).join('')}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">관리 코멘트</label>
<textarea id="edit-management-comment-${issue.id}" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-sm resize-none" placeholder="관리 코멘트를 입력하세요...">${issue.management_comment || ''}</textarea>
</div>
</div>
</div>
${isPendingCompletion ? `
<div class="bg-purple-50 p-4 rounded-lg">
<h4 class="font-semibold text-purple-800 mb-3">완료 신청 정보</h4>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">완료 사진</label>
<!-- 완료 신청 정보 (진행 중, 완료 대기 둘 다 표시) -->
<div class="bg-purple-50 p-4 rounded-lg">
<h4 class="font-semibold text-purple-800 mb-3">완료 신청 정보</h4>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">완료 사진</label>
<div class="space-y-3">
${issue.completion_photo_path ? `
<div class="mt-1">
<img src="${issue.completion_photo_path}" class="w-32 h-32 object-cover rounded-lg cursor-pointer border-2 border-purple-200" onclick="openPhotoModal('${issue.completion_photo_path}')" alt="완료 사진">
<div class="flex items-center space-x-3">
<img src="${issue.completion_photo_path}" class="w-24 h-24 object-cover rounded-lg cursor-pointer border-2 border-purple-200 hover:border-purple-400 transition-colors" onclick="openPhotoModal('${issue.completion_photo_path}')" alt="현재 완료 사진">
<div class="flex-1">
<p class="text-sm text-gray-600 mb-1">현재 완료 사진</p>
<p class="text-xs text-gray-500">클릭하면 크게 볼 수 있습니다</p>
</div>
</div>
` : '<p class="text-sm text-gray-500 mt-1">완료 사진 없음</p>'}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">완료 코멘트</label>
<p class="text-sm text-gray-700 p-2 bg-white rounded border">${issue.completion_comment || '코멘트 없음'}</p>
` : `
<div class="flex items-center justify-center w-24 h-24 bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg">
<div class="text-center">
<i class="fas fa-camera text-gray-400 text-lg mb-1"></i>
<p class="text-xs text-gray-500">사진 없음</p>
</div>
</div>
`}
<div class="flex items-center space-x-2">
<input type="file" id="edit-completion-photo-${issue.id}" accept="image/*" class="hidden">
<button type="button" onclick="document.getElementById('edit-completion-photo-${issue.id}').click()" class="flex items-center px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors text-sm">
<i class="fas fa-upload mr-2"></i>
${issue.completion_photo_path ? '사진 교체' : '사진 업로드'}
</button>
<span id="photo-filename-${issue.id}" class="text-sm text-gray-600"></span>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">완료 코멘트</label>
<textarea id="edit-completion-comment-${issue.id}" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm resize-none" placeholder="완료 코멘트를 입력하세요...">${issue.completion_comment || ''}</textarea>
</div>
${isPendingCompletion ? `
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">신청일시</label>
<p class="text-sm text-gray-700">${new Date(issue.completion_requested_at).toLocaleString('ko-KR')}</p>
</div>
</div>
` : ''}
</div>
` : ''}
</div>
</div>
</div>
@@ -1871,11 +1911,9 @@
<button onclick="saveIssueFromModal(${issue.id})" class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-save mr-2"></i>저장
</button>
${isPendingCompletion ? `
<button onclick="finalConfirmCompletion(${issue.id})" class="px-6 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-check-circle mr-2"></i>최종 확인
</button>
` : ''}
<button onclick="saveAndCompleteIssue(${issue.id})" class="px-6 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-check-circle mr-2"></i>최종확인
</button>
</div>
</div>
</div>
@@ -1884,6 +1922,22 @@
// 모달을 body에 추가
document.body.insertAdjacentHTML('beforeend', modalContent);
// 파일 선택 이벤트 리스너 추가
const fileInput = document.getElementById(`edit-completion-photo-${issue.id}`);
const filenameSpan = document.getElementById(`photo-filename-${issue.id}`);
if (fileInput && filenameSpan) {
fileInput.addEventListener('change', function(e) {
if (e.target.files && e.target.files[0]) {
filenameSpan.textContent = e.target.files[0].name;
filenameSpan.className = 'text-sm text-green-600 font-medium';
} else {
filenameSpan.textContent = '';
filenameSpan.className = 'text-sm text-gray-600';
}
});
}
}
// 이슈 수정 모달 닫기
@@ -1894,14 +1948,62 @@
}
}
// 파일을 Base64로 변환하는 함수
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
});
}
// 모달에서 이슈 저장
async function saveIssueFromModal(issueId) {
const title = document.getElementById(`edit-issue-title-${issueId}`).value.trim();
const detail = document.getElementById(`edit-issue-detail-${issueId}`).value.trim();
const category = document.getElementById(`edit-category-${issueId}`).value;
const solution = document.getElementById(`edit-solution-${issueId}`).value.trim();
const department = document.getElementById(`edit-department-${issueId}`).value;
const person = document.getElementById(`edit-person-${issueId}`).value.trim();
const date = document.getElementById(`edit-date-${issueId}`).value;
const causeDepartment = document.getElementById(`edit-cause-department-${issueId}`).value;
const managementComment = document.getElementById(`edit-management-comment-${issueId}`).value.trim();
// 완료 신청 정보 (완료 대기 상태일 때만)
const completionCommentElement = document.getElementById(`edit-completion-comment-${issueId}`);
const completionPhotoElement = document.getElementById(`edit-completion-photo-${issueId}`);
let completionComment = null;
let completionPhoto = null;
if (completionCommentElement) {
completionComment = completionCommentElement.value.trim();
}
if (completionPhotoElement && completionPhotoElement.files[0]) {
try {
const file = completionPhotoElement.files[0];
console.log('🔍 업로드할 파일 정보:', {
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
});
const base64 = await fileToBase64(file);
console.log('🔍 Base64 변환 완료 - 전체 길이:', base64.length);
console.log('🔍 Base64 헤더:', base64.substring(0, 50));
completionPhoto = base64.split(',')[1]; // Base64 데이터만 추출
console.log('🔍 헤더 제거 후 길이:', completionPhoto.length);
console.log('🔍 전송할 Base64 시작 부분:', completionPhoto.substring(0, 50));
} catch (error) {
console.error('파일 변환 오류:', error);
alert('완료 사진 업로드 중 오류가 발생했습니다.');
return;
}
}
if (!title) {
alert('부적합명을 입력해주세요.');
@@ -1909,6 +2011,25 @@
}
const combinedDescription = title + (detail ? '\n' + detail : '');
const requestBody = {
final_description: combinedDescription,
final_category: category,
solution: solution || null,
responsible_department: department || null,
responsible_person: person || null,
expected_completion_date: date || null,
cause_department: causeDepartment || null,
management_comment: managementComment || null
};
// 완료 신청 정보가 있으면 추가
if (completionComment !== null) {
requestBody.completion_comment = completionComment || null;
}
if (completionPhoto !== null) {
requestBody.completion_photo = completionPhoto;
}
try {
const response = await fetch(`/api/management/${issueId}`, {
@@ -1917,19 +2038,59 @@
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
final_description: combinedDescription,
solution: solution || null,
responsible_department: department || null,
responsible_person: person || null,
expected_completion_date: date || null
})
body: JSON.stringify(requestBody)
});
if (response.ok) {
alert('이슈가 성공적으로 저장되었습니다.');
closeIssueEditModal();
loadManagementData(); // 페이지 새로고침
// 저장 성공 후 데이터 새로고침하고 모달은 유지
await initializeManagement(); // 페이지 새로고침
// 저장된 이슈 정보 다시 로드하여 모달 업데이트
const updatedIssue = issues.find(i => i.id === issueId);
if (updatedIssue) {
// 완료 사진이 저장되었는지 확인
if (updatedIssue.completion_photo_path) {
alert('✅ 완료 사진이 성공적으로 저장되었습니다!');
} else {
alert('⚠️ 저장은 완료되었지만 완료 사진 저장에 실패했습니다. 다시 시도해주세요.');
}
// 모달 내용 업데이트 (완료 사진 표시 갱신)
const photoContainer = document.querySelector(`#issueEditModal img[alt*="완료 사진"]`)?.parentElement;
if (photoContainer && updatedIssue.completion_photo_path) {
// HEIC 파일인지 확인
const isHeic = updatedIssue.completion_photo_path.toLowerCase().endsWith('.heic');
if (isHeic) {
// HEIC 파일은 다운로드 링크로 표시
photoContainer.innerHTML = `
<div class="flex items-center space-x-3">
<div class="w-24 h-24 bg-purple-100 rounded-lg flex items-center justify-center border-2 border-purple-200">
<i class="fas fa-image text-purple-500 text-2xl"></i>
</div>
<div class="flex-1">
<p class="text-sm text-gray-600 mb-1">완료 사진 (HEIC)</p>
<a href="${updatedIssue.completion_photo_path}" download class="text-xs text-blue-500 hover:text-blue-700 underline">다운로드하여 확인</a>
</div>
</div>
`;
} else {
// 일반 이미지는 미리보기 표시
photoContainer.innerHTML = `
<div class="flex items-center space-x-3">
<img src="${updatedIssue.completion_photo_path}" class="w-24 h-24 object-cover rounded-lg cursor-pointer border-2 border-purple-200 hover:border-purple-400 transition-colors" onclick="openPhotoModal('${updatedIssue.completion_photo_path}')" alt="현재 완료 사진">
<div class="flex-1">
<p class="text-sm text-gray-600 mb-1">현재 완료 사진</p>
<p class="text-xs text-gray-500">클릭하면 크게 볼 수 있습니다</p>
</div>
</div>
`;
}
}
} else {
alert('이슈가 성공적으로 저장되었습니다.');
closeIssueEditModal();
}
} else {
const error = await response.json();
alert(`저장 실패: ${error.detail || '알 수 없는 오류'}`);
@@ -1958,8 +2119,8 @@
}
function confirmCompletion(issueId) {
// 모든 정보 확인 모달 열기
openCompletionConfirmModal(issueId);
// 완료 확인 모달 열기 (수정 가능) - 통합 모달 사용
openIssueEditModal(issueId, true);
}
// 완료 신청 초기화 (수정 모드로 전환)
@@ -1975,7 +2136,7 @@
if (response.ok) {
alert('완료 대기 상태가 해제되었습니다. 수정이 가능합니다.');
loadManagementData(); // 페이지 새로고침
initializeManagement(); // 페이지 새로고침
} else {
const error = await response.json();
alert(`상태 변경 실패: ${error.detail || '알 수 없는 오류'}`);
@@ -2002,7 +2163,7 @@
if (response.ok) {
alert('완료 신청이 반려되었습니다.');
loadManagementData(); // 페이지 새로고침
initializeManagement(); // 페이지 새로고침
} else {
const error = await response.json();
alert(`반려 처리 실패: ${error.detail || '알 수 없는 오류'}`);
@@ -2121,7 +2282,110 @@
}
}
// 최종 완료 확인
// 저장 후 완료 처리 (최종확인)
async function saveAndCompleteIssue(issueId) {
if (!confirm('수정 내용을 저장하고 이 부적합을 최종 완료 처리하시겠습니까?\n완료 처리 후에는 수정할 수 없습니다.')) {
return;
}
const title = document.getElementById(`edit-issue-title-${issueId}`).value.trim();
const detail = document.getElementById(`edit-issue-detail-${issueId}`).value.trim();
const category = document.getElementById(`edit-category-${issueId}`).value;
const solution = document.getElementById(`edit-solution-${issueId}`).value.trim();
const department = document.getElementById(`edit-department-${issueId}`).value;
const person = document.getElementById(`edit-person-${issueId}`).value.trim();
const date = document.getElementById(`edit-date-${issueId}`).value;
const causeDepartment = document.getElementById(`edit-cause-department-${issueId}`).value;
const managementComment = document.getElementById(`edit-management-comment-${issueId}`).value.trim();
// 완료 신청 정보 (완료 대기 상태일 때만)
const completionCommentElement = document.getElementById(`edit-completion-comment-${issueId}`);
const completionPhotoElement = document.getElementById(`edit-completion-photo-${issueId}`);
let completionComment = null;
let completionPhoto = null;
if (completionCommentElement) {
completionComment = completionCommentElement.value.trim();
}
if (completionPhotoElement && completionPhotoElement.files[0]) {
try {
const file = completionPhotoElement.files[0];
console.log('🔍 업로드할 파일 정보:', {
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
});
const base64 = await fileToBase64(file);
console.log('🔍 Base64 변환 완료 - 전체 길이:', base64.length);
console.log('🔍 Base64 헤더:', base64.substring(0, 50));
completionPhoto = base64.split(',')[1]; // Base64 데이터만 추출
console.log('🔍 헤더 제거 후 길이:', completionPhoto.length);
console.log('🔍 전송할 Base64 시작 부분:', completionPhoto.substring(0, 50));
} catch (error) {
console.error('파일 변환 오류:', error);
alert('완료 사진 업로드 중 오류가 발생했습니다.');
return;
}
}
if (!title) {
alert('부적합명을 입력해주세요.');
return;
}
const combinedDescription = title + (detail ? '\n' + detail : '');
const requestBody = {
final_description: combinedDescription,
final_category: category,
solution: solution || null,
responsible_department: department || null,
responsible_person: person || null,
expected_completion_date: date || null,
cause_department: causeDepartment || null,
management_comment: managementComment || null,
review_status: 'completed' // 완료 상태로 변경
};
// 완료 신청 정보가 있으면 추가
if (completionComment !== null) {
requestBody.completion_comment = completionComment || null;
}
if (completionPhoto !== null) {
requestBody.completion_photo = completionPhoto;
}
try {
// 1. 먼저 수정 내용 저장
const saveResponse = await fetch(`/api/management/${issueId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (saveResponse.ok) {
alert('부적합이 수정되고 최종 완료 처리되었습니다.');
closeIssueEditModal();
initializeManagement(); // 페이지 새로고침
} else {
const error = await saveResponse.json();
alert(`저장 및 완료 처리 실패: ${error.detail || '알 수 없는 오류'}`);
}
} catch (error) {
console.error('저장 및 완료 처리 오류:', error);
alert('저장 및 완료 처리 중 오류가 발생했습니다.');
}
}
// 최종 완료 확인 (기존 함수 - 필요시 사용)
async function finalConfirmCompletion(issueId) {
if (!confirm('이 부적합을 최종 완료 처리하시겠습니까?\n완료 처리 후에는 수정할 수 없습니다.')) {
return;
@@ -2138,8 +2402,8 @@
if (response.ok) {
alert('부적합이 최종 완료 처리되었습니다.');
closeCompletionConfirmModal();
loadManagementData(); // 페이지 새로고침
closeIssueEditModal();
initializeManagement(); // 페이지 새로고침
} else {
const error = await response.json();
alert(`완료 처리 실패: ${error.detail || '알 수 없는 오류'}`);