/* 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' }; const STATUS_LABELS = { NEW: "신규", INIT: "신규", RECEIVED: "접수", ON_REVIEW: "접수", DETAILED_REVIEW: "진행중", IN_PROGRESS: "진행중", RESOLVED: "완료", COMPLETED: "완료", PENDING: "보류", ON_HOLD: "보류" }; let authReady; 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 normalizeFeedbackAttachment(attachment) { const item = typeof attachment === 'string' ? { url: attachment } : (attachment || {}); const url = item.url || item.download_url || item.downloadUrl || item.file_url || item.fileUrl || item.signed_url || item.signedUrl || item.presigned_url || item.presignedUrl || ''; const safeUrl = /^(https?:\/\/|\/)/i.test(String(url)) ? String(url) : ''; let name = item.original_file_name || item.originalFileName || item.file_name || item.fileName || item.name || ''; if (!name && safeUrl) { try { const objectName = decodeURIComponent(new URL(safeUrl, window.location.origin).pathname.split('/').pop() || ''); const parts = objectName.split('_'); name = parts.length > 2 ? parts.slice(2).join('_') : objectName; } catch (error) {} } name = name || '첨부파일'; return { name: name, url: safeUrl }; } function readJson(key, fallback) { try { return JSON.parse(localStorage.getItem(key) || 'null') || fallback; } catch (error) { return fallback; } } function getPosts() { return readJson(STORAGE_KEY, []).filter(function (post) { return post.category !== '공지사항'; }); } function firstValue(source, keys) { if (!source || typeof source !== "object") return ""; for (let index = 0; index < keys.length; index += 1) { const value = source[keys[index]]; if (value !== undefined && value !== null && value !== "") return value; } return ""; } function toBoolean(value) { if (value === true || value === 1) return true; return ["true", "1", "yes", "y"].indexOf(String(value || "").trim().toLowerCase()) > -1; } function normalizeCategory(value) { const raw = String(value || "").trim(); return ({ ERROR_QNA: "오류문의", IMPROVEMENT_QNA: "개선문의", GENERAL_QNA: "일반문의", 오류: "오류문의", 개선: "개선문의", 일반: "일반문의" })[raw.toUpperCase()] || raw || "일반문의"; } function normalizeRemotePost(item) { item = item || {}; const requester = item.requester || item.author || item.user || {}; const fields = [item.fields, item.metadata, item.meta, item.extra_fields, item.data].find(function (candidate) { return candidate && typeof candidate === "object" && !Array.isArray(candidate); }) || {}; function value(keys) { return firstValue(item, keys) || firstValue(fields, keys) || firstValue(requester, keys); } const createdAt = value(["createdAt", "created_at", "date"]) || new Date().toISOString(); const secret = value(["is_secret", "isSecret", "secret"]); const requesterEmail = value(["requester_email", "requesterEmail", "email"]); const completionRequested = value(["completion_requested", "completionRequested"]); const completionConfirmed = value(["completion_confirmed", "completionConfirmed"]); const completionRequestedAt = value(["completion_requested_at", "completionRequestedAt"]); const completionConfirmedAt = value(["completion_confirmed_at", "completionConfirmedAt"]); return { id: value(["id", "feedbackId", "feedback_id"]), feedbackId: value(["id", "feedbackId", "feedback_id"]), requesterId: value(["requester_id", "requesterId", "requester_uuid"]) || firstValue(requester, ["id", "uuid", "userId"]), requesterTenantId: value(["requester_tenant_id", "requesterTenantId", "tenant_id", "tenantId"]), requesterEmail: requesterEmail, category: normalizeCategory(value(["Category", "category"])), company: getCompanyNameFromEmail(requesterEmail) || value(["requester_company", "company", "organization", "tenant_name", "tenantName", "requester_affiliation"]), department: value(["requester_department", "department", "team"]) || "-", author: value(["requester_name", "author_name", "authorName", "name", "author"]) || "작성자", title: value(["title", "subject"]) || "제목 없음", content: value(["contents", "content", "body"]) || "", date: String(createdAt).slice(0, 10), createdAt: createdAt, status: normalizeStatus(value(["feedback_status", "feedbackStatus", "status_code", "status"])), secret: toBoolean(secret), completionRequested: toBoolean(completionRequested) || Boolean(completionRequestedAt), completionRequestedAt: completionRequestedAt, completionConfirmed: toBoolean(completionConfirmed) || Boolean(completionConfirmedAt), completionConfirmedAt: completionConfirmedAt, attachments: value(["attachments", "images"]) || [], raw: item }; } async function loadRemotePosts() { const result = await requestJson("/api/feedbacks?limit=100&page=1", { method: "GET" }); const items = Array.isArray(result) ? result : (result.items || result.data && result.data.items || []); return { posts: (Array.isArray(items) ? items : []).map(normalizeRemotePost).filter(function (post) { return post.id; }), meta: result.meta || result.data && result.data.meta || {} }; } function savePost(post) { const posts = readJson(STORAGE_KEY, []); posts.unshift(post); localStorage.setItem(STORAGE_KEY, JSON.stringify(posts)); } function updateStoredPost(post) { const posts = readJson(STORAGE_KEY, []); const index = posts.findIndex(function (item) { return item.id === post.id; }); if (index < 0) return; posts[index] = Object.assign({}, posts[index], post); localStorage.setItem(STORAGE_KEY, JSON.stringify(posts)); } function removeStoredPost(id) { const posts = readJson(STORAGE_KEY, []).filter(function (post) { return post.id !== id; }); localStorage.setItem(STORAGE_KEY, JSON.stringify(posts)); } function isPostOwner(post, user) { if (!post || !user) return false; const postIds = [post.requesterId, post.requesterUuid].filter(Boolean); const userIds = [user.requesterId, user.ssoSubject, user.userUuid].filter(Boolean); if (postIds.length && userIds.length) return postIds.some(function (id) { return userIds.indexOf(id) > -1; }); return Boolean(post.author && [user.name, user.loginId, user.email].filter(Boolean).indexOf(post.author) > -1); } function normalizeStatus(value) { if (value && typeof value === "object") value = value.code || value.key || value.value || value.name || ""; const raw = String(value || "").trim(); if (!raw) return ""; const upper = raw.toUpperCase(); return STATUS_LABELS[upper] || ({ NEW: "신규", INIT: "신규", 신규: "신규", RECEIVED: "접수", 접수: "접수", 문의접수: "접수", ON_REVIEW: "접수", REVIEW: "접수", 문의검토: "접수", DETAILED_REVIEW: "진행중", DEEP: "진행중", IN_PROGRESS: "진행중", PATCH: "진행중", 처리중: "진행중", 진행중: "진행중", 정밀검토: "진행중", DONE: "완료", COMPLETED: "완료", RESOLVED: "완료", 답변완료: "완료", 완료: "완료", HOLD: "보류", ON_HOLD: "보류", PENDING: "보류", 보류: "보류" }[upper] || raw); } function extractFeedbackStatus(payload) { const root = payload && payload.feedback ? payload.feedback : payload; const candidates = [root, root && root.data, root && root.feedback, root && root.fields]; for (let index = 0; index < candidates.length; index += 1) { const candidate = candidates[index]; if (!candidate || typeof candidate !== 'object') continue; const value = candidate.feedback_status || candidate.feedbackStatus || candidate.status_code || candidate.status; if (value != null && value !== '') return normalizeStatus(value); } const fields = root && (Array.isArray(root.fields) ? root.fields : (root.data && Array.isArray(root.data.fields) ? root.data.fields : [])); const statusField = fields.find(function (field) { return field && (field.key === 'feedback_status' || field.fieldKey === 'feedback_status' || field.name === 'feedback_status'); }); return statusField ? normalizeStatus(statusField.value || statusField.val || statusField.data) : ''; } async function refreshRemoteStatus(post) { if (!post || !post.id || post.category === '공지사항') return false; try { const result = await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), { method: 'GET' }); const status = extractFeedbackStatus(result); if (!status || status === post.status) return false; post.status = status; updateStoredPost(post); return true; } catch (error) { console.warn('[QA] feedback status sync failed:', post.id, error.message); return false; } } async function refreshRemoteStatuses(posts) { const changed = await Promise.all((posts || []).map(refreshRemoteStatus)); return changed.some(Boolean); } 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 getTenantName(tenant) { if (!tenant || typeof tenant !== 'object') return ''; return tenant.name || tenant.tenantName || tenant.displayName || tenant.label || tenant.nameKo || tenant.koreanName || ''; } function getCompanyNameFromEmail(email) { const domain = String(email || '').split('@').pop().trim().toLowerCase(); return ({ 'samaneng.com': '삼안', 'hanmaceng.co.kr': '한맥기술', 'brsw.kr': '바론그룹', 'baroncs.co.kr': '바론컨설턴트', 'jangheon.co.kr': '장헌산업', 'jangheon.com': '(주)장헌', 'hallasanup.com': '한라산업개발' })[domain] || ''; } function getPostCompanyLabel(post, user) { post = post || {}; const raw = post.raw || {}; const emailCompany = getCompanyNameFromEmail(post.requesterEmail || raw.requester_email || raw.requesterEmail); if (emailCompany) return emailCompany; if (post.company && post.company !== "외부 사용자") return post.company; const company = user && (user.company || user.tenantName); if (company && post.requesterTenantId && user.tenantId && String(post.requesterTenantId) === String(user.tenantId)) return company; return post.company || "외부 사용자"; } 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 primaryTenant = tenantList.find(function (tenant) { return typeof tenant !== 'string' && (tenant.id || tenant.tenantId) === tenantId; }) || tenantList[0] || {}; const tenantName = raw.tenantName || raw.tenant_name || custom.tenantName || custom.tenant_name || claims.tenantName || claims.tenant_name || getTenantName(primaryTenant); 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') || ''; const email = raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || ''; const company = getCompanyNameFromEmail(email) || custom.company || raw.company || claims.company || sessionStorage.getItem('company') || ''; 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: email, 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') || '', tenantName: tenantName, company: 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 normalizePageHeading() { qsa('.egbim-page .section-heading h2').forEach(function (heading) { heading.textContent = '문의하기(Q&A)'; }); qsa('.egbim-page .section-heading p').forEach(function (description) { description.remove(); }); } 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 requestPath = String(path).split("?")[0]; const sameOriginProxy = !config.apiBaseUrl && (requestPath === config.createFeedbackPath || requestPath === "/api/feedbacks" || /^\/api\/feedbacks\/[^\/]+(?:\/comments|\/completion-confirmation)?$/.test(requestPath)); 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) { const isFormData = typeof FormData !== 'undefined' && payload instanceof FormData; return requestJson(path, isFormData ? { method: 'POST', body: payload } : { 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 buildFeedbackSubmission(post, feedbackId, files) { files = Array.prototype.slice.call(files || []); const descriptors = await Promise.all(files.map(async function (file) { return { id: makeUuid(), originalFileName: file.name, mimeType: file.type || 'application/octet-stream', fileSize: file.size, checksumSha256: await sha256(file), purpose: 'FEEDBACK_ATTACHMENT' }; })); const payload = { feedbackId: feedbackId, title: post.title, contents: post.content, Category: CATEGORY_CODES[post.category], is_secret: post.secret ? 'true' : 'false' }; if (!files.length) return { body: payload, attachments: descriptors }; const formData = new FormData(); Object.keys(payload).forEach(function (key) { formData.append(key, payload[key]); }); files.forEach(function (file) { formData.append('images', file, file.name); }); return { body: formData, attachments: descriptors }; } 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; let posts = getPosts(); let remoteLoaded = false; function getPostCompany(post, user) { return getPostCompanyLabel(post, user); } function render() { const query = (qs("#query").value || "").trim().toLowerCase(); const selectedCategory = qs("input[name=category]:checked"); const categoryFilter = selectedCategory ? selectedCategory.value : "전체"; const onlyMine = qs("#onlyMine").checked; const user = getAuthUser(); const filtered = posts.filter(function (post) { const matchesCategory = categoryFilter === "전체" || categoryFilter === post.category; const company = getPostCompany(post, user); const haystack = [post.title, post.content, company, post.department, post.author].join(" ").toLowerCase(); const matchesQuery = !query || haystack.indexOf(query) > -1; const matchesMine = !onlyMine || isPostOwner(post, user); 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); if (resultCount) resultCount.textContent = "총 " + filtered.length + "건"; if (empty) { empty.textContent = remoteLoaded || posts.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 status = normalizeStatus(post.status); const statusClass = status === "답변완료" ? "done" : (status === "문의검토" || status === "정밀검토" ? "review" : ""); const company = getPostCompany(post, user); return "" + "" + number + "" + escapeHtml(post.category.replace("문의", "")) + "" + "" + escapeHtml(company) + "" + escapeHtml(post.department) + "" + escapeHtml(post.author) + "" + "" + (post.secret ? "🔒" : "") + escapeHtml(post.title) + "" + (!isNotice && post.date === "2026-09-17" ? "N" : "") + "" + "" + escapeHtml(post.date) + "" + (status ? "" + escapeHtml(status) + "" : "-") + ""; }).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 ""; }).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(); if (authReady) authReady.then(render); loadRemotePosts().then(function (result) { posts = result.posts; remoteLoaded = true; render(); }).catch(function (error) { console.warn("[QA] feedback list load failed:", error.message); remoteLoaded = true; render(); refreshRemoteStatuses(posts).then(function (changed) { if (changed) 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, requesterId: currentUser.requesterId, requesterTenantId: currentUser.requesterTenantId, category: qs('#category').value, company: currentUser.company || currentUser.tenantName || currentUser.familyCompany || '외부 사용자', 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 submission = await buildFeedbackSubmission(post, feedbackId, qs('#attachment').files); console.info('[QA] sending feedback to Worker:', config.createFeedbackPath); const result = await postToApi(config.createFeedbackPath, submission.body); console.info('[QA] Worker feedback response:', result); post.id = result.id || post.id; post.attachments = submission.attachments; 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 = '문의 등록'; } }); } async function initDetail() { const detail = qs('#detail'); if (!detail) return; const detailListLink = qs('.detail-actions > a', detail); if (detailListLink) detailListLink.textContent = '목록으로'; const bottomBackButton = qs('#backButton', detail); if (bottomBackButton && bottomBackButton.parentElement) bottomBackButton.parentElement.remove(); const id = new URLSearchParams(window.location.search).get("id") || "EG-1048"; const storedPost = getPosts().find(function (item) { return item.id === id; }); let post = storedPost; try { const result = await requestJson("/api/feedbacks/" + encodeURIComponent(id), { method: "GET" }); const remotePost = normalizeRemotePost(result.feedback || result.data || result); if (remotePost.id) post = Object.assign({}, storedPost || {}, remotePost); } catch (error) { console.warn("[QA] feedback detail load failed:", error.message); } if (!post) { qs('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.'; qs('[data-detail-content]').textContent = '목록에 등록된 문의가 없습니다.'; return; } qs('[data-detail-category]').textContent = post.category; qs('[data-detail-title]').textContent = post.title; const renderDetailStatus = function () { const statusElement = qs("[data-detail-status]"); const status = normalizeStatus(post.status) || "공지"; if (!statusElement) return; statusElement.textContent = status; statusElement.classList.toggle("done", status === "완료"); statusElement.classList.toggle("review", status !== "완료"); }; renderDetailStatus(); qs('[data-detail-date]').textContent = post.date; qs('[data-detail-author]').textContent = post.author; let detailUser = getAuthUser(); qs('[data-detail-company]').textContent = getPostCompanyLabel(post, detailUser); qs('[data-detail-department]').textContent = post.department; qs('[data-detail-content]').textContent = post.content; qs('[data-detail-secret]').hidden = !post.secret; const attachmentBox = qs('[data-detail-attachments]'); const attachments = Array.isArray(post.attachments) ? post.attachments : []; if (attachmentBox) attachmentBox.innerHTML = attachments.length ? attachments.map(function (attachment) { const file = normalizeFeedbackAttachment(attachment); if (!file.url) return '
📎 ' + escapeHtml(file.name) + '
'; return '
📎 ' + escapeHtml(file.name) + '
'; }).join('') : '첨부된 파일이 없습니다.'; const editButton = qs('#editButton'); const deleteButton = qs('#deleteButton'); const editForm = qs('#editForm'); const editError = qs('#editError'); const completionNotice = qs("#completionNotice"); const completionDoneNotice = qs("#completionDoneNotice"); const completionButton = qs("#completionButton"); const renderCompletionNotice = function () { const owner = isPostOwner(post, detailUser || getAuthUser()); const completed = normalizeStatus(post.status) === "완료" || post.completionConfirmed; const visible = owner && !completed && post.completionRequested; if (completionNotice) completionNotice.hidden = !visible; if (completionDoneNotice) completionDoneNotice.hidden = !(owner && completed); }; const ownerActions = function () { const owner = isPostOwner(post, detailUser || getAuthUser()); if (editButton) editButton.hidden = !owner; if (deleteButton) deleteButton.hidden = !owner; renderCompletionNotice(); }; ownerActions(); if (authReady) authReady.then(function (user) { detailUser = user || detailUser; ownerActions(); renderCompletionNotice(); }); if (completionButton) completionButton.addEventListener("click", async function () { if (!post.completionRequested || post.completionConfirmed) return; completionButton.disabled = true; completionButton.textContent = "확인 처리 중..."; try { await requestJson("/api/feedbacks/" + encodeURIComponent(post.id) + "/completion-confirmation", { method: "POST" }); post.completionRequested = false; post.completionConfirmed = true; post.status = "완료"; updateStoredPost(post); renderDetailStatus(); renderCompletionNotice(); showToast("문의가 완료 처리되었습니다."); } catch (error) { showToast(error.message || "문의 완료 확인에 실패했습니다."); } finally { completionButton.disabled = false; completionButton.textContent = "완료 확인"; } }); if (editButton && editForm) editButton.addEventListener('click', function () { qs('#editCategory').value = post.category || '일반문의'; qs('#editTitle').value = post.title || ''; qs('#editContent').value = post.content || ''; qs('#editSecret').checked = Boolean(post.secret); editError.hidden = true; editError.style.display = 'none'; editForm.hidden = false; detail.classList.add('is-editing'); editForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); const editCancel = qs('#editCancel'); if (editCancel && editForm) editCancel.addEventListener('click', function () { editForm.hidden = true; detail.classList.remove('is-editing'); }); if (editForm) editForm.addEventListener('submit', async function (event) { event.preventDefault(); editError.hidden = true; editError.style.display = 'none'; const title = qs('#editTitle').value.trim(); const content = qs('#editContent').value.trim(); const category = qs('#editCategory').value; if (!title || !content || !category) { editError.textContent = '구분, 제목, 내용을 모두 입력해주세요.'; editError.hidden = false; editError.style.display = 'block'; return; } const saveButton = qs('button[type="submit"]', editForm); saveButton.disabled = true; saveButton.textContent = '저장 중...'; try { await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title, contents: content, Category: CATEGORY_CODES[category], is_secret: qs('#editSecret').checked ? 'true' : 'false' }) }); post.category = category; post.title = title; post.content = content; post.secret = qs('#editSecret').checked; updateStoredPost(post); qs('[data-detail-category]').textContent = post.category; qs('[data-detail-title]').textContent = post.title; qs('[data-detail-content]').textContent = post.content; qs('[data-detail-secret]').hidden = !post.secret; editForm.hidden = true; detail.classList.remove('is-editing'); showToast('문의가 수정되었습니다.'); } catch (error) { editError.textContent = error.message || '문의 수정에 실패했습니다.'; editError.hidden = false; editError.style.display = 'block'; } finally { saveButton.disabled = false; saveButton.textContent = '수정 저장'; } }); if (deleteButton) deleteButton.addEventListener('click', async function () { if (!window.confirm('이 문의를 삭제하시겠습니까?')) return; deleteButton.disabled = true; try { await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), { method: 'DELETE' }); removeStoredPost(post.id); window.location.href = 'index.html'; } catch (error) { deleteButton.disabled = false; showToast(error.message || '문의 삭제에 실패했습니다.'); } }); function safeMediaUrl(value) { value = String(value || ''); return /^(https?:\/\/|\/|blob:)/i.test(value) ? value : ''; } function normalizeAttachment(attachment) { if (typeof attachment === 'string') return { name: '첨부 이미지', url: safeMediaUrl(attachment), thumbnailUrl: safeMediaUrl(attachment) }; attachment = attachment || {}; const url = attachment.url || attachment.download_url || attachment.downloadUrl || attachment.signed_url || attachment.signedUrl || attachment.presigned_url || attachment.presignedUrl || ''; const thumbnailUrl = attachment.thumbnail_url || attachment.thumbnailUrl || attachment.thumb_url || attachment.thumbUrl || url; return { name: attachment.original_file_name || attachment.originalFileName || attachment.file_name || attachment.fileName || attachment.name || '첨부 이미지', url: safeMediaUrl(url), thumbnailUrl: safeMediaUrl(thumbnailUrl) }; } function normalizeComment(comment) { comment = comment || {}; const rawAttachments = comment.attachments || comment.images || comment.comment_attachments || comment.commentAttachments || []; return { id: comment.id || '', author: comment.author_name || comment.authorName || comment.author?.name || comment.author || '작성자', date: comment.created_at || comment.createdAt || comment.updated_at || '', content: comment.content || comment.body || '', attachments: (Array.isArray(rawAttachments) ? rawAttachments : [rawAttachments]).map(normalizeAttachment) }; } function renderCommentAttachments(attachments) { return (attachments || []).map(function (attachment) { const imageUrl = attachment.thumbnailUrl || attachment.url; if (!imageUrl) return '
📎 ' + escapeHtml(attachment.name) + '
'; const linkUrl = attachment.url || imageUrl; return '' + escapeHtml(attachment.name) + ''; }).join(''); } function renderComments(comments) { comments = (comments || []).map(normalizeComment).filter(function (comment) { return comment.content; }); qs('#commentCount').textContent = comments.length; qs('#comments').innerHTML = comments.length ? comments.map(function (comment) { return '
' + escapeHtml(comment.author) + '' + escapeHtml(comment.date) + '
' + escapeHtml(comment.content) + '
' + (comment.attachments.length ? '
' + renderCommentAttachments(comment.attachments) + '
' : '') + '
'; }).join('') : '
등록된 답변이 없습니다.
'; return comments; } let comments = renderComments(getComments()[post.id] || []); const commentsPath = '/api/feedbacks/' + encodeURIComponent(post.id) + '/comments'; requestJson(commentsPath, { method: 'GET' }).then(function (result) { comments = renderComments(result.comments || result.items || []); }).catch(function (error) { console.warn('[QA] comments load failed:', error.message); }); const commentForm = qs('#commentForm'); if (commentForm) commentForm.addEventListener('submit', async function (event) { event.preventDefault(); const contentInput = qs('#commentContent'); const errorBox = qs('#commentError'); const submitButton = qs('#commentSubmit'); const imageInput = qs('#commentImages'); const files = imageInput ? Array.prototype.slice.call(imageInput.files || []) : []; const content = contentInput.value.trim(); errorBox.hidden = true; if (!content) { errorBox.textContent = '댓글 내용을 입력해주세요.'; errorBox.hidden = false; return; } if (files.some(function (file) { return !/^image\//i.test(file.type); })) { errorBox.textContent = '댓글 첨부는 이미지 파일만 등록할 수 있습니다.'; errorBox.hidden = false; return; } if (files.some(function (file) { return file.size > 30 * 1024 * 1024; })) { errorBox.textContent = '댓글 이미지는 파일당 30MB 이하만 등록할 수 있습니다.'; errorBox.hidden = false; return; } submitButton.disabled = true; submitButton.textContent = '등록 중...'; try { let payload = { content: content }; if (files.length) { payload = new FormData(); payload.append('content', content); files.forEach(function (file) { payload.append('attachments', file, file.name); }); } const result = await postToApi(commentsPath, payload); const localAttachments = files.map(function (file) { const url = URL.createObjectURL(file); return { name: file.name, url: url, thumbnailUrl: url }; }); const savedComment = normalizeComment(result.comment || { id: result.id, content: content, author: (getAuthUser() || {}).name || '작성자', createdAt: new Date().toISOString(), attachments: localAttachments }); if (!savedComment.attachments.length) savedComment.attachments = localAttachments; comments.push(savedComment); renderComments(comments); contentInput.value = ''; if (imageInput) imageInput.value = ''; const imagePreview = qs('#commentImagePreview'); if (imagePreview) imagePreview.innerHTML = ''; } catch (error) { errorBox.textContent = error.message || '댓글 등록에 실패했습니다.'; errorBox.hidden = false; } finally { submitButton.disabled = false; submitButton.textContent = '댓글 등록'; } }); const commentImages = qs('#commentImages'); const commentImagePreview = qs('#commentImagePreview'); const commentImageLabel = qs('label[for="commentImages"]'); if (commentImageLabel) commentImageLabel.textContent = '댓글 이미지 첨부'; if (commentImages && commentImagePreview) commentImages.addEventListener('change', function () { const files = Array.prototype.slice.call(commentImages.files || []); commentImagePreview.innerHTML = files.filter(function (file) { return /^image\//i.test(file.type); }).map(function (file) { const url = URL.createObjectURL(file); return '
' + escapeHtml(file.name) + '' + escapeHtml(file.name) + '
'; }).join(''); }); refreshRemoteStatus(post).then(function (changed) { if (changed) renderDetailStatus(); }); } document.addEventListener('DOMContentLoaded', function () { normalizePageHeading(); bindGlobal(); initList(); initWrite(); initDetail(); }); })();