- System2 신고: SSO JWT 인증 전환, API base 정리 - System3 부적합: SSO 인증 매니저 통합, 권한 체계 정비 - User Management: SSO 토큰 기반 사용자 관리 API 연동 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
758 lines
36 KiB
JavaScript
758 lines
36 KiB
JavaScript
/**
|
|
* 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 = '<option value="">전체 프로젝트</option>';
|
|
projects.forEach(function (p) {
|
|
sel.innerHTML += '<option value="' + p.id + '">' + escapeHtml(p.project_name) + '</option>';
|
|
});
|
|
}
|
|
} 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': '<span class="m-badge in-progress"><i class="fas fa-cog"></i> 진행 중</span>',
|
|
'urgent': '<span class="m-badge urgent"><i class="fas fa-exclamation-triangle"></i> 긴급</span>',
|
|
'overdue': '<span class="m-badge overdue"><i class="fas fa-clock"></i> 지연됨</span>',
|
|
'pending_completion': '<span class="m-badge pending-completion"><i class="fas fa-hourglass-half"></i> 완료 대기</span>',
|
|
'completed': '<span class="m-badge completed"><i class="fas fa-check-circle"></i> 완료됨</span>'
|
|
};
|
|
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 '<div class="m-date-group"><div class="m-date-header">' +
|
|
'<i class="fas fa-calendar-alt"></i>' +
|
|
'<span>' + dateKey + '</span>' +
|
|
'<span class="m-date-count">(' + issues.length + '건)</span></div>' +
|
|
issues.map(function (issue) { return renderIssueCard(issue); }).join('') +
|
|
'</div>';
|
|
}).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 = '<div style="background:#fff7ed;border-left:3px solid #f97316;border-radius:8px;padding:8px 10px;margin-top:8px;font-size:12px;">' +
|
|
'<div style="font-weight:600;color:#c2410c;margin-bottom:2px"><i class="fas fa-exclamation-triangle" style="margin-right:4px"></i>완료 반려</div>' +
|
|
'<div style="color:#9a3412">' + (rejAt ? '[' + rejAt + '] ' : '') + escapeHtml(issue.completion_rejection_reason) + '</div></div>';
|
|
}
|
|
|
|
// 완료 대기 정보
|
|
var completionInfoHtml = '';
|
|
if (isPending) {
|
|
var cPhotos = getCompletionPhotoPaths(issue);
|
|
completionInfoHtml = '<div class="m-completion-info">' +
|
|
'<div style="font-size:12px;font-weight:600;color:#6d28d9;margin-bottom:6px"><i class="fas fa-check-circle" style="margin-right:4px"></i>완료 신청 정보</div>' +
|
|
(cPhotos.length ? renderPhotoThumbs(cPhotos) : '<div style="font-size:12px;color:#9ca3af">완료 사진 없음</div>') +
|
|
'<div style="font-size:12px;color:#6b7280;margin-top:4px">' + (issue.completion_comment || '코멘트 없음') + '</div>' +
|
|
'<div style="font-size:11px;color:#9ca3af;margin-top:4px">신청: ' + formatKSTDateTime(issue.completion_requested_at) + '</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
// 액션 버튼
|
|
var actionHtml = '';
|
|
if (isPending) {
|
|
actionHtml = '<div class="m-action-row">' +
|
|
'<button class="m-action-btn red" onclick="openRejectionSheet(' + issue.id + ')"><i class="fas fa-times"></i>반려</button>' +
|
|
'<button class="m-action-btn green" onclick="event.stopPropagation();openOpinionSheet(' + issue.id + ')"><i class="fas fa-comment-medical"></i>의견</button>' +
|
|
'</div>';
|
|
} else {
|
|
actionHtml = '<div class="m-action-row">' +
|
|
'<button class="m-action-btn green" onclick="openCompletionSheet(' + issue.id + ')"><i class="fas fa-check"></i>완료신청</button>' +
|
|
'<button class="m-action-btn blue" onclick="event.stopPropagation();openOpinionSheet(' + issue.id + ')"><i class="fas fa-comment-medical"></i>의견</button>' +
|
|
'</div>';
|
|
}
|
|
|
|
return '<div class="m-card border-blue">' +
|
|
'<div class="m-card-header">' +
|
|
'<div><span class="m-card-no">No.' + (issue.project_sequence_no || '-') + '</span>' +
|
|
'<span class="m-card-project">' + escapeHtml(projectName) + '</span></div>' +
|
|
getStatusBadgeHtml(status) +
|
|
'</div>' +
|
|
'<div class="m-card-title">' + escapeHtml(getIssueTitle(issue)) + '</div>' +
|
|
'<div class="m-card-body">' +
|
|
// 상세 내용
|
|
'<div style="font-size:13px;color:#6b7280;line-height:1.5;margin-bottom:8px" class="text-ellipsis-3">' + escapeHtml(getIssueDetail(issue)) + '</div>' +
|
|
// 정보행
|
|
'<div style="display:flex;gap:12px;font-size:12px;color:#9ca3af;margin-bottom:8px">' +
|
|
'<span><i class="fas fa-user" style="margin-right:3px"></i>' + escapeHtml(issue.reporter?.full_name || issue.reporter?.username || '-') + '</span>' +
|
|
'<span><i class="fas fa-tag" style="margin-right:3px"></i>' + getCategoryText(issue.category || issue.final_category) + '</span>' +
|
|
(issue.expected_completion_date ? '<span><i class="fas fa-calendar-alt" style="margin-right:3px"></i>' + formatKSTDate(issue.expected_completion_date) + '</span>' : '') +
|
|
'</div>' +
|
|
// 사진
|
|
(photos.length ? renderPhotoThumbs(photos) : '') +
|
|
// 해결방안 / 의견 섹션
|
|
'<div style="margin-top:8px">' +
|
|
renderManagementComment(issue) +
|
|
opinionsHtml +
|
|
'</div>' +
|
|
rejectionHtml +
|
|
completionInfoHtml +
|
|
'</div>' +
|
|
actionHtml +
|
|
'<div class="m-card-footer">' +
|
|
'<span>' + getTimeAgo(issue.report_date) + '</span>' +
|
|
'<span>ID: ' + issue.id + '</span>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
// ===== 관리 코멘트 (확정 해결방안) =====
|
|
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 '<div style="background:#fef2f2;border-left:3px solid #fca5a5;border-radius:8px;padding:8px 10px;margin-bottom:6px">' +
|
|
'<div style="font-size:' + (content ? '12' : '12') + 'px;color:' + (content ? '#991b1b' : '#d1d5db') + ';line-height:1.5;white-space:pre-wrap">' +
|
|
(content ? escapeHtml(content) : '확정된 해결 방안 없음') +
|
|
'</div></div>';
|
|
}
|
|
|
|
// ===== 의견/댓글/답글 렌더링 =====
|
|
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 = '<button class="m-opinions-toggle" onclick="toggleOpinions(\'' + toggleId + '\')">' +
|
|
'<i class="fas fa-comments"></i> 의견 ' + validOpinions.length + '개' +
|
|
'<i class="fas fa-chevron-down" style="font-size:10px;transition:transform 0.2s" id="chevron-' + toggleId + '"></i></button>' +
|
|
'<div id="' + toggleId + '" class="hidden" style="margin-top:6px">';
|
|
|
|
validOpinions.forEach(function (opinion, opinionIndex) {
|
|
var trimmed = opinion.trim();
|
|
var headerMatch = trimmed.match(/^\[([^\]]+)\]\s*\(([^)]+)\)/);
|
|
|
|
if (headerMatch) {
|
|
var author = headerMatch[1];
|
|
var datetime = headerMatch[2];
|
|
var rest = trimmed.substring(headerMatch[0].length).trim().split('\n');
|
|
var mainContent = '';
|
|
var comments = [];
|
|
var currentCommentIdx = -1;
|
|
|
|
rest.forEach(function (line) {
|
|
if (line.match(/^\s*└/)) {
|
|
var cm = line.match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
|
|
if (cm) { comments.push({ author: cm[1], datetime: cm[2], content: cm[3], replies: [] }); currentCommentIdx = comments.length - 1; }
|
|
} else if (line.match(/^\s*↳/)) {
|
|
var rm = line.match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
|
|
if (rm && currentCommentIdx >= 0) { comments[currentCommentIdx].replies.push({ author: rm[1], datetime: rm[2], content: rm[3] }); }
|
|
} else {
|
|
mainContent += (mainContent ? '\n' : '') + line;
|
|
}
|
|
});
|
|
|
|
var isOwn = currentUser && (author === currentUser.full_name || author === currentUser.username);
|
|
|
|
html += '<div class="m-opinion-card">' +
|
|
'<div class="m-opinion-header">' +
|
|
'<div class="m-opinion-avatar">' + author.charAt(0) + '</div>' +
|
|
'<span class="m-opinion-author">' + escapeHtml(author) + '</span>' +
|
|
'<span class="m-opinion-time">' + escapeHtml(datetime) + '</span>' +
|
|
'</div>' +
|
|
'<div class="m-opinion-text">' + escapeHtml(mainContent) + '</div>' +
|
|
'<div class="m-opinion-actions">' +
|
|
'<button class="m-opinion-action-btn comment-btn" onclick="openCommentSheet(' + issue.id + ',' + opinionIndex + ')"><i class="fas fa-comment"></i>댓글</button>' +
|
|
(isOwn ? '<button class="m-opinion-action-btn edit-btn" onclick="openEditSheet(\'opinion\',' + issue.id + ',' + opinionIndex + ')"><i class="fas fa-edit"></i>수정</button>' +
|
|
'<button class="m-opinion-action-btn delete-btn" onclick="deleteOpinion(' + issue.id + ',' + opinionIndex + ')"><i class="fas fa-trash"></i>삭제</button>' : '') +
|
|
'</div>';
|
|
|
|
// 댓글
|
|
comments.forEach(function (comment, commentIndex) {
|
|
var isOwnComment = currentUser && (comment.author === currentUser.full_name || comment.author === currentUser.username);
|
|
html += '<div class="m-comment">' +
|
|
'<div class="m-comment-header">' +
|
|
'<div class="m-comment-avatar">' + comment.author.charAt(0) + '</div>' +
|
|
'<span style="font-weight:600;color:#111827;font-size:11px">' + escapeHtml(comment.author) + '</span>' +
|
|
'<span style="color:#9ca3af;font-size:10px">' + escapeHtml(comment.datetime) + '</span>' +
|
|
'</div>' +
|
|
'<div class="m-comment-text">' + escapeHtml(comment.content) + '</div>' +
|
|
'<div style="display:flex;gap:4px;margin-top:4px;padding-left:22px">' +
|
|
'<button class="m-opinion-action-btn reply-btn" onclick="openReplySheet(' + issue.id + ',' + opinionIndex + ',' + commentIndex + ')"><i class="fas fa-reply"></i>답글</button>' +
|
|
(isOwnComment ? '<button class="m-opinion-action-btn edit-btn" onclick="openEditSheet(\'comment\',' + issue.id + ',' + opinionIndex + ',' + commentIndex + ')"><i class="fas fa-edit"></i></button>' +
|
|
'<button class="m-opinion-action-btn delete-btn" onclick="deleteComment(' + issue.id + ',' + opinionIndex + ',' + commentIndex + ')"><i class="fas fa-trash"></i></button>' : '') +
|
|
'</div>';
|
|
|
|
// 답글
|
|
comment.replies.forEach(function (reply, replyIndex) {
|
|
var isOwnReply = currentUser && (reply.author === currentUser.full_name || reply.author === currentUser.username);
|
|
html += '<div class="m-reply">' +
|
|
'<div class="m-reply-header">' +
|
|
'<i class="fas fa-reply" style="color:#93c5fd;font-size:9px;margin-right:3px"></i>' +
|
|
'<span style="font-weight:600;color:#111827;font-size:10px">' + escapeHtml(reply.author) + '</span>' +
|
|
'<span style="color:#9ca3af;font-size:9px;margin-left:3px">' + escapeHtml(reply.datetime) + '</span>' +
|
|
(isOwnReply ? '<button class="m-opinion-action-btn edit-btn" style="margin-left:auto;padding:1px 4px" onclick="openEditSheet(\'reply\',' + issue.id + ',' + opinionIndex + ',' + commentIndex + ',' + replyIndex + ')"><i class="fas fa-edit" style="font-size:9px"></i></button>' +
|
|
'<button class="m-opinion-action-btn delete-btn" style="padding:1px 4px" onclick="deleteReply(' + issue.id + ',' + opinionIndex + ',' + commentIndex + ',' + replyIndex + ')"><i class="fas fa-trash" style="font-size:9px"></i></button>' : '') +
|
|
'</div>' +
|
|
'<div class="m-reply-text">' + escapeHtml(reply.content) + '</div>' +
|
|
'</div>';
|
|
});
|
|
|
|
html += '</div>'; // m-comment
|
|
});
|
|
|
|
html += '</div>'; // m-opinion-card
|
|
}
|
|
});
|
|
|
|
html += '</div>';
|
|
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 = '<i class="fas fa-edit" style="color:#22c55e;margin-right:6px"></i>' + 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);
|