(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 { '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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().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) { if (!config.apiBaseUrl) return { local: true }; const response = await fetch(config.apiBaseUrl.replace(/\/$/, '') + path, Object.assign({ credentials: 'include' }, options)); if (!response.ok) { const errorBody = await response.json().catch(function () { return {}; }); throw new Error(errorBody.message || '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() { if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID(); return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (char) { const random = Math.random() * 16 | 0; const value = char === 'x' ? random : (random & 3 | 8); return value.toString(16); }); } 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 source = { namespace: config.sourceNamespace, recordId: feedbackId }; 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 data = { id: feedbackId, createdAt: post.createdAt, title: post.title, contents: post.content, priority: 'MEDIUM', Category: categoryCode, requester_id: user.requesterId, requester_tenant_id: user.requesterTenantId, requester_email: user.email, requester_name: user.name, requester_department: user.department, is_secret: post.secret ? 1 : 0, feedback_status: 'NEW', images: imageMetadata }; return { schemaVersion: 'feedback.hmac.kr/qa.v1', source: source, feedback: { id: feedbackId, channelId: config.channelId, workspaceId: config.workspaceId, workspaceCode: config.workspaceCode, sourceNamespace: source.namespace, sourceRecordId: source.recordId, data: data }, supportTicket: { workspace_id: config.workspaceId, requester_id: user.requesterId, requester_tenant_id: user.requesterTenantId, ticket_type: 'QNA', source_system: 'EGBIM', title: post.title, category_code: categoryCode, status_code: 'RECEIVED', is_secret: post.secret ? 1 : 0, priority: 'NORMAL', description: post.content, approval_status: 'NOT_REQUIRED', sync_status: 'PENDING', issue_link_status: 'NOT_REQUIRED', feedback_status: 'NEW', idempotency_key: feedbackId, requester_email: user.email, requester_name: user.name, requester_department: user.department, requester_phone_number: user.phone, extra_fields: { feedback_id: feedbackId, channel_id: config.channelId } }, attachments: attachments.map(function (item) { return Object.assign({}, item, { sourceNamespace: source.namespace, sourceRecordId: source.recordId }); }), comments: [], commentAttachments: [], author: { userUuid: user.userUuid, ssoSubject: user.ssoSubject, requesterId: user.requesterId, tenantId: user.tenantId, tenantIds: user.tenantIds, scope: user.scope, roles: user.roles, email: user.email, name: user.name, department: user.department, phone: user.phone } }; } 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 '