From 2fc7d4bc2cc5384719a222574f4ba3df442bde10 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Sat, 8 Nov 2025 11:56:09 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=99=84=EB=A3=8C=20=EB=B0=98?= =?UTF-8?q?=EB=A0=A4=20=ED=95=84=EB=93=9C=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend: completion_rejection_reason 등 전용 필드 추가 - 기존 management_comment에 섞여있던 완료 반려 내용 분리 - 현황판: 완료 반려 내역 별도 카드로 표시 - 관리함: 해결방안에 완료 반려 내용 제외하여 표시 - DB 마이그레이션: completion_rejected_at, completion_rejected_by_id, completion_rejection_reason 필드 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- backend/database/models.py | 25 +- backend/database/schemas.py | 11 +- backend/routers/issues.py | 122 ++- frontend/issues-dashboard.html | 1735 ++++++++++++++++++++++++++++++- frontend/issues-management.html | 126 ++- 5 files changed, 1937 insertions(+), 82 deletions(-) diff --git a/backend/database/models.py b/backend/database/models.py index 14c0c21..a7f1276 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -143,7 +143,12 @@ class Issue(Base): completion_requested_by_id = Column(Integer, ForeignKey("users.id")) # 완료 신청자 completion_photo_path = Column(String(500)) # 완료 사진 경로 completion_comment = Column(Text) # 완료 코멘트 - + + # 완료 반려 관련 필드들 + completion_rejected_at = Column(DateTime) # 완료 반려 시간 + completion_rejected_by_id = Column(Integer, ForeignKey("users.id")) # 완료 반려자 + completion_rejection_reason = Column(Text) # 완료 반려 사유 + # Relationships reporter = relationship("User", back_populates="issues", foreign_keys=[reporter_id]) reviewer = relationship("User", foreign_keys=[reviewed_by_id], overlaps="reviewed_issues") @@ -183,14 +188,28 @@ class DailyWork(Base): class ProjectDailyWork(Base): __tablename__ = "project_daily_works" - + id = Column(Integer, primary_key=True, index=True) date = Column(DateTime, nullable=False, index=True) project_id = Column(BigInteger, ForeignKey("projects.id"), nullable=False) hours = Column(Float, nullable=False) created_by_id = Column(Integer, ForeignKey("users.id")) created_at = Column(DateTime, default=get_kst_now) - + # Relationships project = relationship("Project") created_by = relationship("User") + +class DeletionLog(Base): + __tablename__ = "deletion_logs" + + id = Column(Integer, primary_key=True, index=True) + entity_type = Column(String(50), nullable=False) # 'issue', 'project', 'daily_work' 등 + entity_id = Column(Integer, nullable=False) # 삭제된 엔티티의 ID + entity_data = Column(JSONB, nullable=False) # 삭제된 데이터 전체 (JSON) + deleted_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) + deleted_at = Column(DateTime, default=get_kst_now, nullable=False) + reason = Column(Text) # 삭제 사유 (선택사항) + + # Relationships + deleted_by = relationship("User") diff --git a/backend/database/schemas.py b/backend/database/schemas.py index 650625a..aea4838 100644 --- a/backend/database/schemas.py +++ b/backend/database/schemas.py @@ -152,7 +152,12 @@ class Issue(IssueBase): completion_requested_by_id: Optional[int] = None # 완료 신청자 completion_photo_path: Optional[str] = None # 완료 사진 경로 completion_comment: Optional[str] = None # 완료 코멘트 - + + # 완료 반려 관련 필드들 + completion_rejected_at: Optional[datetime] = None # 완료 반려 시간 + completion_rejected_by_id: Optional[int] = None # 완료 반려자 + completion_rejection_reason: Optional[str] = None # 완료 반려 사유 + class Config: from_attributes = True @@ -200,6 +205,10 @@ class CompletionRequestRequest(BaseModel): completion_photo: str # 완료 사진 (Base64) completion_comment: Optional[str] = None # 완료 코멘트 +class CompletionRejectionRequest(BaseModel): + """완료 신청 반려 요청""" + rejection_reason: str # 반려 사유 + class ManagementUpdateRequest(BaseModel): """관리함에서 이슈 업데이트 요청""" final_description: Optional[str] = None diff --git a/backend/routers/issues.py b/backend/routers/issues.py index 7b3b571..a73e964 100644 --- a/backend/routers/issues.py +++ b/backend/routers/issues.py @@ -186,21 +186,66 @@ async def delete_issue( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): + from database.models import DeletionLog + import json + issue = db.query(Issue).filter(Issue.id == issue_id).first() if not issue: raise HTTPException(status_code=404, detail="Issue not found") - - # 권한 확인 (관리자만 삭제 가능) - if current_user.role != UserRole.admin: - raise HTTPException(status_code=403, detail="Only admin can delete issues") - + + # 권한 확인 (관리자 또는 본인이 등록한 경우 삭제 가능) + if current_user.role != UserRole.admin and issue.reporter_id != current_user.id: + raise HTTPException(status_code=403, detail="본인이 등록한 부적합만 삭제할 수 있습니다.") + + # 이 이슈를 중복 대상으로 참조하는 다른 이슈들의 참조 제거 + referencing_issues = db.query(Issue).filter(Issue.duplicate_of_issue_id == issue_id).all() + if referencing_issues: + print(f"DEBUG: {len(referencing_issues)}개의 이슈가 이 이슈를 중복 대상으로 참조하고 있습니다. 참조를 제거합니다.") + for ref_issue in referencing_issues: + ref_issue.duplicate_of_issue_id = None + db.flush() # 참조 제거를 먼저 커밋 + + # 삭제 로그 생성 (삭제 전 데이터 저장) + issue_data = { + "id": issue.id, + "category": issue.category.value if issue.category else None, + "description": issue.description, + "status": issue.status.value if issue.status else None, + "reporter_id": issue.reporter_id, + "project_id": issue.project_id, + "report_date": issue.report_date.isoformat() if issue.report_date else None, + "work_hours": issue.work_hours, + "detail_notes": issue.detail_notes, + "photo_path": issue.photo_path, + "photo_path2": issue.photo_path2, + "review_status": issue.review_status.value if issue.review_status else None, + "solution": issue.solution, + "responsible_department": issue.responsible_department.value if issue.responsible_department else None, + "responsible_person": issue.responsible_person, + "expected_completion_date": issue.expected_completion_date.isoformat() if issue.expected_completion_date else None, + "actual_completion_date": issue.actual_completion_date.isoformat() if issue.actual_completion_date else None, + } + + deletion_log = DeletionLog( + entity_type="issue", + entity_id=issue.id, + entity_data=issue_data, + deleted_by_id=current_user.id, + reason=f"사용자 {current_user.username}에 의해 삭제됨" + ) + db.add(deletion_log) + # 이미지 파일 삭제 if issue.photo_path: delete_file(issue.photo_path) - + if issue.photo_path2: + delete_file(issue.photo_path2) + if issue.completion_photo_path: + delete_file(issue.completion_photo_path) + db.delete(issue) db.commit() - return {"detail": "Issue deleted successfully"} + return {"detail": "Issue deleted successfully", "logged": True} @router.get("/stats/summary") async def get_issue_stats( @@ -353,3 +398,66 @@ async def request_completion( except: pass raise HTTPException(status_code=500, detail=f"완료 신청 처리 중 오류가 발생했습니다: {str(e)}") + +@router.post("/{issue_id}/reject-completion") +async def reject_completion_request( + issue_id: int, + request: schemas.CompletionRejectionRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 완료 신청 반려 - 관리자가 완료 신청을 반려 + """ + # 이슈 조회 + issue = db.query(Issue).filter(Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.") + + # 완료 신청이 있는지 확인 + if not issue.completion_requested_at: + raise HTTPException(status_code=400, detail="완료 신청이 없는 부적합입니다.") + + # 권한 확인 (관리자 또는 관리함 접근 권한이 있는 사용자) + if current_user.role != UserRole.admin and not check_page_access(current_user.id, 'issues_management', db): + raise HTTPException(status_code=403, detail="완료 반려 권한이 없습니다.") + + try: + print(f"DEBUG: 완료 반려 시작 - Issue ID: {issue_id}, User: {current_user.username}") + + # 완료 사진 파일 삭제 + if issue.completion_photo_path: + try: + delete_file(issue.completion_photo_path) + print(f"DEBUG: 완료 사진 삭제 완료") + except Exception as e: + print(f"WARNING: 완료 사진 삭제 실패 - {str(e)}") + + # 완료 신청 정보 초기화 + issue.completion_requested_at = None + issue.completion_requested_by_id = None + issue.completion_photo_path = None + issue.completion_comment = None + + # 완료 반려 정보 기록 (전용 필드 사용) + issue.completion_rejected_at = datetime.now() + issue.completion_rejected_by_id = current_user.id + issue.completion_rejection_reason = request.rejection_reason + + # 상태는 in_progress로 유지 + issue.review_status = ReviewStatus.in_progress + + db.commit() + db.refresh(issue) + print(f"DEBUG: 완료 반려 처리 완료") + + return { + "message": "완료 신청이 반려되었습니다.", + "issue_id": issue.id, + "rejection_reason": request.rejection_reason + } + + except Exception as e: + print(f"ERROR: 완료 반려 처리 오류 - {str(e)}") + db.rollback() + raise HTTPException(status_code=500, detail=f"완료 반려 처리 중 오류가 발생했습니다: {str(e)}") diff --git a/frontend/issues-dashboard.html b/frontend/issues-dashboard.html index 1e57e24..a0b36ea 100644 --- a/frontend/issues-dashboard.html +++ b/frontend/issues-dashboard.html @@ -509,7 +509,7 @@ const getCategoryText = (category) => { const categoryMap = { 'material_missing': '자재 누락', - 'design_error': '설계 오류', + 'design_error': '설계 오류', 'incoming_defect': '반입 불량', 'inspection_miss': '검사 누락', 'etc': '기타' @@ -517,6 +517,292 @@ return category ? categoryMap[category] || category : '-'; }; + // 완료 반려 내용 포맷팅 + const formatRejectionContent = (issue) => { + // 1. 새 필드에서 확인 + if (issue.completion_rejection_reason) { + const rejectedAt = issue.completion_rejected_at + ? new Date(issue.completion_rejected_at).toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + : ''; + + return rejectedAt + ? `[${rejectedAt}] ${issue.completion_rejection_reason}` + : issue.completion_rejection_reason; + } + + // 2. 기존 데이터에서 패턴 추출 (마이그레이션 전 데이터용) + if (issue.management_comment) { + const rejectionPattern = /\[완료 반려[^\]]*\][^\n]*/g; + const rejections = issue.management_comment.match(rejectionPattern); + return rejections ? rejections.join('\n') : ''; + } + + return ''; + }; + + // solution 파싱 및 카드 형식으로 표시 + const parseSolutionOpinions = (solution, issue) => { + let html = ''; + + // 1. 수신함/관리함 내용 표시 - 항상 표시 + let rawManagementContent = issue.management_comment || issue.final_description || ''; + + // 기존 데이터에서 완료 반려 패턴 제거 (마이그레이션용) + rawManagementContent = rawManagementContent.replace(/\[완료 반려[^\]]*\][^\n]*\n*/g, '').trim(); + + // 기본 텍스트들 필터링 (카테고리 이름, "상세 내용 없음" 등) + const defaultTexts = [ + '중복작업 신고용', + '상세 내용 없음', + '자재 누락', + '설계 오류', + '반입 불량', + '검사 누락', + '기타', + '부적합명', + '상세내용', + '상세 내용' + ]; + + // 줄바꿈으로 나눠서 기본 텍스트가 아닌 실제 내용만 추출 + const filteredLines = rawManagementContent.split('\n').filter(line => { + const trimmed = line.trim(); + // 빈 줄이거나 기본 텍스트인 경우 제외 + if (!trimmed) return false; + // 기본 텍스트와 정확히 일치하면 제외 + if (defaultTexts.includes(trimmed)) return false; + return true; + }); + + const managementContent = filteredLines.join('\n').trim(); + + const displayContent = managementContent ? managementContent : '확정된 해결 방안 없음'; + const contentStyle = managementContent ? 'text-red-700' : 'text-red-400 italic'; + + html += ` +
+
+ ${displayContent} +
+
+ `; + + // 2. 해결 방안 의견들 표시 + if (!solution || solution.trim() === '') { + return html; + } + + // 구분선으로 의견들을 분리 + const opinions = solution.split(/─{30,}/); + + html += opinions.map((opinion, opinionIndex) => { + const trimmed = opinion.trim(); + if (!trimmed) return ''; + + // [작성자] (날짜시간) 패턴 매칭 + const headerMatch = trimmed.match(/^\[([^\]]+)\]\s*\(([^)]+)\)/); + + if (headerMatch) { + const author = headerMatch[1]; + const datetime = headerMatch[2]; + + // 댓글과 본문 분리 (들여쓰기로 시작하는 줄은 댓글/대댓글) + const lines = trimmed.substring(headerMatch[0].length).trim().split('\n'); + let mainContent = ''; + let comments = []; + let currentCommentIndex = -1; + + for (const line of lines) { + // 댓글 (└ 기호) + if (line.match(/^\s*└/)) { + const commentMatch = line.match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/); + if (commentMatch) { + comments.push({ + author: commentMatch[1], + datetime: commentMatch[2], + content: commentMatch[3], + replies: [] + }); + currentCommentIndex = comments.length - 1; + } + } + // 대댓글 (↳ 기호) + else if (line.match(/^\s*↳/)) { + const replyMatch = line.match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/); + if (replyMatch && currentCommentIndex >= 0) { + comments[currentCommentIndex].replies.push({ + author: replyMatch[1], + datetime: replyMatch[2], + content: replyMatch[3] + }); + } + } else { + mainContent += (mainContent ? '\n' : '') + line; + } + } + + // 색상 스킴 (최신 의견일수록 진한 색) + const colorSchemes = [ + 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-300', + 'bg-gradient-to-r from-blue-50 to-cyan-50 border-blue-300', + 'bg-gradient-to-r from-purple-50 to-pink-50 border-purple-300', + 'bg-gradient-to-r from-orange-50 to-yellow-50 border-orange-300', + 'bg-gradient-to-r from-indigo-50 to-violet-50 border-indigo-300' + ]; + const colorScheme = colorSchemes[opinionIndex % colorSchemes.length]; + + // 본인 의견인지 확인 + const isOwnOpinion = currentUser && (author === currentUser.full_name || author === currentUser.username); + + return ` +
+
+
+
+ ${author.charAt(0)} +
+
+ ${author} +
+ + ${datetime} +
+
+
+
+ + ${isOwnOpinion ? ` + + + ` : ''} +
+
+
${mainContent}
+ + ${comments.length > 0 ? ` +
+ + +
+ ` : ''} +
+ `; + } else { + // 형식이 맞지 않는 경우 그냥 텍스트로 표시 + return ` +
+
${trimmed}
+
+ `; + } + }).join(''); + + return html; + }; + // 날짜 포맷팅 const formatKSTDate = (dateStr) => { if (!dateStr) return '-'; @@ -621,13 +907,13 @@
- +
상세 내용
-
+
${getIssueDetail(issue)}
@@ -635,78 +921,117 @@
- - 해결 방안 + + 신고자 & 담당 & 마감
-
-
- ${issue.solution || '미정'} +
+
+
+ + 신고자 +
+
${issue.reporter?.full_name || issue.reporter?.username || '-'}
+
+
+
+ + 담당자 +
+
${issue.responsible_person || '-'}
+
+
+
+ + 마감 +
+
${formatKSTDate(issue.expected_completion_date)}
+ ${currentStatus === 'in_progress' || currentStatus === 'urgent' || currentStatus === 'overdue' ? ` +
+ +
+ ` : ''}
- + +
+
+
+ + 해결 방안 +
+ +
+
+ ${parseSolutionOpinions(issue.solution, issue)} +
+
이미지
-
+
${issue.photo_path ? ` -
+
부적합 사진 1 -
+
` : ` -
- +
+
`} ${issue.photo_path2 ? ` -
+
부적합 사진 2 -
+
` : ` -
- +
+
`}
-
-
-
- - 일정 & 담당 -
-
-
-
- 담당자 - + + + ${(() => { + const rejection = formatRejectionContent(issue); + if (!rejection) return ''; + + return ` +
+
+
+ +
+ 완료 반려 내역 +
+
${rejection}
-
${issue.responsible_person || '-'}
+ `; + })()} + + ${currentStatus === 'pending_completion' ? ` +
+
-
-
- 마감시간 - -
-
${formatKSTDate(issue.expected_completion_date)}
- ${currentStatus === 'in_progress' || currentStatus === 'urgent' || currentStatus === 'overdue' ? ` - - ` : ''} -
-
+ ` : ''}
@@ -749,6 +1074,14 @@ document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { closePhotoModal(); + closeRejectionModal(); + closeOpinionModal(); + closeCompletionRequestModal(); + closeCommentModal(); + closeEditOpinionModal(); + closeReplyModal(); + closeEditCommentModal(); + closeEditReplyModal(); } }); @@ -857,7 +1190,7 @@ function openCompletionRequestModal(issueId) { selectedCompletionIssueId = issueId; document.getElementById('completionRequestModal').classList.remove('hidden'); - + // 폼 초기화 document.getElementById('completionRequestForm').reset(); document.getElementById('photoPreview').classList.add('hidden'); @@ -871,6 +1204,988 @@ document.getElementById('completionRequestModal').classList.add('hidden'); } + // 완료 신청 반려 관련 함수들 + let selectedRejectionIssueId = null; + + function openRejectionModal(issueId) { + selectedRejectionIssueId = issueId; + document.getElementById('rejectionModal').classList.remove('hidden'); + document.getElementById('rejectionReason').value = ''; + document.getElementById('rejectionReason').focus(); + } + + function closeRejectionModal() { + selectedRejectionIssueId = null; + document.getElementById('rejectionModal').classList.add('hidden'); + } + + async function submitRejection(event) { + event.preventDefault(); + + if (!selectedRejectionIssueId) { + alert('이슈 ID가 없습니다.'); + return; + } + + const rejectionReason = document.getElementById('rejectionReason').value.trim(); + if (!rejectionReason) { + alert('반려 사유를 입력해주세요.'); + return; + } + + try { + const response = await fetch(`/api/issues/${selectedRejectionIssueId}/reject-completion`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + rejection_reason: rejectionReason + }) + }); + + if (response.ok) { + alert('완료 신청이 반려되었습니다.'); + closeRejectionModal(); + // 현황판 새로고침 + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`반려 처리 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('반려 처리 오류:', error); + alert('반려 처리 중 오류가 발생했습니다: ' + error.message); + } + } + + // 댓글 토글 기능 + function toggleComments(issueId, opinionIndex) { + const commentsDiv = document.getElementById(`comments-${issueId}-${opinionIndex}`); + const chevron = document.getElementById(`comment-chevron-${issueId}-${opinionIndex}`); + + if (commentsDiv.classList.contains('hidden')) { + commentsDiv.classList.remove('hidden'); + chevron.classList.add('fa-rotate-180'); + } else { + commentsDiv.classList.add('hidden'); + chevron.classList.remove('fa-rotate-180'); + } + } + + // 의견 제시 모달 관련 + let selectedOpinionIssueId = null; + + function openOpinionModal(issueId) { + selectedOpinionIssueId = issueId; + document.getElementById('opinionModal').classList.remove('hidden'); + document.getElementById('opinionText').value = ''; + document.getElementById('opinionText').focus(); + } + + function closeOpinionModal() { + selectedOpinionIssueId = null; + document.getElementById('opinionModal').classList.add('hidden'); + } + + // 댓글 추가 모달 관련 + let selectedCommentIssueId = null; + let selectedCommentOpinionIndex = null; + + function openCommentModal(issueId, opinionIndex) { + selectedCommentIssueId = issueId; + selectedCommentOpinionIndex = opinionIndex; + document.getElementById('commentModal').classList.remove('hidden'); + document.getElementById('commentText').value = ''; + document.getElementById('commentText').focus(); + } + + function closeCommentModal() { + selectedCommentIssueId = null; + selectedCommentOpinionIndex = null; + document.getElementById('commentModal').classList.add('hidden'); + } + + // 로그 기록 함수 + async function logModification(issueId, action, details) { + try { + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (issueResponse.ok) { + const issue = await issueResponse.json(); + const modificationLog = issue.modification_log || []; + + modificationLog.push({ + timestamp: new Date().toISOString(), + user: currentUser.full_name || currentUser.username, + user_id: currentUser.id, + action: action, + details: details + }); + + // modification_log 업데이트는 solution 업데이트와 함께 처리됨 + return modificationLog; + } + } catch (error) { + console.error('로그 기록 오류:', error); + } + return null; + } + + async function submitComment(event) { + event.preventDefault(); + + if (!selectedCommentIssueId || selectedCommentOpinionIndex === null) { + alert('대상 의견이 선택되지 않았습니다.'); + return; + } + + const commentText = document.getElementById('commentText').value.trim(); + if (!commentText) { + alert('댓글을 입력해주세요.'); + return; + } + + try { + const issueResponse = await fetch(`/api/issues/${selectedCommentIssueId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!issueResponse.ok) { + throw new Error('이슈 정보를 가져올 수 없습니다.'); + } + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + + if (selectedCommentOpinionIndex >= opinions.length) { + throw new Error('잘못된 의견 인덱스입니다.'); + } + + const now = new Date(); + const dateStr = now.toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + const newComment = ` └ [${currentUser.full_name || currentUser.username}] (${dateStr}): ${commentText}`; + + opinions[selectedCommentOpinionIndex] = opinions[selectedCommentOpinionIndex].trim() + '\n' + newComment; + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${selectedCommentIssueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + solution: updatedSolution + }) + }); + + if (response.ok) { + await logModification(selectedCommentIssueId, 'comment_added', { + opinion_index: selectedCommentOpinionIndex, + comment: commentText + }); + alert('댓글이 추가되었습니다.'); + closeCommentModal(); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`댓글 추가 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('댓글 추가 오류:', error); + alert('댓글 추가 중 오류가 발생했습니다: ' + error.message); + } + } + + // 답글(대댓글) 관련 + let selectedReplyIssueId = null; + let selectedReplyOpinionIndex = null; + let selectedReplyCommentIndex = null; + + function openReplyModal(issueId, opinionIndex, commentIndex) { + selectedReplyIssueId = issueId; + selectedReplyOpinionIndex = opinionIndex; + selectedReplyCommentIndex = commentIndex; + document.getElementById('replyModal').classList.remove('hidden'); + document.getElementById('replyText').value = ''; + document.getElementById('replyText').focus(); + } + + function closeReplyModal() { + selectedReplyIssueId = null; + selectedReplyOpinionIndex = null; + selectedReplyCommentIndex = null; + document.getElementById('replyModal').classList.add('hidden'); + } + + async function submitReply(event) { + event.preventDefault(); + + if (!selectedReplyIssueId || selectedReplyOpinionIndex === null || selectedReplyCommentIndex === null) { + alert('대상 댓글이 선택되지 않았습니다.'); + return; + } + + const replyText = document.getElementById('replyText').value.trim(); + if (!replyText) { + alert('답글을 입력해주세요.'); + return; + } + + try { + const issueResponse = await fetch(`/api/issues/${selectedReplyIssueId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!issueResponse.ok) { + throw new Error('이슈 정보를 가져올 수 없습니다.'); + } + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + + if (selectedReplyOpinionIndex >= opinions.length) { + throw new Error('잘못된 의견 인덱스입니다.'); + } + + const now = new Date(); + const dateStr = now.toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + + // 의견을 라인별로 분리하여 해당 댓글 뒤에 답글 추가 + const lines = opinions[selectedReplyOpinionIndex].trim().split('\n'); + const newReply = ` ↳ [${currentUser.full_name || currentUser.username}] (${dateStr}): ${replyText}`; + + let commentCount = -1; + let insertIndex = -1; + + for (let i = 0; i < lines.length; i++) { + if (lines[i].match(/^\s*└/)) { + commentCount++; + if (commentCount === selectedReplyCommentIndex) { + // 이 댓글의 마지막 대댓글 찾기 또는 댓글 바로 다음 + insertIndex = i + 1; + while (insertIndex < lines.length && lines[insertIndex].match(/^\s*↳/)) { + insertIndex++; + } + break; + } + } + } + + if (insertIndex >= 0) { + lines.splice(insertIndex, 0, newReply); + opinions[selectedReplyOpinionIndex] = lines.join('\n'); + } + + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${selectedReplyIssueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + solution: updatedSolution + }) + }); + + if (response.ok) { + await logModification(selectedReplyIssueId, 'reply_added', { + opinion_index: selectedReplyOpinionIndex, + comment_index: selectedReplyCommentIndex, + reply: replyText + }); + alert('답글이 추가되었습니다.'); + closeReplyModal(); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`답글 추가 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('답글 추가 오류:', error); + alert('답글 추가 중 오류가 발생했습니다: ' + error.message); + } + } + + // 의견 수정 관련 + let selectedEditIssueId = null; + let selectedEditOpinionIndex = null; + + async function editOpinion(issueId, opinionIndex) { + selectedEditIssueId = issueId; + selectedEditOpinionIndex = opinionIndex; + + try { + // 현재 이슈 정보 가져오기 + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!issueResponse.ok) { + throw new Error('이슈 정보를 가져올 수 없습니다.'); + } + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + + if (opinionIndex >= opinions.length) { + throw new Error('잘못된 의견 인덱스입니다.'); + } + + // 의견에서 헤더와 본문 분리 + const opinion = opinions[opinionIndex].trim(); + const headerMatch = opinion.match(/^\[([^\]]+)\]\s*\(([^)]+)\)/); + + if (headerMatch) { + const lines = opinion.substring(headerMatch[0].length).trim().split('\n'); + let mainContent = ''; + + for (const line of lines) { + if (!line.match(/^\s*[└├]/)) { + mainContent += (mainContent ? '\n' : '') + line; + } + } + + document.getElementById('editOpinionText').value = mainContent; + document.getElementById('editOpinionModal').classList.remove('hidden'); + document.getElementById('editOpinionText').focus(); + } + } catch (error) { + console.error('의견 수정 준비 오류:', error); + alert('의견을 불러오는 중 오류가 발생했습니다: ' + error.message); + } + } + + function closeEditOpinionModal() { + selectedEditIssueId = null; + selectedEditOpinionIndex = null; + document.getElementById('editOpinionModal').classList.add('hidden'); + } + + async function submitEditOpinion(event) { + event.preventDefault(); + + if (!selectedEditIssueId || selectedEditOpinionIndex === null) { + alert('대상 의견이 선택되지 않았습니다.'); + return; + } + + const newText = document.getElementById('editOpinionText').value.trim(); + if (!newText) { + alert('의견 내용을 입력해주세요.'); + return; + } + + try { + const issueResponse = await fetch(`/api/issues/${selectedEditIssueId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!issueResponse.ok) { + throw new Error('이슈 정보를 가져올 수 없습니다.'); + } + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + + if (selectedEditOpinionIndex >= opinions.length) { + throw new Error('잘못된 의견 인덱스입니다.'); + } + + // 기존 헤더와 댓글 유지, 본문만 변경 + const opinion = opinions[selectedEditOpinionIndex].trim(); + const headerMatch = opinion.match(/^\[([^\]]+)\]\s*\(([^)]+)\)/); + + if (headerMatch) { + const lines = opinion.substring(headerMatch[0].length).trim().split('\n'); + let comments = []; + + for (const line of lines) { + if (line.match(/^\s*[└├]/)) { + comments.push(line); + } + } + + // 새 의견 = 헤더 + 새 본문 + 기존 댓글들 + opinions[selectedEditOpinionIndex] = headerMatch[0] + '\n' + newText + + (comments.length > 0 ? '\n' + comments.join('\n') : ''); + } + + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${selectedEditIssueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + solution: updatedSolution + }) + }); + + if (response.ok) { + alert('의견이 수정되었습니다.'); + closeEditOpinionModal(); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`의견 수정 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('의견 수정 오류:', error); + alert('의견 수정 중 오류가 발생했습니다: ' + error.message); + } + } + + // 의견 삭제 + async function deleteOpinion(issueId, opinionIndex) { + if (!confirm('이 의견을 삭제하시겠습니까? (댓글도 함께 삭제됩니다)')) { + return; + } + + try { + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!issueResponse.ok) { + throw new Error('이슈 정보를 가져올 수 없습니다.'); + } + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + + if (opinionIndex >= opinions.length) { + throw new Error('잘못된 의견 인덱스입니다.'); + } + + const deletedOpinion = opinions[opinionIndex]; + opinions.splice(opinionIndex, 1); + + const updatedSolution = opinions.length > 0 ? opinions.join('\n' + '─'.repeat(50) + '\n') : ''; + + const response = await fetch(`/api/issues/${issueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + solution: updatedSolution || null + }) + }); + + if (response.ok) { + await logModification(issueId, 'opinion_deleted', { + opinion_index: opinionIndex, + deleted_content: deletedOpinion + }); + alert('의견이 삭제되었습니다.'); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`의견 삭제 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('의견 삭제 오류:', error); + alert('의견 삭제 중 오류가 발생했습니다: ' + error.message); + } + } + + // 댓글 수정 + let selectedEditCommentIssueId = null; + let selectedEditCommentOpinionIndex = null; + let selectedEditCommentIndex = null; + + async function editComment(issueId, opinionIndex, commentIndex) { + selectedEditCommentIssueId = issueId; + selectedEditCommentOpinionIndex = opinionIndex; + selectedEditCommentIndex = commentIndex; + + try { + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + const lines = opinions[opinionIndex].trim().split('\n'); + + let commentCount = -1; + for (const line of lines) { + if (line.match(/^\s*└/)) { + commentCount++; + if (commentCount === commentIndex) { + const match = line.match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/); + if (match) { + document.getElementById('editCommentText').value = match[3]; + document.getElementById('editCommentModal').classList.remove('hidden'); + document.getElementById('editCommentText').focus(); + } + break; + } + } + } + } catch (error) { + console.error('댓글 수정 준비 오류:', error); + alert('댓글을 불러오는 중 오류가 발생했습니다: ' + error.message); + } + } + + function closeEditCommentModal() { + selectedEditCommentIssueId = null; + selectedEditCommentOpinionIndex = null; + selectedEditCommentIndex = null; + document.getElementById('editCommentModal').classList.add('hidden'); + } + + async function submitEditComment(event) { + event.preventDefault(); + + const newText = document.getElementById('editCommentText').value.trim(); + if (!newText) { + alert('댓글 내용을 입력해주세요.'); + return; + } + + try { + const issueResponse = await fetch(`/api/issues/${selectedEditCommentIssueId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + const lines = opinions[selectedEditCommentOpinionIndex].trim().split('\n'); + + let commentCount = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].match(/^\s*└/)) { + commentCount++; + if (commentCount === selectedEditCommentIndex) { + const match = lines[i].match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):/); + if (match) { + lines[i] = ` └ [${match[1]}] (${match[2]}): ${newText}`; + } + break; + } + } + } + + opinions[selectedEditCommentOpinionIndex] = lines.join('\n'); + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${selectedEditCommentIssueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ solution: updatedSolution }) + }); + + if (response.ok) { + await logModification(selectedEditCommentIssueId, 'comment_edited', { + opinion_index: selectedEditCommentOpinionIndex, + comment_index: selectedEditCommentIndex, + new_content: newText + }); + alert('댓글이 수정되었습니다.'); + closeEditCommentModal(); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`댓글 수정 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('댓글 수정 오류:', error); + alert('댓글 수정 중 오류가 발생했습니다: ' + error.message); + } + } + + // 댓글 삭제 + async function deleteComment(issueId, opinionIndex, commentIndex) { + if (!confirm('이 댓글을 삭제하시겠습니까? (답글도 함께 삭제됩니다)')) { + return; + } + + try { + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + const lines = opinions[opinionIndex].trim().split('\n'); + + let commentCount = -1; + let deleteStart = -1; + let deleteEnd = -1; + + for (let i = 0; i < lines.length; i++) { + if (lines[i].match(/^\s*└/)) { + commentCount++; + if (commentCount === commentIndex) { + deleteStart = i; + deleteEnd = i + 1; + // 이 댓글의 답글들도 찾기 + while (deleteEnd < lines.length && lines[deleteEnd].match(/^\s*↳/)) { + deleteEnd++; + } + break; + } + } + } + + if (deleteStart >= 0) { + const deletedContent = lines.slice(deleteStart, deleteEnd).join('\n'); + lines.splice(deleteStart, deleteEnd - deleteStart); + opinions[opinionIndex] = lines.join('\n'); + } + + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${issueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ solution: updatedSolution }) + }); + + if (response.ok) { + await logModification(issueId, 'comment_deleted', { + opinion_index: opinionIndex, + comment_index: commentIndex + }); + alert('댓글이 삭제되었습니다.'); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`댓글 삭제 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('댓글 삭제 오류:', error); + alert('댓글 삭제 중 오류가 발생했습니다: ' + error.message); + } + } + + // 대댓글(답글) 수정 + let selectedEditReplyIssueId = null; + let selectedEditReplyOpinionIndex = null; + let selectedEditReplyCommentIndex = null; + let selectedEditReplyIndex = null; + + async function editReply(issueId, opinionIndex, commentIndex, replyIndex) { + selectedEditReplyIssueId = issueId; + selectedEditReplyOpinionIndex = opinionIndex; + selectedEditReplyCommentIndex = commentIndex; + selectedEditReplyIndex = replyIndex; + + try { + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + const lines = opinions[opinionIndex].trim().split('\n'); + + let commentCount = -1; + let replyCount = -1; + + for (const line of lines) { + if (line.match(/^\s*└/)) { + commentCount++; + replyCount = -1; + } else if (line.match(/^\s*↳/) && commentCount === commentIndex) { + replyCount++; + if (replyCount === replyIndex) { + const match = line.match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/); + if (match) { + document.getElementById('editReplyText').value = match[3]; + document.getElementById('editReplyModal').classList.remove('hidden'); + document.getElementById('editReplyText').focus(); + } + break; + } + } + } + } catch (error) { + console.error('답글 수정 준비 오류:', error); + alert('답글을 불러오는 중 오류가 발생했습니다: ' + error.message); + } + } + + function closeEditReplyModal() { + selectedEditReplyIssueId = null; + selectedEditReplyOpinionIndex = null; + selectedEditReplyCommentIndex = null; + selectedEditReplyIndex = null; + document.getElementById('editReplyModal').classList.add('hidden'); + } + + async function submitEditReply(event) { + event.preventDefault(); + + const newText = document.getElementById('editReplyText').value.trim(); + if (!newText) { + alert('답글 내용을 입력해주세요.'); + return; + } + + try { + const issueResponse = await fetch(`/api/issues/${selectedEditReplyIssueId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + const lines = opinions[selectedEditReplyOpinionIndex].trim().split('\n'); + + let commentCount = -1; + let replyCount = -1; + + for (let i = 0; i < lines.length; i++) { + if (lines[i].match(/^\s*└/)) { + commentCount++; + replyCount = -1; + } else if (lines[i].match(/^\s*↳/) && commentCount === selectedEditReplyCommentIndex) { + replyCount++; + if (replyCount === selectedEditReplyIndex) { + const match = lines[i].match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):/); + if (match) { + lines[i] = ` ↳ [${match[1]}] (${match[2]}): ${newText}`; + } + break; + } + } + } + + opinions[selectedEditReplyOpinionIndex] = lines.join('\n'); + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${selectedEditReplyIssueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ solution: updatedSolution }) + }); + + if (response.ok) { + await logModification(selectedEditReplyIssueId, 'reply_edited', { + opinion_index: selectedEditReplyOpinionIndex, + comment_index: selectedEditReplyCommentIndex, + reply_index: selectedEditReplyIndex, + new_content: newText + }); + alert('답글이 수정되었습니다.'); + closeEditReplyModal(); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`답글 수정 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('답글 수정 오류:', error); + alert('답글 수정 중 오류가 발생했습니다: ' + error.message); + } + } + + // 대댓글(답글) 삭제 + async function deleteReply(issueId, opinionIndex, commentIndex, replyIndex) { + if (!confirm('이 답글을 삭제하시겠습니까?')) { + return; + } + + try { + const issueResponse = await fetch(`/api/issues/${issueId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.'); + + const issue = await issueResponse.json(); + const opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; + const lines = opinions[opinionIndex].trim().split('\n'); + + let commentCount = -1; + let replyCount = -1; + let deleteIndex = -1; + + for (let i = 0; i < lines.length; i++) { + if (lines[i].match(/^\s*└/)) { + commentCount++; + replyCount = -1; + } else if (lines[i].match(/^\s*↳/) && commentCount === commentIndex) { + replyCount++; + if (replyCount === replyIndex) { + deleteIndex = i; + break; + } + } + } + + if (deleteIndex >= 0) { + lines.splice(deleteIndex, 1); + opinions[opinionIndex] = lines.join('\n'); + } + + const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n'); + + const response = await fetch(`/api/issues/${issueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ solution: updatedSolution }) + }); + + if (response.ok) { + await logModification(issueId, 'reply_deleted', { + opinion_index: opinionIndex, + comment_index: commentIndex, + reply_index: replyIndex + }); + alert('답글이 삭제되었습니다.'); + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`답글 삭제 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('답글 삭제 오류:', error); + alert('답글 삭제 중 오류가 발생했습니다: ' + error.message); + } + } + + async function submitOpinion(event) { + event.preventDefault(); + + if (!selectedOpinionIssueId) { + alert('이슈 ID가 없습니다.'); + return; + } + + const opinionText = document.getElementById('opinionText').value.trim(); + if (!opinionText) { + alert('의견을 입력해주세요.'); + return; + } + + try { + // 현재 이슈 정보 가져오기 + const issueResponse = await fetch(`/api/issues/${selectedOpinionIssueId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!issueResponse.ok) { + throw new Error('이슈 정보를 가져올 수 없습니다.'); + } + + const issue = await issueResponse.json(); + + // 새 의견 형식: [작성자] (날짜시간)\n내용 + const now = new Date(); + const dateStr = now.toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + const newOpinion = `[${currentUser.full_name || currentUser.username}] (${dateStr})\n${opinionText}`; + + // 기존 solution에 추가 (최신이 위로) + const updatedSolution = issue.solution + ? `${newOpinion}\n${'─'.repeat(50)}\n${issue.solution}` + : newOpinion; + + // 백엔드 업데이트 + const response = await fetch(`/api/issues/${selectedOpinionIssueId}/management`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + solution: updatedSolution + }) + }); + + if (response.ok) { + alert('의견이 추가되었습니다.'); + closeOpinionModal(); + // 페이지 새로고침 + await initializeDashboard(); + } else { + const error = await response.json(); + alert(`의견 추가 실패: ${error.detail || '알 수 없는 오류'}`); + } + } catch (error) { + console.error('의견 추가 오류:', error); + alert('의견 추가 중 오류가 발생했습니다: ' + error.message); + } + } + function handleCompletionPhotoUpload(event) { const file = event.target.files[0]; if (!file) return; @@ -956,6 +2271,332 @@ document.body.appendChild(script); + + + + + + + + + + + + + + + + + + + + +