feat: 관리함 탭별 차별화 및 완료된 이슈 상세보기 모달 구현

🎯 Tab-Specific Interface Design:
- 진행 중 탭: 편집 가능한 테이블 형태 (11개 컬럼)
- 완료됨 탭: 입력 여부 표시 + 클릭으로 상세보기 (13개 컬럼)
- 탭별 다른 헤더 구조로 최적화된 정보 표시

 완료됨 탭 - 입력 여부 표시:
-  입력됨 (초록색 체크)
-  미입력 (회색 X)
- 사진:  2장 형태로 개수 표시
- 클릭 시 상세보기 모달 팝업

🔧 진행 중 탭 최적화:
- 완료 처리 버튼 한 줄로 표시 (white-space: nowrap)
- 불필요한 컬럼 제거 (완료확인일, 확인자, 원인부서, 의견, 조치결과, 완료사진)
- 핵심 편집 필드만 표시로 깔끔한 인터페이스

📋 완료된 이슈 상세보기 모달:
- 2컬럼 레이아웃 (읽기전용 vs 편집가능)
- 읽기전용: 프로젝트, 내용, 원인, 확인자, 업로드사진
- 편집가능: 해결방안, 담당부서, 담당자, 조치예상일, 원인부서, 의견, 완료사진
- 완료 사진 업로드 기능 포함

🎨 Enhanced UX Features:
- 완료됨 행 호버 효과 (hover:bg-blue-50)
- 모달 내 사진 클릭으로 확대보기
- 파일 업로드 → Base64 변환 → API 전송
- 저장 후 자동 목록 새로고침

🔄 Dynamic Header Generation:
- createTableHeader() 함수로 탭별 헤더 동적 생성
- 진행 중: 11개 컬럼 (편집 중심)
- 완료됨: 13개 컬럼 (검토 중심)

📊 Status Icon System:
- getStatusIcon(): 입력 여부를 시각적으로 표시
- getPhotoStatusIcon(): 사진 개수와 함께 상태 표시
- 일관된 / 아이콘으로 직관적 인식

🚀 Technical Implementation:
- openIssueDetailModal(): 완료된 이슈 상세보기
- createModalContent(): 동적 모달 내용 생성
- saveModalChanges(): 편집된 내용 저장
- fileToBase64(): 파일 업로드 처리

Expected Result:
 진행 중: 편집 중심의 간소화된 테이블
 완료됨: 입력 여부 확인 + 상세 편집 모달
 완료 처리 버튼 한 줄로 깔끔한 표시
 권한별 필드 구분으로 명확한 워크플로우
This commit is contained in:
Hyungi Ahn
2025-10-25 16:01:10 +09:00
parent 95be1f6c6e
commit 11b03348f9

View File

@@ -167,6 +167,8 @@
font-size: 0.75rem;
border-radius: 4px;
margin: 2px;
white-space: nowrap;
min-width: fit-content;
}
.collapse-content {
@@ -289,6 +291,38 @@
</div>
</main>
<!-- 완료된 이슈 상세보기 모달 -->
<div id="issueDetailModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6">
<!-- 모달 헤더 -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900" id="modalTitle">부적합 상세 정보</h2>
<button onclick="closeIssueDetailModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 모달 내용 -->
<div id="modalContent" class="space-y-6">
<!-- 동적으로 생성될 내용 -->
</div>
<!-- 모달 푸터 -->
<div class="flex justify-end space-x-3 mt-6 pt-6 border-t">
<button onclick="closeIssueDetailModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
취소
</button>
<button onclick="saveModalChanges()" class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
저장
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 상태 변경 모달 -->
<div id="statusModal" class="fixed inset-0 bg-black bg-opacity-50 modal hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
@@ -575,22 +609,7 @@
<table class="issue-table">
<thead>
<tr>
<th class="col-no">No.</th>
<th class="col-project">프로젝트</th>
<th class="col-content">내용</th>
<th class="col-cause">원인</th>
<th class="col-solution">해결방안</th>
<th class="col-department">담당부서</th>
<th class="col-person">담당자</th>
<th class="col-date">조치예상일</th>
<th class="col-completion">${currentTab === 'in_progress' ? '완료 확인' : '완료확인일'}</th>
<th class="col-confirmer">확인자</th>
<th class="col-department">원인부서</th>
<th class="col-comment">의견</th>
<th class="col-status">조치결과</th>
<th class="col-photos">업로드 사진</th>
<th class="col-completion">완료 사진</th>
<th class="col-actions">작업</th>
${createTableHeader()}
</tr>
</thead>
<tbody>
@@ -609,11 +628,20 @@
// 이슈 행 생성 함수
function createIssueRow(issue) {
const project = projects.find(p => p.id === issue.project_id);
const statusText = issue.review_status === 'in_progress' ? '진행 중' : '완료됨';
const statusClass = issue.review_status === 'in_progress' ? 'text-blue-600' : 'text-green-600';
const isInProgress = issue.review_status === 'in_progress';
const isCompleted = issue.review_status === 'completed';
if (isInProgress) {
// 진행 중 - 편집 가능한 형태
return createInProgressRow(issue, project);
} else {
// 완료됨 - 입력 여부 표시 + 클릭으로 상세보기
return createCompletedRow(issue, project);
}
}
// 진행 중 행 생성
function createInProgressRow(issue, project) {
return `
<tr data-issue-id="${issue.id}">
<td class="col-no font-medium">${issue.project_sequence_no || '-'}</td>
@@ -633,19 +661,8 @@
${createEditableField('expected_completion_date', issue.expected_completion_date ? issue.expected_completion_date.split('T')[0] : '', 'date', issue.id, true)}
</td>
<td class="col-completion">
${isInProgress ?
`<button onclick="completeIssue(${issue.id})" class="btn-sm bg-green-500 text-white hover:bg-green-600">완료 처리</button>` :
(issue.actual_completion_date ? new Date(issue.actual_completion_date).toLocaleDateString('ko-KR') : '-')
}
<button onclick="completeIssue(${issue.id})" class="btn-sm bg-green-500 text-white hover:bg-green-600 whitespace-nowrap">완료처리</button>
</td>
<td class="col-confirmer">${getReporterNames(issue)}</td>
<td class="col-department">
${createEditableField('cause_department', issue.cause_department || '', 'select', issue.id, true, getDepartmentOptions())}
</td>
<td class="col-comment">
${createEditableField('management_comment', issue.management_comment || '', 'textarea', issue.id, true)}
</td>
<td class="col-status ${statusClass} font-medium">${statusText}</td>
<td class="col-photos">
<div class="photo-container">
${issue.photo_path ? `<img src="${issue.photo_path}" class="issue-photo" onclick="openPhotoModal('${issue.photo_path}')" alt="업로드 사진 1">` : ''}
@@ -653,9 +670,6 @@
${!issue.photo_path && !issue.photo_path2 ? '-' : ''}
</div>
</td>
<td class="col-completion">
${issue.completion_photo_path ? `<img src="${issue.completion_photo_path}" class="issue-photo" onclick="openPhotoModal('${issue.completion_photo_path}')" alt="완료 사진">` : '-'}
</td>
<td class="col-actions">
<button onclick="saveIssueChanges(${issue.id})" class="btn-sm bg-blue-500 text-white hover:bg-blue-600">저장</button>
</td>
@@ -663,6 +677,83 @@
`;
}
// 완료됨 행 생성 (입력 여부 표시)
function createCompletedRow(issue, project) {
return `
<tr data-issue-id="${issue.id}" class="cursor-pointer hover:bg-blue-50" onclick="openIssueDetailModal(${issue.id})">
<td class="col-no font-medium">${issue.project_sequence_no || '-'}</td>
<td class="col-project">${project ? project.project_name : '-'}</td>
<td class="col-content">${getStatusIcon(issue.final_description || issue.description)}</td>
<td class="col-cause">${getStatusIcon(getCategoryText(issue.final_category || issue.category))}</td>
<td class="col-solution">${getStatusIcon(issue.solution)}</td>
<td class="col-department">${getStatusIcon(issue.responsible_department)}</td>
<td class="col-person">${getStatusIcon(issue.responsible_person)}</td>
<td class="col-date">${getStatusIcon(issue.expected_completion_date)}</td>
<td class="col-confirmer">${getReporterNames(issue)}</td>
<td class="col-department">${getStatusIcon(issue.cause_department)}</td>
<td class="col-comment">${getStatusIcon(issue.management_comment)}</td>
<td class="col-photos">${getPhotoStatusIcon(issue.photo_path, issue.photo_path2)}</td>
<td class="col-completion">${getStatusIcon(issue.completion_photo_path)}</td>
</tr>
`;
}
// 입력 여부 아이콘 생성
function getStatusIcon(value) {
if (value && value.toString().trim() !== '') {
return '<span class="text-green-500 text-lg">✅</span>';
} else {
return '<span class="text-gray-400 text-lg">❌</span>';
}
}
// 사진 상태 아이콘 생성
function getPhotoStatusIcon(photo1, photo2) {
const count = (photo1 ? 1 : 0) + (photo2 ? 1 : 0);
if (count > 0) {
return `<span class="text-green-500 text-lg">✅</span><span class="text-xs ml-1">${count}장</span>`;
} else {
return '<span class="text-gray-400 text-lg">❌</span>';
}
}
// 테이블 헤더 생성 함수
function createTableHeader() {
if (currentTab === 'in_progress') {
// 진행 중 탭 헤더
return `
<th class="col-no">No.</th>
<th class="col-project">프로젝트</th>
<th class="col-content">내용</th>
<th class="col-cause">원인</th>
<th class="col-solution">해결방안</th>
<th class="col-department">담당부서</th>
<th class="col-person">담당자</th>
<th class="col-date">조치예상일</th>
<th class="col-completion">완료 확인</th>
<th class="col-photos">업로드 사진</th>
<th class="col-actions">작업</th>
`;
} else {
// 완료됨 탭 헤더
return `
<th class="col-no">No.</th>
<th class="col-project">프로젝트</th>
<th class="col-content">내용</th>
<th class="col-cause">원인</th>
<th class="col-solution">해결방안</th>
<th class="col-department">담당부서</th>
<th class="col-person">담당자</th>
<th class="col-date">조치예상일</th>
<th class="col-confirmer">확인자</th>
<th class="col-department">원인부서</th>
<th class="col-comment">의견</th>
<th class="col-photos">업로드 사진</th>
<th class="col-completion">완료 사진</th>
`;
}
}
// 편집 가능한 필드 생성 함수
function createEditableField(fieldName, value, type, issueId, editable, options = null) {
if (!editable) {
@@ -888,6 +979,189 @@
}
}
// 완료된 이슈 상세보기 모달 함수들
let currentModalIssueId = null;
async function openIssueDetailModal(issueId) {
currentModalIssueId = issueId;
const issue = issues.find(i => i.id === issueId);
if (!issue) return;
const project = projects.find(p => p.id === issue.project_id);
// 모달 제목 설정
document.getElementById('modalTitle').textContent = `부적합 No.${issue.project_sequence_no} 상세 정보`;
// 모달 내용 생성
const modalContent = document.getElementById('modalContent');
modalContent.innerHTML = createModalContent(issue, project);
// 모달 표시
document.getElementById('issueDetailModal').classList.remove('hidden');
}
function closeIssueDetailModal() {
document.getElementById('issueDetailModal').classList.add('hidden');
currentModalIssueId = null;
}
function createModalContent(issue, project) {
return `
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 읽기 전용 필드들 -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-800 border-b pb-2">기본 정보 (수신함 확정)</h3>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
<div class="p-3 bg-gray-50 rounded-lg text-gray-800">${project ? project.project_name : '-'}</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">내용</label>
<div class="p-3 bg-gray-50 rounded-lg text-gray-800 whitespace-pre-wrap">${issue.final_description || issue.description}</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">원인</label>
<div class="p-3 bg-gray-50 rounded-lg text-gray-800">${getCategoryText(issue.final_category || issue.category)}</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">확인자</label>
<div class="p-3 bg-gray-50 rounded-lg text-gray-800">${getReporterNames(issue)}</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">업로드 사진</label>
<div class="flex gap-2">
${issue.photo_path ? `<img src="${issue.photo_path}" class="w-20 h-20 object-cover rounded cursor-pointer" onclick="openPhotoModal('${issue.photo_path}')" alt="업로드 사진 1">` : '<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center text-gray-500 text-xs">없음</div>'}
${issue.photo_path2 ? `<img src="${issue.photo_path2}" class="w-20 h-20 object-cover rounded cursor-pointer" onclick="openPhotoModal('${issue.photo_path2}')" alt="업로드 사진 2">` : '<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center text-gray-500 text-xs">없음</div>'}
</div>
</div>
</div>
</div>
<!-- 편집 가능한 필드들 -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-800 border-b pb-2">관리 정보 (편집 가능)</h3>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">해결방안</label>
<textarea id="modal_solution" rows="3" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">${issue.solution || ''}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">담당부서</label>
<select id="modal_responsible_department" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
${getDepartmentOptions().map(opt =>
`<option value="${opt.value}" ${opt.value === (issue.responsible_department || '') ? 'selected' : ''}>${opt.text}</option>`
).join('')}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">담당자</label>
<input type="text" id="modal_responsible_person" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="${issue.responsible_person || ''}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">조치예상일</label>
<input type="date" id="modal_expected_completion_date" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" 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="modal_cause_department" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
${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="modal_management_comment" rows="3" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">${issue.management_comment || ''}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">완료 사진</label>
<div class="flex items-center gap-3">
${issue.completion_photo_path ?
`<img src="${issue.completion_photo_path}" class="w-20 h-20 object-cover rounded cursor-pointer" onclick="openPhotoModal('${issue.completion_photo_path}')" alt="완료 사진">` :
'<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center text-gray-500 text-xs">없음</div>'
}
<input type="file" id="modal_completion_photo" accept="image/*" class="flex-1 text-sm">
</div>
</div>
</div>
</div>
</div>
`;
}
async function saveModalChanges() {
if (!currentModalIssueId) return;
try {
// 편집된 필드들의 값 수집
const updates = {};
const fields = ['solution', 'responsible_department', 'responsible_person', 'expected_completion_date', 'cause_department', 'management_comment'];
fields.forEach(field => {
const element = document.getElementById(`modal_${field}`);
if (element) {
let value = element.value.trim();
if (value === '' || value === '선택하세요') {
value = null;
}
updates[field] = value;
}
});
// 완료 사진 처리
const photoFile = document.getElementById('modal_completion_photo').files[0];
if (photoFile) {
const base64 = await fileToBase64(photoFile);
updates.completion_photo = base64;
}
// API 호출
const response = await fetch(`/api/issues/${currentModalIssueId}/management`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(updates)
});
if (response.ok) {
alert('변경사항이 저장되었습니다.');
closeIssueDetailModal();
await loadIssues(); // 목록 새로고침
} else {
const error = await response.json();
throw new Error(error.detail || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 실패:', error);
alert(error.message || '저장 중 오류가 발생했습니다.');
}
}
// 파일을 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);
});
}
// 기타 함수들
function viewIssueDetail(issueId) {