Files
baron_qa_write/egbim/app.js
T
root 9bbd9ed8ce
Deploy EG-BIM QA Gateway / deploy (push) Successful in 40s
디렉토리 구조 개선(egbim 분리)
2026-09-21 16:48:42 +09:00

381 lines
24 KiB
JavaScript

/* EG-BIM-specific Q&A page behavior. */
(function () {
'use strict';
const config = window.QA_CONFIG || { product: 'egbim', apiBaseUrl: '', ssoUrl: 'https://test.baroncs.co.kr/' };
const STORAGE_KEY = 'baron.qa.posts.egbim';
const COMMENTS_KEY = 'baron.qa.comments.egbim';
const CATEGORY_CODES = { '오류문의': 'ERROR_QNA', '개선문의': 'IMPROVEMENT_QNA', '일반문의': 'GENERAL_QNA' };
let authReady;
const seedPosts = [
{ id: 'EG-1048', category: '오류문의', company: '바론컨설턴트', department: '기술연구팀', author: '홍길동', title: '도면 내보내기 중 프로그램이 종료됩니다.', date: '2026-09-17', status: '문의접수', secret: false, content: '도면 내보내기 버튼을 누르면 프로그램이 종료되는 현상이 있습니다.\n재현 절차와 사용 중인 버전을 함께 확인 부탁드립니다.' },
{ id: 'EG-1047', category: '개선문의', company: '한맥기술', department: 'BIM팀', author: '김민지', title: '층별 도면을 한 번에 선택할 수 있으면 좋겠습니다.', date: '2026-09-16', status: '문의검토', secret: false, content: '층별 도면을 일괄 선택하여 내보낼 수 있는 기능을 제안드립니다.' },
{ id: 'EG-1046', category: '일반문의', company: '삼안', department: '설계1팀', author: '이도윤', title: 'EG-BIM 라이선스와 설치 환경을 문의드립니다.', date: '2026-09-15', status: '답변완료', secret: true, content: '설치 가능한 운영체제와 라이선스 정책을 확인하고 싶습니다.' },
{ id: 'EG-1045', category: '오류문의', company: '바론컨설턴트', department: '디지털전환팀', author: '박서준', title: '프로젝트 파일을 열 때 로딩이 오래 걸립니다.', date: '2026-09-12', status: '정밀검토', secret: false, content: '최근 프로젝트 파일을 열 때 약 2분 이상 로딩이 지속됩니다.' },
{ id: 'EG-1044', category: '공지사항', company: '관리자', department: '-', author: '관리자', title: 'EG-BIM Q&A 게시판 운영 안내', date: '2026-09-10', status: '', secret: false, content: 'EG-BIM 사용 중 궁금한 점이나 개선 의견을 남겨주세요.' },
{ id: 'EG-1043', category: '개선문의', company: '한맥기술', department: '사업관리팀', author: '최유진', title: '검색 결과에서 도면 미리보기를 제공해주세요.', date: '2026-09-09', status: '문의접수', secret: false, content: '검색 결과에 도면 미리보기 썸네일이 있으면 확인이 더 편리할 것 같습니다.' },
{ id: 'EG-1042', category: '일반문의', company: '외부 사용자', department: '-', author: '정하늘', title: '교육 자료를 어디서 확인할 수 있나요?', date: '2026-09-08', status: '답변완료', secret: false, content: 'EG-BIM 기본 교육 자료와 사용 가이드 위치를 알려주세요.' }
];
function qs(selector, root) { return (root || document).querySelector(selector); }
function qsa(selector, root) { return Array.prototype.slice.call((root || document).querySelectorAll(selector)); }
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>'"]/g, function (char) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#039;', '"': '&quot;' }[char];
});
}
function readJson(key, fallback) {
try { return JSON.parse(localStorage.getItem(key) || 'null') || fallback; } catch (error) { return fallback; }
}
function getPosts() {
const saved = readJson(STORAGE_KEY, []);
return saved.concat(seedPosts.filter(function (seed) {
return !saved.some(function (post) { return post.id === seed.id; });
}));
}
function savePost(post) {
const posts = readJson(STORAGE_KEY, []);
posts.unshift(post);
localStorage.setItem(STORAGE_KEY, JSON.stringify(posts));
}
function getComments() { return readJson(COMMENTS_KEY, {}); }
function parseJson(value, fallback) {
if (!value) return fallback;
try { return typeof value === 'string' ? JSON.parse(value) : value; } catch (error) { return fallback; }
}
function decodeCookieJson(value) {
if (!value) return null;
try {
const binary = window.atob(value);
const bytes = Array.prototype.map.call(binary, function (char) { return '%' + ('00' + char.charCodeAt(0).toString(16)).slice(-2); }).join('');
return JSON.parse(decodeURIComponent(bytes));
} catch (error) {
try { return JSON.parse(window.atob(value)); } catch (fallbackError) { return null; }
}
}
function readCookie(name) {
const item = document.cookie.split('; ').find(function (part) { return part.indexOf(name + '=') === 0; });
return item ? item.slice(name.length + 1) : '';
}
function decodeJwtPayload(token) {
if (!token || token.split('.').length < 2) return {};
try {
const encoded = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(decodeURIComponent(escape(window.atob(encoded + '='.repeat((4 - encoded.length % 4) % 4)))));
} catch (error) { return {}; }
}
function collectSsoClaims() {
const storedClaims = parseJson(
sessionStorage.getItem('ssoClaims') || sessionStorage.getItem('baronClaims') || sessionStorage.getItem('claims'),
decodeCookieJson(readCookie('baron_claims')) || {}
);
return Object.assign({}, decodeJwtPayload(sessionStorage.getItem('sessionJwt') || ''), storedClaims);
}
function normalizeAuthUser(raw, claims) {
raw = raw || {};
claims = claims || {};
const custom = raw.customAttributes || raw.custom_attributes || {};
const claimProfile = claims.profile || {};
const tenants = raw.tenants || raw.tenantIds || custom.tenants || claims.tenants || claims.tenantIds || [];
const tenantList = Array.isArray(tenants) ? tenants : Object.keys(tenants).map(function (key) {
return typeof tenants[key] === 'object' ? Object.assign({ id: key }, tenants[key]) : { id: key, name: tenants[key] };
});
const tenantId = raw.tenantId || raw.tenant_id || custom.tenantId || custom.tenant_id || claims.tenantId || claims.tenant_id || (tenantList[0] && (tenantList[0].id || tenantList[0].tenantId)) || '';
const tenantIds = tenantList.map(function (tenant) { return typeof tenant === 'string' ? tenant : (tenant.id || tenant.tenantId || tenant.key || ''); }).filter(Boolean);
if (tenantId && tenantIds.indexOf(tenantId) === -1) tenantIds.unshift(tenantId);
const ssoSubject = raw.ssoSubject || raw.oauthSubject || raw.subject || claims.sub || claims.subject || raw.userId || sessionStorage.getItem('descopeUserId') || '';
const userUuid = raw.userUuid || raw.userId || claims.userId || claims.user_id || sessionStorage.getItem('descopeUserId') || '';
const loginId = raw.loginId || (raw.loginIds || [])[0] || raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || readCookie('descope_login_id') || '';
return {
userUuid: userUuid,
ssoSubject: ssoSubject,
requesterId: ssoSubject,
tenantId: tenantId,
requesterTenantId: tenantId,
tenantIds: tenantIds,
scope: raw.scope || claims.scope || sessionStorage.getItem('ssoScope') || '',
roles: raw.roles || raw.roleNames || claims.roles || claims.roleNames || [],
loginId: loginId,
email: raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || '',
name: raw.name || claims.name || claimProfile.name || sessionStorage.getItem('userName') || readCookie('descope_user_name') || '',
phone: raw.phone || raw.phoneNumber || claims.phone || claims.phone_number || sessionStorage.getItem('phone') || '',
company: custom.company || raw.company || claims.company || sessionStorage.getItem('company') || '',
familyCompany: custom.familyCompany || custom.family_company || raw.familyCompany || sessionStorage.getItem('familyCompany') || '',
department: custom.team || custom.department || raw.department || claims.department || sessionStorage.getItem('team') || '',
rawClaims: claims
};
}
function getAuthUser() {
const sessionUser = normalizeAuthUser({
loginId: sessionStorage.getItem('loginId') || '',
userId: sessionStorage.getItem('descopeUserId') || '',
name: sessionStorage.getItem('userName') || '',
company: sessionStorage.getItem('company') || '',
familyCompany: sessionStorage.getItem('familyCompany') || '',
department: sessionStorage.getItem('team') || '',
tenantId: sessionStorage.getItem('tenantId') || sessionStorage.getItem('tenant_id') || '',
tenantIds: parseJson(sessionStorage.getItem('tenantIds'), [])
}, collectSsoClaims());
if (sessionUser.loginId || sessionUser.ssoSubject) return sessionUser;
const baronUser = decodeCookieJson(readCookie('baron_user'));
const descopeUser = baronUser || {
loginId: readCookie('descope_login_id'), userId: readCookie('descope_user_id'), name: readCookie('descope_user_name'),
email: readCookie('descope_user_email'), phone: readCookie('descope_user_phone'),
customAttributes: decodeCookieJson(readCookie('descope_custom_attributes')) || {}
};
return descopeUser && (descopeUser.loginId || descopeUser.userId || descopeUser.email) ? normalizeAuthUser(descopeUser, collectSsoClaims()) : null;
}
async function loadAuthUser() {
const localUser = getAuthUser();
if (localUser && localUser.tenantId) return localUser;
if (!config.ssoSessionEndpoint) return localUser;
try {
const response = await fetch(config.ssoSessionEndpoint, { credentials: 'include', headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('SSO 세션 확인에 실패했습니다.');
const body = await response.json();
const remoteUser = normalizeAuthUser(body.user || body, body.claims || body.scope || {});
if (remoteUser.loginId || remoteUser.ssoSubject) return remoteUser;
} catch (error) {
console.warn('[QA] SSO session bridge unavailable:', error.message);
}
return localUser;
}
function renderAuth(user) {
user = user || getAuthUser();
qsa('.login-link').forEach(function (element) { element.href = config.ssoUrl || element.href; });
qsa('[data-auth-name]').forEach(function (element) { element.textContent = user ? (user.name || user.loginId) : '로그인'; });
qsa('[data-auth-state]').forEach(function (element) {
element.textContent = user ? (user.name || user.loginId) + '님으로 로그인됨' : '글 작성은 로그인 후 이용할 수 있습니다.';
});
qsa('[data-auth-required]').forEach(function (element) { element.hidden = Boolean(user); });
qsa('[data-auth-user]').forEach(function (element) { element.hidden = !user; });
}
function bindGlobal() {
renderAuth(getAuthUser());
authReady = loadAuthUser().catch(function (error) {
console.error('[QA] SSO session load failed:', error);
return getAuthUser();
}).then(function (user) { renderAuth(user); return user; });
const menuButton = qs('.menu-button');
const nav = qs('.global-nav');
if (menuButton && nav) {
menuButton.addEventListener('click', function () {
nav.classList.toggle('mobile-open');
});
}
}
function showToast(message) {
const toast = qs('#toast');
if (!toast) return;
toast.textContent = message;
toast.classList.add('show');
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(function () { toast.classList.remove('show'); }, 2600);
}
async function requestJson(path, options) {
const sameOriginProxy = !config.apiBaseUrl && path === config.createFeedbackPath;
if (!config.apiBaseUrl && !sameOriginProxy) return { local: true };
const target = config.apiBaseUrl ? config.apiBaseUrl.replace(/\/$/, '') + path : path;
const response = await fetch(target, Object.assign({ credentials: 'include' }, options));
if (!response.ok) {
const errorBody = await response.json().catch(function () { return {}; });
throw new Error(errorBody.message || errorBody.error || 'API 요청에 실패했습니다. (' + response.status + ')');
}
return response.json().catch(function () { return {}; });
}
async function postToApi(path, payload) {
return requestJson(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
}
function makeUuid() {
const bytes = new Uint8Array(16);
if (window.crypto && typeof window.crypto.getRandomValues === 'function') window.crypto.getRandomValues(bytes);
else for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.random() * 256 | 0;
const timestamp = Date.now();
bytes[0] = Math.floor(timestamp / 0x10000000000) & 0xff;
bytes[1] = Math.floor(timestamp / 0x100000000) & 0xff;
bytes[2] = Math.floor(timestamp / 0x1000000) & 0xff;
bytes[3] = Math.floor(timestamp / 0x10000) & 0xff;
bytes[4] = Math.floor(timestamp / 0x100) & 0xff;
bytes[5] = timestamp & 0xff;
bytes[6] = (bytes[6] & 0x0f) | 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.prototype.map.call(bytes, function (byte) { return ('0' + byte.toString(16)).slice(-2); }).join('');
return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16) + '-' + hex.slice(16, 20) + '-' + hex.slice(20);
}
async function sha256(file) {
if (!window.crypto || !window.crypto.subtle) return '';
const digest = await window.crypto.subtle.digest('SHA-256', await file.arrayBuffer());
return Array.prototype.map.call(new Uint8Array(digest), function (byte) { return ('00' + byte.toString(16)).slice(-2); }).join('');
}
async function uploadFiles(files, feedbackId, purpose) {
files = Array.prototype.slice.call(files || []);
if (!files.length) return [];
const descriptors = await Promise.all(files.map(async function (file) {
return { clientId: makeUuid(), originalFileName: file.name, mimeType: file.type || 'application/octet-stream', fileSize: file.size, checksumSha256: await sha256(file) };
}));
if (!config.apiBaseUrl) {
return descriptors.map(function (item) { return Object.assign(item, { id: makeUuid(), storageBucket: config.storageBucket, storageKey: 'local-preview/' + feedbackId + '/' + item.clientId + '-' + item.originalFileName, purpose: purpose }); });
}
const presign = await postToApi(config.presignPath, { feedbackId: feedbackId, workspaceId: config.workspaceId, workspaceCode: config.workspaceCode, bucket: config.storageBucket, purpose: purpose, files: descriptors });
const uploads = presign.uploads || presign.items || [];
if (uploads.length !== descriptors.length) throw new Error('첨부파일 업로드 URL을 모두 받지 못했습니다.');
return Promise.all(uploads.map(async function (upload, index) {
const file = files[index];
const headers = Object.assign({}, upload.headers || {});
if (!headers['Content-Type'] && file.type) headers['Content-Type'] = file.type;
const response = await fetch(upload.uploadUrl, { method: upload.method || 'PUT', headers: headers, body: file });
if (!response.ok) throw new Error(file.name + ' 업로드에 실패했습니다.');
if (!upload.storageKey) throw new Error(file.name + '의 storageKey를 받지 못했습니다.');
return Object.assign(descriptors[index], upload.metadata || {}, {
id: upload.id || makeUuid(), storageBucket: upload.storageBucket || config.storageBucket,
storageKey: upload.storageKey, purpose: purpose
});
}));
}
function buildFeedbackPayload(post, user, feedbackId, attachments) {
const categoryCode = CATEGORY_CODES[post.category];
const imageMetadata = attachments.filter(function (item) { return item.purpose === 'FEEDBACK_ATTACHMENT'; }).map(function (item) {
return { id: item.id, original_file_name: item.originalFileName, storage_bucket: item.storageBucket, storage_key: item.storageKey, mime_type: item.mimeType, file_size: item.fileSize, checksum_sha256: item.checksumSha256 };
});
const payload = { feedbackId: feedbackId, title: post.title, contents: post.content, Category: categoryCode, is_secret: post.secret ? 1 : 0 };
if (imageMetadata.length) payload.images = imageMetadata;
return payload;
}
function initList() {
const tableBody = qs('#qaRows');
if (!tableBody) return;
const searchForm = qs('#searchForm');
const empty = qs('#tableEmpty');
const resultCount = qs('#resultCount');
const pagination = qs('#pagination');
let page = 1;
function render() {
const query = (qs('#query').value || '').trim().toLowerCase();
const checkedCategories = qsa('input[name="category"]:checked').map(function (input) { return input.value; });
const onlyMine = qs('#onlyMine').checked;
const user = getAuthUser();
const filtered = getPosts().filter(function (post) {
const matchesCategory = !checkedCategories.length || checkedCategories.indexOf(post.category) > -1;
const haystack = [post.title, post.content, post.company, post.department, post.author].join(' ').toLowerCase();
const matchesQuery = !query || haystack.indexOf(query) > -1;
const matchesMine = !onlyMine || (user && (post.author === user.name || post.author === user.loginId));
return matchesCategory && matchesQuery && matchesMine;
});
const pageSize = config.pageSize || 10;
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
page = Math.min(page, totalPages);
const rows = filtered.slice((page - 1) * pageSize, page * pageSize);
resultCount.textContent = '총 ' + filtered.length + '건';
empty.style.display = rows.length ? 'none' : 'block';
tableBody.innerHTML = rows.map(function (post, index) {
const isNotice = post.category === '공지사항';
const number = isNotice ? '공지' : filtered.length - ((page - 1) * pageSize + index);
const statusClass = post.status === '답변완료' ? 'done' : (post.status === '문의검토' || post.status === '정밀검토' ? 'review' : '');
return '<tr class="' + (isNotice ? 'notice-row' : '') + '" data-id="' + escapeHtml(post.id) + '">' +
'<td>' + number + '</td><td><span class="category ' + (isNotice ? 'notice' : '') + '">' + escapeHtml(post.category.replace('문의', '')) + '</span></td>' +
'<td>' + escapeHtml(post.company) + '</td><td>' + escapeHtml(post.department) + '</td><td>' + escapeHtml(post.author) + '</td>' +
'<td class="subject"><span class="subject-link">' + (post.secret ? '<span class="lock">🔒</span>' : '') + escapeHtml(post.title) + '</span>' + (!isNotice && post.date === '2026-09-17' ? '<span class="new-mark">N</span>' : '') + '</td>' +
'<td>' + escapeHtml(post.date) + '</td><td>' + (post.status ? '<span class="status ' + statusClass + '">' + escapeHtml(post.status) + '</span>' : '-') + '</td></tr>';
}).join('');
qsa('#qaRows tr').forEach(function (row) { row.addEventListener('click', function () { window.location.href = 'detail.html?id=' + encodeURIComponent(row.dataset.id); }); });
pagination.innerHTML = Array.from({ length: totalPages }, function (_, i) { return '<button type="button" class="' + (i + 1 === page ? 'active' : '') + '" data-page="' + (i + 1) + '">' + (i + 1) + '</button>'; }).join('');
qsa('#pagination button').forEach(function (button) { button.addEventListener('click', function () { page = Number(button.dataset.page); render(); window.scrollTo({ top: 360, behavior: 'smooth' }); }); });
}
searchForm.addEventListener('submit', function (event) { event.preventDefault(); page = 1; render(); });
qsa('input[name="category"], #onlyMine').forEach(function (input) { input.addEventListener('change', function () { page = 1; render(); }); });
render();
}
function initWrite() {
const form = qs('#qaForm');
if (!form) return;
const user = getAuthUser();
const errorBox = qs('#formError');
if (!user) {
qs('#writeFields').setAttribute('aria-disabled', 'true');
}
form.addEventListener('submit', async function (event) {
event.preventDefault();
console.info('[QA] feedback submit started');
errorBox.style.display = 'none';
const currentUser = await (authReady || Promise.resolve(getAuthUser()));
console.info('[QA] SSO user resolved:', Boolean(currentUser), currentUser ? { hasRequesterId: Boolean(currentUser.requesterId), hasTenantId: Boolean(currentUser.requesterTenantId), hasPhone: Boolean(currentUser.phone) } : null);
if (!currentUser) { errorBox.textContent = '로그인 후 문의를 등록할 수 있습니다.'; errorBox.style.display = 'block'; return; }
if (!currentUser.requesterId || !currentUser.requesterTenantId) { errorBox.textContent = 'SSO에서 작성자 UUID와 테넌트 정보를 확인하지 못했습니다. 다시 로그인한 뒤 시도해주세요.'; errorBox.style.display = 'block'; return; }
const title = qs('#title').value.trim();
const content = qs('#content').value.trim();
if (!qs('#category').value || !title || !content) { errorBox.textContent = '구분, 제목, 내용을 모두 입력해주세요.'; errorBox.style.display = 'block'; return; }
const feedbackId = makeUuid();
const createdAt = new Date().toISOString();
const post = { id: feedbackId, feedbackId: feedbackId, category: qs('#category').value, company: currentUser.familyCompany || currentUser.company || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: createdAt.slice(0, 10), createdAt: createdAt, status: '접수', secret: qs('#secret').checked, content: content };
const submitButton = qs('#submitButton');
submitButton.disabled = true;
submitButton.textContent = '등록 중...';
try {
const attachments = await uploadFiles(qs('#attachment').files, feedbackId, 'FEEDBACK_ATTACHMENT');
const payload = buildFeedbackPayload(post, currentUser, feedbackId, attachments);
console.info('[QA] sending feedback to Worker:', config.createFeedbackPath);
const result = await postToApi(config.createFeedbackPath, payload);
console.info('[QA] Worker feedback response:', result);
post.id = result.id || post.id;
savePost(post);
showToast(result.local ? '테스트 글로 저장했습니다. UUID가 생성되었습니다.' : '문의가 등록되었습니다.');
window.setTimeout(function () { window.location.href = 'detail.html?id=' + encodeURIComponent(post.id); }, 500);
} catch (error) {
console.error('[QA] feedback submit failed:', error);
errorBox.textContent = error.message || '등록 중 오류가 발생했습니다.';
errorBox.style.display = 'block';
submitButton.disabled = false;
submitButton.textContent = '문의 등록';
}
});
}
function initDetail() {
const detail = qs('#detail');
if (!detail) return;
const id = new URLSearchParams(window.location.search).get('id') || 'EG-1048';
const post = getPosts().find(function (item) { return item.id === id; }) || seedPosts[0];
qs('[data-detail-category]').textContent = post.category;
qs('[data-detail-title]').textContent = post.title;
qs('[data-detail-status]').textContent = post.status || '공지';
qs('[data-detail-date]').textContent = post.date;
qs('[data-detail-author]').textContent = post.author;
qs('[data-detail-company]').textContent = post.company;
qs('[data-detail-department]').textContent = post.department;
qs('[data-detail-content]').textContent = post.content;
qs('[data-detail-secret]').hidden = !post.secret;
const comments = getComments()[post.id] || [];
qs('#commentCount').textContent = comments.length;
qs('#comments').innerHTML = comments.length ? comments.map(function (comment) { return '<div class="comment"><div class="comment-meta"><strong>' + escapeHtml(comment.author) + '</strong><span>' + escapeHtml(comment.date) + '</span></div><div class="comment-content">' + escapeHtml(comment.content) + '</div></div>'; }).join('') : '<div class="comment-empty">등록된 답변이 없습니다.</div>';
qs('#backButton').addEventListener('click', function () { window.location.href = 'index.html'; });
}
document.addEventListener('DOMContentLoaded', function () { bindGlobal(); initList(); initWrite(); initDetail(); });
})();