/** * m-dashboard.js — 현황판 모바일 페이지 로직 */ var currentUser = null; var allIssues = []; var projects = []; var filteredIssues = []; // 모달/시트 상태 var selectedOpinionIssueId = null; var selectedCommentIssueId = null; var selectedCommentOpinionIndex = null; var selectedReplyIssueId = null; var selectedReplyOpinionIndex = null; var selectedReplyCommentIndex = null; var selectedCompletionIssueId = null; var selectedRejectionIssueId = null; var completionPhotoBase64 = null; // 수정 상태 var editMode = null; // 'opinion', 'comment', 'reply' var editIssueId = null; var editOpinionIndex = null; var editCommentIndex = null; var editReplyIndex = null; // ===== 초기화 ===== async function initialize() { currentUser = await mCheckAuth(); if (!currentUser) return; await Promise.all([loadProjects(), loadIssues()]); updateStatistics(); renderIssues(); renderBottomNav('dashboard'); hideLoading(); } async function loadProjects() { try { var resp = await fetch(API_BASE_URL + '/projects/', { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } }); if (resp.ok) { projects = await resp.json(); var sel = document.getElementById('projectFilter'); sel.innerHTML = ''; projects.forEach(function (p) { sel.innerHTML += ''; }); } } catch (e) { console.error('프로젝트 로드 실패:', e); } } async function loadIssues() { try { var resp = await fetch(API_BASE_URL + '/issues/admin/all', { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } }); if (resp.ok) { var data = await resp.json(); allIssues = data.filter(function (i) { return i.review_status === 'in_progress'; }); // 프로젝트별 순번 재할당 allIssues.sort(function (a, b) { return new Date(a.reviewed_at) - new Date(b.reviewed_at); }); var groups = {}; allIssues.forEach(function (issue) { if (!groups[issue.project_id]) groups[issue.project_id] = []; groups[issue.project_id].push(issue); }); Object.keys(groups).forEach(function (pid) { groups[pid].forEach(function (issue, idx) { issue.project_sequence_no = idx + 1; }); }); filteredIssues = allIssues.slice(); } } catch (e) { console.error('이슈 로드 실패:', e); } } function updateStatistics() { var today = new Date().toDateString(); var todayIssues = allIssues.filter(function (i) { return i.reviewed_at && new Date(i.reviewed_at).toDateString() === today; }); var pending = allIssues.filter(function (i) { return i.completion_requested_at && i.review_status === 'in_progress'; }); var overdue = allIssues.filter(function (i) { return i.expected_completion_date && new Date(i.expected_completion_date) < new Date(); }); document.getElementById('totalInProgress').textContent = allIssues.length; document.getElementById('todayNew').textContent = todayIssues.length; document.getElementById('pendingCompletion').textContent = pending.length; document.getElementById('overdue').textContent = overdue.length; } function filterByProject() { var pid = document.getElementById('projectFilter').value; filteredIssues = pid ? allIssues.filter(function (i) { return i.project_id == pid; }) : allIssues.slice(); renderIssues(); } // ===== 이슈 상태 판별 ===== function getIssueStatus(issue) { if (issue.review_status === 'completed') return 'completed'; if (issue.completion_requested_at) return 'pending_completion'; if (issue.expected_completion_date) { var diff = (new Date(issue.expected_completion_date) - new Date()) / 86400000; if (diff < 0) return 'overdue'; if (diff <= 3) return 'urgent'; } return 'in_progress'; } function getStatusBadgeHtml(status) { var map = { 'in_progress': ' 진행 중', 'urgent': ' 긴급', 'overdue': ' 지연됨', 'pending_completion': ' 완료 대기', 'completed': ' 완료됨' }; return map[status] || map['in_progress']; } // ===== 렌더링 ===== function renderIssues() { var container = document.getElementById('issuesList'); var empty = document.getElementById('emptyState'); if (!filteredIssues.length) { container.innerHTML = ''; empty.classList.remove('hidden'); return; } empty.classList.add('hidden'); // 날짜별 그룹화 (reviewed_at 기준) var grouped = {}; var dateObjs = {}; filteredIssues.forEach(function (issue) { var d = new Date(issue.reviewed_at || issue.report_date); var key = d.toLocaleDateString('ko-KR'); if (!grouped[key]) { grouped[key] = []; dateObjs[key] = d; } grouped[key].push(issue); }); var html = Object.keys(grouped) .sort(function (a, b) { return dateObjs[b] - dateObjs[a]; }) .map(function (dateKey) { var issues = grouped[dateKey]; return '
' + '' + '' + dateKey + '' + '(' + issues.length + '건)
' + issues.map(function (issue) { return renderIssueCard(issue); }).join('') + '
'; }).join(''); container.innerHTML = html; } function renderIssueCard(issue) { var project = projects.find(function (p) { return p.id === issue.project_id; }); var projectName = project ? project.project_name : '미지정'; var status = getIssueStatus(issue); var photos = getPhotoPaths(issue); var isPending = status === 'pending_completion'; // 의견/댓글 파싱 var opinionsHtml = renderOpinions(issue); // 완료 반려 내용 var rejectionHtml = ''; if (issue.completion_rejection_reason) { var rejAt = issue.completion_rejected_at ? formatKSTDateTime(issue.completion_rejected_at) : ''; rejectionHtml = '
' + '
완료 반려
' + '
' + (rejAt ? '[' + rejAt + '] ' : '') + escapeHtml(issue.completion_rejection_reason) + '
'; } // 완료 대기 정보 var completionInfoHtml = ''; if (isPending) { var cPhotos = getCompletionPhotoPaths(issue); completionInfoHtml = '
' + '
완료 신청 정보
' + (cPhotos.length ? renderPhotoThumbs(cPhotos) : '
완료 사진 없음
') + '
' + (issue.completion_comment || '코멘트 없음') + '
' + '
신청: ' + formatKSTDateTime(issue.completion_requested_at) + '
' + '
'; } // 액션 버튼 var actionHtml = ''; if (isPending) { actionHtml = '
' + '' + '' + '
'; } else { actionHtml = '
' + '' + '' + '
'; } return '
' + '
' + '
No.' + (issue.project_sequence_no || '-') + '' + '' + escapeHtml(projectName) + '
' + getStatusBadgeHtml(status) + '
' + '
' + escapeHtml(getIssueTitle(issue)) + '
' + '
' + // 상세 내용 '
' + escapeHtml(getIssueDetail(issue)) + '
' + // 정보행 '
' + '' + escapeHtml(issue.reporter?.full_name || issue.reporter?.username || '-') + '' + '' + getCategoryText(issue.category || issue.final_category) + '' + (issue.expected_completion_date ? '' + formatKSTDate(issue.expected_completion_date) + '' : '') + '
' + // 사진 (photos.length ? renderPhotoThumbs(photos) : '') + // 해결방안 / 의견 섹션 '
' + renderManagementComment(issue) + opinionsHtml + '
' + rejectionHtml + completionInfoHtml + '
' + actionHtml + '' + '
'; } // ===== 관리 코멘트 (확정 해결방안) ===== function renderManagementComment(issue) { var raw = issue.management_comment || issue.final_description || ''; raw = raw.replace(/\[완료 반려[^\]]*\][^\n]*/g, '').trim(); var defaults = ['중복작업 신고용', '상세 내용 없음', '자재 누락', '설계 오류', '반입 불량', '검사 누락', '기타', '부적합명', '상세내용', '상세 내용']; var lines = raw.split('\n').filter(function (l) { var t = l.trim(); return t && defaults.indexOf(t) < 0; }); var content = lines.join('\n').trim(); return '
' + '
' + (content ? escapeHtml(content) : '확정된 해결 방안 없음') + '
'; } // ===== 의견/댓글/답글 렌더링 ===== function renderOpinions(issue) { if (!issue.solution || !issue.solution.trim()) { return ''; } var opinions = issue.solution.split(/─{30,}/); var validOpinions = opinions.filter(function (o) { return o.trim(); }); if (!validOpinions.length) return ''; var toggleId = 'opinions-' + issue.id; var html = '' + ''; return html; } function toggleOpinions(id) { var el = document.getElementById(id); var chevron = document.getElementById('chevron-' + id); if (el.classList.contains('hidden')) { el.classList.remove('hidden'); if (chevron) chevron.style.transform = 'rotate(180deg)'; } else { el.classList.add('hidden'); if (chevron) chevron.style.transform = ''; } } // ===== 의견 제시 ===== function openOpinionSheet(issueId) { selectedOpinionIssueId = issueId; document.getElementById('opinionText').value = ''; openSheet('opinion'); } async function submitOpinion() { if (!selectedOpinionIssueId) return; var text = document.getElementById('opinionText').value.trim(); if (!text) { showToast('의견을 입력해주세요.', 'warning'); return; } try { var issueResp = await fetch(API_BASE_URL + '/issues/' + selectedOpinionIssueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } }); if (!issueResp.ok) throw new Error('이슈 조회 실패'); var issue = await issueResp.json(); var now = new Date(); var dateStr = now.toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); var newOpinion = '[' + (currentUser.full_name || currentUser.username) + '] (' + dateStr + ')\n' + text; var solution = issue.solution ? newOpinion + '\n' + '─'.repeat(50) + '\n' + issue.solution : newOpinion; var resp = await fetch(API_BASE_URL + '/issues/' + selectedOpinionIssueId + '/management', { method: 'PUT', headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' }, body: JSON.stringify({ solution: solution }) }); if (resp.ok) { showToast('의견이 추가되었습니다.', 'success'); closeSheet('opinion'); await refreshData(); } else { throw new Error('저장 실패'); } } catch (e) { console.error('의견 제시 오류:', e); showToast('오류: ' + e.message, 'error'); } } // ===== 댓글 추가 ===== function openCommentSheet(issueId, opinionIndex) { selectedCommentIssueId = issueId; selectedCommentOpinionIndex = opinionIndex; document.getElementById('commentText').value = ''; openSheet('comment'); } async function submitComment() { if (!selectedCommentIssueId || selectedCommentOpinionIndex === null) return; var text = document.getElementById('commentText').value.trim(); if (!text) { showToast('댓글을 입력해주세요.', 'warning'); return; } try { var issue = await (await fetch(API_BASE_URL + '/issues/' + selectedCommentIssueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } })).json(); var opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; if (selectedCommentOpinionIndex >= opinions.length) throw new Error('잘못된 인덱스'); var dateStr = new Date().toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); opinions[selectedCommentOpinionIndex] = opinions[selectedCommentOpinionIndex].trim() + '\n └ [' + (currentUser.full_name || currentUser.username) + '] (' + dateStr + '): ' + text; var resp = await fetch(API_BASE_URL + '/issues/' + selectedCommentIssueId + '/management', { method: 'PUT', headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' }, body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') }) }); if (resp.ok) { showToast('댓글이 추가되었습니다.', 'success'); closeSheet('comment'); await refreshData(); } else throw new Error('저장 실패'); } catch (e) { showToast('오류: ' + e.message, 'error'); } } // ===== 답글 추가 ===== function openReplySheet(issueId, opinionIndex, commentIndex) { selectedReplyIssueId = issueId; selectedReplyOpinionIndex = opinionIndex; selectedReplyCommentIndex = commentIndex; document.getElementById('replyText').value = ''; openSheet('reply'); } async function submitReply() { if (!selectedReplyIssueId || selectedReplyOpinionIndex === null || selectedReplyCommentIndex === null) return; var text = document.getElementById('replyText').value.trim(); if (!text) { showToast('답글을 입력해주세요.', 'warning'); return; } try { var issue = await (await fetch(API_BASE_URL + '/issues/' + selectedReplyIssueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } })).json(); var opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; var lines = opinions[selectedReplyOpinionIndex].trim().split('\n'); var dateStr = new Date().toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); var newReply = ' ↳ [' + (currentUser.full_name || currentUser.username) + '] (' + dateStr + '): ' + text; var commentCount = -1; var insertIndex = -1; for (var 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'); var resp = await fetch(API_BASE_URL + '/issues/' + selectedReplyIssueId + '/management', { method: 'PUT', headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' }, body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') }) }); if (resp.ok) { showToast('답글이 추가되었습니다.', 'success'); closeSheet('reply'); await refreshData(); } else throw new Error('저장 실패'); } catch (e) { showToast('오류: ' + e.message, 'error'); } } // ===== 수정 ===== function openEditSheet(mode, issueId, opinionIndex, commentIndex, replyIndex) { editMode = mode; editIssueId = issueId; editOpinionIndex = opinionIndex; editCommentIndex = commentIndex !== undefined ? commentIndex : null; editReplyIndex = replyIndex !== undefined ? replyIndex : null; var titles = { opinion: '의견 수정', comment: '댓글 수정', reply: '답글 수정' }; document.getElementById('editSheetTitle').innerHTML = '' + titles[mode]; // 현재 내용 로드 fetch(API_BASE_URL + '/issues/' + issueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } }).then(function (r) { return r.json(); }).then(function (issue) { var opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; var opinion = opinions[opinionIndex] || ''; if (mode === 'opinion') { var hm = opinion.trim().match(/^\[([^\]]+)\]\s*\(([^)]+)\)/); if (hm) { var restLines = opinion.trim().substring(hm[0].length).trim().split('\n'); var main = restLines.filter(function (l) { return !l.match(/^\s*[└↳]/); }).join('\n'); document.getElementById('editText').value = main; } } else if (mode === 'comment') { var cLines = opinion.trim().split('\n'); var cc = -1; for (var i = 0; i < cLines.length; i++) { if (cLines[i].match(/^\s*└/)) { cc++; if (cc === commentIndex) { var m = cLines[i].match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/); if (m) document.getElementById('editText').value = m[3]; break; } } } } else if (mode === 'reply') { var rLines = opinion.trim().split('\n'); var rc = -1; var ri = -1; for (var j = 0; j < rLines.length; j++) { if (rLines[j].match(/^\s*└/)) { rc++; ri = -1; } else if (rLines[j].match(/^\s*↳/)) { ri++; if (rc === commentIndex && ri === replyIndex) { var rm = rLines[j].match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/); if (rm) document.getElementById('editText').value = rm[3]; break; } } } } openSheet('edit'); }); } async function submitEdit() { var newText = document.getElementById('editText').value.trim(); if (!newText) { showToast('내용을 입력해주세요.', 'warning'); return; } try { var issue = await (await fetch(API_BASE_URL + '/issues/' + editIssueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } })).json(); var opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; var lines = opinions[editOpinionIndex].trim().split('\n'); if (editMode === 'opinion') { var hm = opinions[editOpinionIndex].trim().match(/^\[([^\]]+)\]\s*\(([^)]+)\)/); if (hm) { var commentLines = lines.filter(function (l) { return l.match(/^\s*[└↳]/); }); opinions[editOpinionIndex] = hm[0] + '\n' + newText + (commentLines.length ? '\n' + commentLines.join('\n') : ''); } } else if (editMode === 'comment') { var cc = -1; for (var i = 0; i < lines.length; i++) { if (lines[i].match(/^\s*└/)) { cc++; if (cc === editCommentIndex) { var m = lines[i].match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):/); if (m) lines[i] = ' └ [' + m[1] + '] (' + m[2] + '): ' + newText; break; } } } opinions[editOpinionIndex] = lines.join('\n'); } else if (editMode === 'reply') { var rc = -1, ri = -1; for (var j = 0; j < lines.length; j++) { if (lines[j].match(/^\s*└/)) { rc++; ri = -1; } else if (lines[j].match(/^\s*↳/)) { ri++; if (rc === editCommentIndex && ri === editReplyIndex) { var rm = lines[j].match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):/); if (rm) lines[j] = ' ↳ [' + rm[1] + '] (' + rm[2] + '): ' + newText; break; } } } opinions[editOpinionIndex] = lines.join('\n'); } var resp = await fetch(API_BASE_URL + '/issues/' + editIssueId + '/management', { method: 'PUT', headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' }, body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') }) }); if (resp.ok) { showToast('수정되었습니다.', 'success'); closeSheet('edit'); await refreshData(); } else throw new Error('저장 실패'); } catch (e) { showToast('오류: ' + e.message, 'error'); } } // ===== 삭제 ===== async function deleteOpinion(issueId, opinionIndex) { if (!confirm('이 의견을 삭제하시겠습니까?')) return; try { var issue = await (await fetch(API_BASE_URL + '/issues/' + issueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } })).json(); var opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; opinions.splice(opinionIndex, 1); await fetch(API_BASE_URL + '/issues/' + issueId + '/management', { method: 'PUT', headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' }, body: JSON.stringify({ solution: opinions.length ? opinions.join('\n' + '─'.repeat(50) + '\n') : null }) }); showToast('의견이 삭제되었습니다.', 'success'); await refreshData(); } catch (e) { showToast('오류: ' + e.message, 'error'); } } async function deleteComment(issueId, opinionIndex, commentIndex) { if (!confirm('이 댓글을 삭제하시겠습니까?')) return; try { var issue = await (await fetch(API_BASE_URL + '/issues/' + issueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } })).json(); var opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; var lines = opinions[opinionIndex].trim().split('\n'); var cc = -1, start = -1, end = -1; for (var i = 0; i < lines.length; i++) { if (lines[i].match(/^\s*└/)) { cc++; if (cc === commentIndex) { start = i; end = i + 1; while (end < lines.length && lines[end].match(/^\s*↳/)) end++; break; } } } if (start >= 0) { lines.splice(start, end - start); opinions[opinionIndex] = lines.join('\n'); } await fetch(API_BASE_URL + '/issues/' + issueId + '/management', { method: 'PUT', headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' }, body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') }) }); showToast('댓글이 삭제되었습니다.', 'success'); await refreshData(); } catch (e) { showToast('오류: ' + e.message, 'error'); } } async function deleteReply(issueId, opinionIndex, commentIndex, replyIndex) { if (!confirm('이 답글을 삭제하시겠습니까?')) return; try { var issue = await (await fetch(API_BASE_URL + '/issues/' + issueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } })).json(); var opinions = issue.solution ? issue.solution.split(/─{30,}/) : []; var lines = opinions[opinionIndex].trim().split('\n'); var cc = -1, ri = -1; for (var i = 0; i < lines.length; i++) { if (lines[i].match(/^\s*└/)) { cc++; ri = -1; } else if (lines[i].match(/^\s*↳/)) { ri++; if (cc === commentIndex && ri === replyIndex) { lines.splice(i, 1); break; } } } opinions[opinionIndex] = lines.join('\n'); await fetch(API_BASE_URL + '/issues/' + issueId + '/management', { method: 'PUT', headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' }, body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') }) }); showToast('답글이 삭제되었습니다.', 'success'); await refreshData(); } catch (e) { showToast('오류: ' + e.message, 'error'); } } // ===== 완료 신청 ===== function openCompletionSheet(issueId) { selectedCompletionIssueId = issueId; completionPhotoBase64 = null; document.getElementById('completionPhotoInput').value = ''; document.getElementById('completionPhotoPreview').classList.add('hidden'); document.getElementById('completionComment').value = ''; openSheet('completion'); } function handleCompletionPhoto(event) { var file = event.target.files[0]; if (!file) return; if (file.size > 5 * 1024 * 1024) { showToast('파일 크기는 5MB 이하여야 합니다.', 'warning'); event.target.value = ''; return; } if (!file.type.startsWith('image/')) { showToast('이미지 파일만 가능합니다.', 'warning'); event.target.value = ''; return; } var reader = new FileReader(); reader.onload = function (e) { completionPhotoBase64 = e.target.result.split(',')[1]; var preview = document.getElementById('completionPhotoPreview'); preview.src = e.target.result; preview.classList.remove('hidden'); }; reader.readAsDataURL(file); } async function submitCompletionRequest() { if (!selectedCompletionIssueId) return; if (!completionPhotoBase64) { showToast('완료 사진을 업로드해주세요.', 'warning'); return; } try { var body = { completion_photo: completionPhotoBase64 }; var comment = document.getElementById('completionComment').value.trim(); if (comment) body.completion_comment = comment; var resp = await fetch(API_BASE_URL + '/issues/' + selectedCompletionIssueId + '/request-completion', { method: 'POST', headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (resp.ok) { showToast('완료 신청이 접수되었습니다.', 'success'); closeSheet('completion'); await refreshData(); } else { var err = await resp.json(); throw new Error(err.detail || '완료 신청 실패'); } } catch (e) { showToast('오류: ' + e.message, 'error'); } } // ===== 완료 반려 ===== function openRejectionSheet(issueId) { selectedRejectionIssueId = issueId; document.getElementById('rejectionReason').value = ''; openSheet('rejection'); } async function submitRejection() { if (!selectedRejectionIssueId) return; var reason = document.getElementById('rejectionReason').value.trim(); if (!reason) { showToast('반려 사유를 입력해주세요.', 'warning'); return; } try { var resp = await fetch(API_BASE_URL + '/issues/' + selectedRejectionIssueId + '/reject-completion', { method: 'POST', headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' }, body: JSON.stringify({ rejection_reason: reason }) }); if (resp.ok) { showToast('완료 신청이 반려되었습니다.', 'success'); closeSheet('rejection'); await refreshData(); } else { var err = await resp.json(); throw new Error(err.detail || '반려 실패'); } } catch (e) { showToast('오류: ' + e.message, 'error'); } } // ===== 새로고침 ===== async function refreshData() { await loadIssues(); filterByProject(); updateStatistics(); } function refreshPage() { location.reload(); } // ===== 시작 ===== document.addEventListener('DOMContentLoaded', initialize);