feat: 관리함 통합 수정 모달 및 히스토리 추적 방안 구현
🔧 통합 수정 모달: - 모든 진행 중 상태에서 '확인' 버튼으로 모달 열기 - 완료 대기 상태에서도 '수정' 버튼으로 동일 모달 사용 - 6열 와이드 모달로 모든 정보 한눈에 표시 📝 모달 구성: - 왼쪽: 기본 정보 (프로젝트, 부적합명, 상세내용, 원인분류, 업로드 사진) - 오른쪽: 관리 정보 (해결방안, 담당부서/자, 조치예상일) + 완료 신청 정보 🎯 버튼 시스템 개선: - 일반 진행 중: 저장 | 확인 | 완료처리 - 완료 대기: 반려 | 수정 | 최종확인 - 모달에서 통합 수정 가능 ✏️ 수정 기능: - 부적합명, 상세내용 직접 수정 - 해결방안, 담당부서/자, 조치예상일 수정 - 모달에서 저장 시 실시간 반영 📋 히스토리 추적 방안 문서화: - 단일 히스토리 테이블 vs 페이지별 테이블 비교 - 변경 이력 기록 서비스 클래스 설계 - 프론트엔드 히스토리 조회 모달 구현 방안 - 감사 추적, 데이터 복구, 보안 고려사항 포함 🔍 구현 우선순위: - Phase 1: 기본 히스토리 테이블 + 관리함 이력 - Phase 2: 수신함 이력 + 히스토리 UI - Phase 3: 데이터 복구 + 감사 보고서 💡 추가 아이디어: - 변경 승인 워크플로우 - 자동 백업 시스템 - 변경 영향도 분석 Expected Result: ✅ 모든 진행 중 상태에서 통합 수정 모달 사용 ✅ 완료 대기 상태 정보 포함 표시 ✅ 체계적인 히스토리 추적 방안 수립 ✅ 투명하고 추적 가능한 이슈 관리 기반 마련
This commit is contained in:
307
HISTORY_TRACKING_IDEAS.md
Normal file
307
HISTORY_TRACKING_IDEAS.md
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
# 📝 수정 히스토리 저장 방안
|
||||||
|
|
||||||
|
## 🎯 목적
|
||||||
|
- 각 페이지에서 수정한 내용의 변경 이력 추적
|
||||||
|
- 누가, 언제, 무엇을, 왜 변경했는지 기록
|
||||||
|
- 데이터 무결성 및 감사 추적 (Audit Trail) 제공
|
||||||
|
|
||||||
|
## 🗄️ DB 구조 방안
|
||||||
|
|
||||||
|
### 방안 1: 단일 히스토리 테이블 (권장)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE issue_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
issue_id INTEGER NOT NULL REFERENCES issues(id),
|
||||||
|
changed_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
change_type VARCHAR(50) NOT NULL, -- 'UPDATE', 'STATUS_CHANGE', 'COMPLETION_REQUEST' 등
|
||||||
|
page_source VARCHAR(50) NOT NULL, -- 'INBOX', 'MANAGEMENT', 'DASHBOARD' 등
|
||||||
|
field_name VARCHAR(100), -- 변경된 필드명
|
||||||
|
old_value TEXT, -- 이전 값 (JSON 형태로 저장 가능)
|
||||||
|
new_value TEXT, -- 새로운 값 (JSON 형태로 저장 가능)
|
||||||
|
change_reason TEXT, -- 변경 사유 (선택사항)
|
||||||
|
session_id VARCHAR(100), -- 동일 세션에서 여러 필드 변경 시 그룹핑
|
||||||
|
ip_address INET, -- 변경자 IP 주소
|
||||||
|
user_agent TEXT -- 브라우저 정보
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 인덱스 생성
|
||||||
|
CREATE INDEX idx_issue_history_issue_id ON issue_history(issue_id);
|
||||||
|
CREATE INDEX idx_issue_history_changed_at ON issue_history(changed_at);
|
||||||
|
CREATE INDEX idx_issue_history_changed_by ON issue_history(changed_by_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 방안 2: 페이지별 히스토리 테이블
|
||||||
|
```sql
|
||||||
|
-- 수신함 히스토리
|
||||||
|
CREATE TABLE inbox_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
issue_id INTEGER NOT NULL REFERENCES issues(id),
|
||||||
|
action_type VARCHAR(50), -- 'REVIEW', 'DISPOSE', 'STATUS_CHANGE'
|
||||||
|
old_data JSONB,
|
||||||
|
new_data JSONB,
|
||||||
|
changed_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 관리함 히스토리
|
||||||
|
CREATE TABLE management_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
issue_id INTEGER NOT NULL REFERENCES issues(id),
|
||||||
|
action_type VARCHAR(50), -- 'UPDATE', 'COMPLETION_REQUEST', 'APPROVAL'
|
||||||
|
old_data JSONB,
|
||||||
|
new_data JSONB,
|
||||||
|
changed_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 구현 방안
|
||||||
|
|
||||||
|
### 1️⃣ 백엔드 구현 (FastAPI)
|
||||||
|
|
||||||
|
#### 히스토리 서비스 클래스
|
||||||
|
```python
|
||||||
|
# services/history_service.py
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
class HistoryService:
|
||||||
|
@staticmethod
|
||||||
|
def log_change(
|
||||||
|
db: Session,
|
||||||
|
issue_id: int,
|
||||||
|
changed_by_id: int,
|
||||||
|
change_type: str,
|
||||||
|
page_source: str,
|
||||||
|
old_data: Dict[str, Any],
|
||||||
|
new_data: Dict[str, Any],
|
||||||
|
change_reason: Optional[str] = None,
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""변경 이력 기록"""
|
||||||
|
|
||||||
|
# 변경된 필드들 찾기
|
||||||
|
changed_fields = []
|
||||||
|
for field, new_value in new_data.items():
|
||||||
|
old_value = old_data.get(field)
|
||||||
|
if old_value != new_value:
|
||||||
|
history_entry = IssueHistory(
|
||||||
|
issue_id=issue_id,
|
||||||
|
changed_by_id=changed_by_id,
|
||||||
|
change_type=change_type,
|
||||||
|
page_source=page_source,
|
||||||
|
field_name=field,
|
||||||
|
old_value=json.dumps(old_value) if old_value else None,
|
||||||
|
new_value=json.dumps(new_value) if new_value else None,
|
||||||
|
change_reason=change_reason,
|
||||||
|
session_id=session_id
|
||||||
|
)
|
||||||
|
db.add(history_entry)
|
||||||
|
changed_fields.append(field)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return changed_fields
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_issue_history(db: Session, issue_id: int, limit: int = 50):
|
||||||
|
"""이슈의 변경 이력 조회"""
|
||||||
|
return db.query(IssueHistory)\
|
||||||
|
.filter(IssueHistory.issue_id == issue_id)\
|
||||||
|
.order_by(IssueHistory.changed_at.desc())\
|
||||||
|
.limit(limit)\
|
||||||
|
.all()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### API 엔드포인트에서 히스토리 기록
|
||||||
|
```python
|
||||||
|
# routers/management.py
|
||||||
|
from services.history_service import HistoryService
|
||||||
|
|
||||||
|
@router.put("/{issue_id}")
|
||||||
|
async def update_issue(
|
||||||
|
issue_id: int,
|
||||||
|
update_request: ManagementUpdateRequest,
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
# 기존 이슈 데이터 백업
|
||||||
|
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||||
|
old_data = {
|
||||||
|
"final_description": issue.final_description,
|
||||||
|
"solution": issue.solution,
|
||||||
|
"responsible_department": issue.responsible_department,
|
||||||
|
"responsible_person": issue.responsible_person,
|
||||||
|
"expected_completion_date": issue.expected_completion_date.isoformat() if issue.expected_completion_date else None
|
||||||
|
}
|
||||||
|
|
||||||
|
# 업데이트 수행
|
||||||
|
update_data = update_request.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(issue, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(issue)
|
||||||
|
|
||||||
|
# 새로운 데이터
|
||||||
|
new_data = {
|
||||||
|
"final_description": issue.final_description,
|
||||||
|
"solution": issue.solution,
|
||||||
|
"responsible_department": issue.responsible_department,
|
||||||
|
"responsible_person": issue.responsible_person,
|
||||||
|
"expected_completion_date": issue.expected_completion_date.isoformat() if issue.expected_completion_date else None
|
||||||
|
}
|
||||||
|
|
||||||
|
# 히스토리 기록
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
HistoryService.log_change(
|
||||||
|
db=db,
|
||||||
|
issue_id=issue_id,
|
||||||
|
changed_by_id=current_user.id,
|
||||||
|
change_type="UPDATE",
|
||||||
|
page_source="MANAGEMENT",
|
||||||
|
old_data=old_data,
|
||||||
|
new_data=new_data,
|
||||||
|
session_id=session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "업데이트 완료", "changed_fields": list(update_data.keys())}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2️⃣ 프론트엔드 구현
|
||||||
|
|
||||||
|
#### 히스토리 조회 모달
|
||||||
|
```javascript
|
||||||
|
// 히스토리 조회 함수
|
||||||
|
async function showIssueHistory(issueId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/issues/${issueId}/history`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const history = await response.json();
|
||||||
|
openHistoryModal(issueId, history);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('히스토리 조회 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 히스토리 모달 생성
|
||||||
|
function openHistoryModal(issueId, history) {
|
||||||
|
const modalContent = `
|
||||||
|
<div class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center" id="historyModal">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-xl font-semibold">
|
||||||
|
<i class="fas fa-history text-blue-500 mr-2"></i>
|
||||||
|
변경 이력 - No.${issueId}
|
||||||
|
</h3>
|
||||||
|
<button onclick="closeHistoryModal()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
${history.map(entry => `
|
||||||
|
<div class="border-l-4 border-blue-400 bg-blue-50 p-4 rounded-r-lg">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="font-semibold text-blue-800">${entry.field_name}</span>
|
||||||
|
<span class="px-2 py-1 bg-blue-200 text-blue-800 text-xs rounded-full">${entry.page_source}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-500">${new Date(entry.changed_at).toLocaleString('ko-KR')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-red-600">이전:</span>
|
||||||
|
<p class="text-gray-700 bg-red-50 p-2 rounded mt-1">${entry.old_value || '없음'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-green-600">변경:</span>
|
||||||
|
<p class="text-gray-700 bg-green-50 p-2 rounded mt-1">${entry.new_value || '없음'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-sm text-gray-600">
|
||||||
|
<i class="fas fa-user mr-1"></i>변경자: ${entry.changed_by_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 히스토리 활용 방안
|
||||||
|
|
||||||
|
### 1️⃣ 관리자 대시보드
|
||||||
|
- 최근 변경 사항 요약
|
||||||
|
- 사용자별 활동 통계
|
||||||
|
- 페이지별 수정 빈도
|
||||||
|
|
||||||
|
### 2️⃣ 감사 보고서
|
||||||
|
- 특정 기간 동안의 모든 변경 사항
|
||||||
|
- 중요 필드 변경 알림
|
||||||
|
- 규정 준수 확인
|
||||||
|
|
||||||
|
### 3️⃣ 데이터 복구
|
||||||
|
- 잘못된 변경 사항 롤백
|
||||||
|
- 특정 시점으로 데이터 복원
|
||||||
|
- 변경 사항 비교 및 분석
|
||||||
|
|
||||||
|
## 🔒 보안 고려사항
|
||||||
|
|
||||||
|
### 1️⃣ 접근 권한
|
||||||
|
- 히스토리 조회 권한 분리
|
||||||
|
- 민감한 정보 마스킹
|
||||||
|
- 관리자만 전체 히스토리 접근
|
||||||
|
|
||||||
|
### 2️⃣ 데이터 보호
|
||||||
|
- 히스토리 데이터 암호화
|
||||||
|
- 개인정보 자동 삭제 정책
|
||||||
|
- 백업 및 아카이빙
|
||||||
|
|
||||||
|
## 🚀 구현 우선순위
|
||||||
|
|
||||||
|
### Phase 1 (필수)
|
||||||
|
1. 기본 히스토리 테이블 생성
|
||||||
|
2. 관리함 수정 이력 기록
|
||||||
|
3. 간단한 히스토리 조회 API
|
||||||
|
|
||||||
|
### Phase 2 (확장)
|
||||||
|
1. 수신함 처리 이력 기록
|
||||||
|
2. 히스토리 조회 UI 구현
|
||||||
|
3. 변경 사유 입력 기능
|
||||||
|
|
||||||
|
### Phase 3 (고급)
|
||||||
|
1. 데이터 복구 기능
|
||||||
|
2. 감사 보고서 생성
|
||||||
|
3. 실시간 변경 알림
|
||||||
|
|
||||||
|
## 💡 추가 아이디어
|
||||||
|
|
||||||
|
### 1️⃣ 변경 승인 워크플로우
|
||||||
|
- 중요한 변경사항은 승인 후 적용
|
||||||
|
- 변경 요청 → 검토 → 승인/반려
|
||||||
|
|
||||||
|
### 2️⃣ 자동 백업
|
||||||
|
- 중요 변경 전 자동 스냅샷
|
||||||
|
- 일정 주기별 전체 데이터 백업
|
||||||
|
|
||||||
|
### 3️⃣ 변경 영향도 분석
|
||||||
|
- 연관된 다른 이슈에 미치는 영향
|
||||||
|
- 변경으로 인한 통계 변화
|
||||||
|
|
||||||
|
이러한 히스토리 시스템을 통해 **투명하고 추적 가능한 이슈 관리**가 가능해집니다! 🎯
|
||||||
@@ -786,20 +786,23 @@
|
|||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
${isPendingCompletion ? `
|
${isPendingCompletion ? `
|
||||||
<!-- 완료 대기 상태 버튼들 -->
|
<!-- 완료 대기 상태 버튼들 -->
|
||||||
<button onclick="editIssue(${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="rejectCompletion(${issue.id})" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
|
<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>반려
|
<i class="fas fa-times mr-1"></i>반려
|
||||||
</button>
|
</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">
|
<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>확인
|
<i class="fas fa-check-circle mr-1"></i>최종확인
|
||||||
</button>
|
</button>
|
||||||
` : `
|
` : `
|
||||||
<!-- 일반 진행 중 상태 버튼들 -->
|
<!-- 일반 진행 중 상태 버튼들 -->
|
||||||
<button onclick="saveIssueChanges(${issue.id})" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
<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>저장
|
<i class="fas fa-save mr-1"></i>저장
|
||||||
</button>
|
</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="completeIssue(${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>완료처리
|
<i class="fas fa-check mr-1"></i>완료처리
|
||||||
</button>
|
</button>
|
||||||
@@ -1745,6 +1748,198 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 이슈 수정 모달 열기 (모든 진행 중 상태에서 사용)
|
||||||
|
function openIssueEditModal(issueId) {
|
||||||
|
const issue = issues.find(i => i.id === issueId);
|
||||||
|
if (!issue) return;
|
||||||
|
|
||||||
|
const project = projects.find(p => p.id === issue.project_id);
|
||||||
|
const isPendingCompletion = issue.completion_requested_at;
|
||||||
|
|
||||||
|
// 모달 내용 생성
|
||||||
|
const modalContent = `
|
||||||
|
<div class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center" id="issueEditModal">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl max-w-6xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- 모달 헤더 -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
<i class="fas fa-edit text-blue-500 mr-2"></i>
|
||||||
|
이슈 수정 - No.${issue.project_sequence_no || '-'}
|
||||||
|
</h3>
|
||||||
|
<button onclick="closeIssueEditModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 이슈 정보 -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
<!-- 왼쪽: 기본 정보 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-blue-50 p-4 rounded-lg">
|
||||||
|
<h4 class="font-semibold text-blue-800 mb-3">기본 정보</h4>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
|
||||||
|
<input type="text" value="${project ? project.project_name : '-'}" class="w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-lg text-sm" readonly>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">부적합명</label>
|
||||||
|
<input type="text" id="edit-issue-title-${issue.id}" value="${getIssueTitle(issue)}" 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">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">상세내용</label>
|
||||||
|
<textarea id="edit-issue-detail-${issue.id}" rows="4" 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 resize-none">${getIssueDetail(issue)}</textarea>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h4 class="font-semibold text-gray-800 mb-3">업로드 사진</h4>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
${issue.photo_path ? `<img src="${issue.photo_path}" class="w-20 h-20 object-cover rounded-lg cursor-pointer border-2 border-gray-200" onclick="openPhotoModal('${issue.photo_path}')" alt="업로드 사진 1">` : '<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center text-gray-400 text-xs">사진 없음</div>'}
|
||||||
|
${issue.photo_path2 ? `<img src="${issue.photo_path2}" class="w-20 h-20 object-cover rounded-lg cursor-pointer border-2 border-gray-200" onclick="openPhotoModal('${issue.photo_path2}')" alt="업로드 사진 2">` : '<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center text-gray-400 text-xs">사진 없음</div>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 오른쪽: 관리 정보 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-green-50 p-4 rounded-lg">
|
||||||
|
<h4 class="font-semibold text-green-800 mb-3">관리 정보</h4>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">해결방안</label>
|
||||||
|
<textarea id="edit-solution-${issue.id}" rows="3" 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.solution || ''}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">담당부서</label>
|
||||||
|
<select id="edit-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.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="edit-person-${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" placeholder="담당자 이름" value="${issue.responsible_person || ''}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
${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>
|
||||||
|
` : '<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>
|
||||||
|
<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 class="flex justify-end space-x-3">
|
||||||
|
<button onclick="closeIssueEditModal()" class="px-6 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 모달을 body에 추가
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 수정 모달 닫기
|
||||||
|
function closeIssueEditModal() {
|
||||||
|
const modal = document.getElementById('issueEditModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달에서 이슈 저장
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
alert('부적합명을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedDescription = title + (detail ? '\\n' + detail : '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(\`/api/management/\${issueId}\`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'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
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('이슈가 성공적으로 저장되었습니다.');
|
||||||
|
closeIssueEditModal();
|
||||||
|
loadManagementData(); // 페이지 새로고침
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(\`저장 실패: \${error.detail || '알 수 없는 오류'}\`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('저장 오류:', error);
|
||||||
|
alert('저장 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 완료 대기 상태 관련 함수들
|
// 완료 대기 상태 관련 함수들
|
||||||
function editIssue(issueId) {
|
function editIssue(issueId) {
|
||||||
// 수정 모드로 전환 (완료 대기 상태를 해제)
|
// 수정 모드로 전환 (완료 대기 상태를 해제)
|
||||||
|
|||||||
Reference in New Issue
Block a user