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:
Binary file not shown.
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user