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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user