feat: 관리함 완전 개편 - 편집 가능한 테이블 및 완료 처리 기능

🎯 Major Management Page Overhaul:
- 테이블 최소 너비 2000px로 확장 (좌우 스크롤 최적화)
- 컬럼별 개별 너비 설정으로 내용에 맞는 크기 조정
- 편집 가능한 필드들 (해결방안, 담당부서, 담당자, 조치예상일, 원인부서, 의견)
- 진행 중 → 완료됨 처리 버튼 추가

📊 Enhanced Table Structure:
- 업로드 사진 2장 표시 (photo_path, photo_path2)
- 완료 사진 별도 컬럼으로 표시
- 작업 컬럼 추가 (저장 버튼)
- 완료 확인 컬럼 (진행 중: 완료 처리 버튼, 완료됨: 완료일)

✏️ Editable Fields Implementation:
- createEditableField() 함수로 동적 입력 필드 생성
- textarea, select, date, text 타입 지원
- 부서 선택 드롭다운 (생산, 품질, 구매, 설계, 영업)
- 실시간 편집 및 저장 기능

🔧 Backend API Enhancement:
- PUT /api/issues/{issue_id}/management 엔드포인트 추가
- ManagementUpdateRequest 스키마 활용
- 관리함 페이지 권한 검증
- 완료 사진 업로드 지원

📈 Smart Sequencing System:
- 수신함에서 넘어온 순서대로 No. 할당 (reviewed_at 기준)
- 프로젝트별 그룹화 후 순번 재할당
- 진행 중 A → 진행 중 B → 완료됨 C → 진행 중 D = 1, 2, 3, 4

🎨 UI/UX Improvements:
- 컬럼별 CSS 클래스로 일관된 스타일링
- 편집 가능한 필드 포커스 효과
- 사진 컨테이너로 2장 사진 깔끔한 배치
- 버튼 크기 최적화 (btn-sm 클래스)

🚀 Functional Features:
- completeIssue(): 진행 중 → 완료됨 처리
- saveIssueChanges(): 편집된 필드들 일괄 저장
- 실시간 목록 새로고침
- 확인 다이얼로그로 안전한 작업 처리

Expected Result:
 좌우 스크롤로 모든 정보 편리하게 확인
 관리함에서 필요한 정보 직접 입력/수정
 진행 중에서 완료 처리 원클릭
 수신함 순서 기반 체계적인 No. 관리
 업로드 사진 2장 + 완료 사진 명확한 구분
This commit is contained in:
Hyungi Ahn
2025-10-25 15:48:53 +09:00
parent 28fcc6a72e
commit 95be1f6c6e
3 changed files with 290 additions and 47 deletions

View File

@@ -7,6 +7,7 @@ from database.database import get_db
from database.models import Issue, IssueStatus, User, UserRole
from database import schemas
from routers.auth import get_current_user, get_current_admin
from routers.page_permissions import check_page_access
from services.file_service import save_base64_image, delete_file
router = APIRouter(prefix="/api/issues", tags=["issues"])
@@ -224,3 +225,45 @@ async def get_issue_stats(
"progress": progress,
"complete": complete
}
@router.put("/{issue_id}/management")
async def update_issue_management(
issue_id: int,
management_update: schemas.ManagementUpdateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
관리함에서 이슈의 관리 관련 필드들을 업데이트합니다.
"""
# 관리함 페이지 권한 확인
if not (current_user.role == UserRole.admin or check_page_access(current_user.id, 'issues_management', db)):
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
# 이슈 조회
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
# 관리함에서만 수정 가능한 필드들만 업데이트
update_data = management_update.dict(exclude_unset=True)
for field, value in update_data.items():
if field == 'completion_photo' and value:
# 완료 사진 업로드 처리
try:
completion_photo_path = save_base64_image(value, "completion")
setattr(issue, 'completion_photo_path', completion_photo_path)
except Exception as e:
raise HTTPException(status_code=400, detail=f"완료 사진 저장 실패: {str(e)}")
elif field != 'completion_photo': # completion_photo는 위에서 처리됨
setattr(issue, field, value)
db.commit()
db.refresh(issue)
return {
"message": "관리 정보가 업데이트되었습니다.",
"issue_id": issue.id,
"updated_fields": list(update_data.keys())
}

View File

@@ -85,7 +85,7 @@
}
.issue-table {
min-width: 1200px;
min-width: 2000px; /* 더 넓은 최소 너비 설정 */
width: 100%;
border-collapse: collapse;
}
@@ -95,7 +95,7 @@
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #f3f4f6;
white-space: nowrap;
vertical-align: top;
}
.issue-table th {
@@ -103,24 +103,70 @@
font-weight: 600;
color: #374151;
font-size: 0.875rem;
white-space: nowrap;
}
.issue-table tbody tr:hover {
background-color: #f9fafb;
}
/* 컬럼별 너비 조정 */
.col-no { min-width: 60px; }
.col-project { min-width: 120px; }
.col-content { min-width: 250px; max-width: 300px; }
.col-cause { min-width: 100px; }
.col-solution { min-width: 200px; max-width: 250px; }
.col-department { min-width: 100px; }
.col-person { min-width: 120px; }
.col-date { min-width: 120px; }
.col-confirmer { min-width: 120px; }
.col-comment { min-width: 200px; max-width: 250px; }
.col-status { min-width: 100px; }
.col-photos { min-width: 150px; }
.col-completion { min-width: 80px; }
.col-actions { min-width: 120px; }
.issue-photo {
width: 60px;
height: 40px;
object-fit: cover;
border-radius: 0.375rem;
cursor: pointer;
margin: 2px;
}
.issue-description {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
.photo-container {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* 편집 가능한 필드 스타일 */
.editable-field {
min-width: 100%;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.875rem;
}
.editable-field:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
}
.text-wrap {
white-space: normal;
word-wrap: break-word;
line-height: 1.4;
}
.btn-sm {
padding: 4px 8px;
font-size: 0.75rem;
border-radius: 4px;
margin: 2px;
}
.collapse-content {
@@ -367,9 +413,30 @@
if (response.ok) {
const allIssues = await response.json();
// 관리함에서는 진행 중(in_progress)과 완료됨(completed) 상태만 표시
issues = allIssues.filter(issue =>
let filteredIssues = allIssues.filter(issue =>
issue.review_status === 'in_progress' || issue.review_status === 'completed'
);
// 수신함에서 넘어온 순서대로 No. 재할당 (reviewed_at 기준)
filteredIssues.sort((a, b) => new Date(a.reviewed_at) - new Date(b.reviewed_at));
// 프로젝트별로 그룹화하여 No. 재할당
const projectGroups = {};
filteredIssues.forEach(issue => {
if (!projectGroups[issue.project_id]) {
projectGroups[issue.project_id] = [];
}
projectGroups[issue.project_id].push(issue);
});
// 각 프로젝트별로 순번 재할당
Object.keys(projectGroups).forEach(projectId => {
projectGroups[projectId].forEach((issue, index) => {
issue.project_sequence_no = index + 1;
});
});
issues = filteredIssues;
filterIssues();
} else {
throw new Error('부적합 목록을 불러올 수 없습니다.');
@@ -506,25 +573,26 @@
<div class="collapse-content" id="${groupId}">
<div class="issue-table-container">
<table class="issue-table">
<thead>
<tr>
<th>No.</th>
<th>프로젝트</th>
<th>내용</th>
<th>원인</th>
<th>해결방안</th>
<th>담당부서</th>
<th>담당자</th>
<th>조치예상일</th>
<th>완료확인일</th>
<th>확인자</th>
<th>원인부서</th>
<th>의견</th>
<th>조치결과</th>
<th>업로드 사진</th>
<th>완료 사진</th>
</tr>
</thead>
<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>
</tr>
</thead>
<tbody>
${issues.map(issue => createIssueRow(issue)).join('')}
</tbody>
@@ -543,38 +611,98 @@
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';
return `
<tr>
<td class="font-medium">${issue.project_sequence_no || '-'}</td>
<td>${project ? project.project_name : '-'}</td>
<td class="issue-description" title="${issue.final_description || issue.description}">
${issue.final_description || issue.description}
<tr data-issue-id="${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 text-wrap">${issue.final_description || issue.description}</td>
<td class="col-cause">${getCategoryText(issue.final_category || issue.category)}</td>
<td class="col-solution">
${createEditableField('solution', issue.solution || '', 'textarea', issue.id, true)}
</td>
<td>${getCategoryText(issue.final_category || issue.category)}</td>
<td class="issue-description" title="${issue.solution || '-'}">
${issue.solution || '-'}
<td class="col-department">
${createEditableField('responsible_department', issue.responsible_department || '', 'select', issue.id, true, getDepartmentOptions())}
</td>
<td>${getDepartmentText(issue.responsible_department) || '-'}</td>
<td>${issue.responsible_person || '-'}</td>
<td>${issue.expected_completion_date ? new Date(issue.expected_completion_date).toLocaleDateString('ko-KR') : '-'}</td>
<td>${issue.actual_completion_date ? new Date(issue.actual_completion_date).toLocaleDateString('ko-KR') : '-'}</td>
<td>${getReporterNames(issue)}</td>
<td>${getDepartmentText(issue.cause_department) || '-'}</td>
<td class="issue-description" title="${issue.management_comment || '-'}">
${issue.management_comment || '-'}
<td class="col-person">
${createEditableField('responsible_person', issue.responsible_person || '', 'text', issue.id, true)}
</td>
<td class="${statusClass} font-medium">${statusText}</td>
<td>
${issue.photo_path ? `<img src="${issue.photo_path}" class="issue-photo" onclick="openPhotoModal('${issue.photo_path}')" alt="업로드 사진">` : '-'}
${issue.photo_path2 ? `<br><img src="${issue.photo_path2}" class="issue-photo mt-1" onclick="openPhotoModal('${issue.photo_path2}')" alt="업로드 사진 2">` : ''}
<td class="col-date">
${createEditableField('expected_completion_date', issue.expected_completion_date ? issue.expected_completion_date.split('T')[0] : '', 'date', issue.id, true)}
</td>
<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') : '-')
}
</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">` : ''}
${issue.photo_path2 ? `<img src="${issue.photo_path2}" class="issue-photo" onclick="openPhotoModal('${issue.photo_path2}')" alt="업로드 사진 2">` : ''}
${!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>
</tr>
`;
}
// 편집 가능한 필드 생성 함수
function createEditableField(fieldName, value, type, issueId, editable, options = null) {
if (!editable) {
return value || '-';
}
const fieldId = `${fieldName}_${issueId}`;
switch (type) {
case 'textarea':
return `<textarea id="${fieldId}" class="editable-field text-wrap" rows="2">${value}</textarea>`;
case 'select':
if (options) {
const optionsHtml = options.map(opt =>
`<option value="${opt.value}" ${opt.value === value ? 'selected' : ''}>${opt.text}</option>`
).join('');
return `<select id="${fieldId}" class="editable-field">${optionsHtml}</select>`;
}
break;
case 'date':
return `<input type="date" id="${fieldId}" class="editable-field" value="${value}">`;
case 'text':
default:
return `<input type="text" id="${fieldId}" class="editable-field" value="${value}">`;
}
return value || '-';
}
// 부서 옵션 생성 함수
function getDepartmentOptions() {
return [
{ value: '', text: '선택하세요' },
{ value: 'production', text: '생산' },
{ value: 'quality', text: '품질' },
{ value: 'purchasing', text: '구매' },
{ value: 'design', text: '설계' },
{ value: 'sales', text: '영업' }
];
}
// 날짜 그룹 토글 함수
function toggleDateGroup(groupId) {
@@ -688,6 +816,78 @@
}
}
// 완료 처리 함수
async function completeIssue(issueId) {
if (!confirm('이 부적합을 완료 처리하시겠습니까?')) {
return;
}
try {
const response = await fetch(`/api/inbox/${issueId}/status`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
review_status: 'completed'
})
});
if (response.ok) {
alert('완료 처리되었습니다.');
await loadIssues(); // 목록 새로고침
} else {
const error = await response.json();
throw new Error(error.detail || '완료 처리에 실패했습니다.');
}
} catch (error) {
console.error('완료 처리 실패:', error);
alert(error.message || '완료 처리 중 오류가 발생했습니다.');
}
}
// 이슈 변경사항 저장 함수
async function saveIssueChanges(issueId) {
try {
// 편집된 필드들의 값 수집
const updates = {};
const fields = ['solution', 'responsible_department', 'responsible_person', 'expected_completion_date', 'cause_department', 'management_comment'];
fields.forEach(field => {
const element = document.getElementById(`${field}_${issueId}`);
if (element) {
let value = element.value.trim();
if (value === '' || value === '선택하세요') {
value = null;
}
updates[field] = value;
}
});
// API 호출
const response = await fetch(`/api/issues/${issueId}/management`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(updates)
});
if (response.ok) {
alert('변경사항이 저장되었습니다.');
await loadIssues(); // 목록 새로고침
} else {
const error = await response.json();
throw new Error(error.detail || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 실패:', error);
alert(error.message || '저장 중 오류가 발생했습니다.');
}
}
// 기타 함수들
function viewIssueDetail(issueId) {