626 lines
35 KiB
JavaScript
626 lines
35 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' };
|
|
const STATUS_LABELS = {
|
|
INIT: '접수',
|
|
ON_REVIEW: '문의검토',
|
|
DETAILED_REVIEW: '정밀검토',
|
|
IN_PROGRESS: '처리중',
|
|
RESOLVED: '답변완료',
|
|
PENDING: '보류'
|
|
};
|
|
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 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 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: '접수', RECEIVED: '접수', 접수: '접수', 문의접수: '접수',
|
|
REVIEW: '문의검토', 문의검토: '문의검토',
|
|
DEEP: '정밀검토', 정밀검토: '정밀검토',
|
|
PATCH: '처리중', 처리중: '처리중',
|
|
DONE: '답변완료', COMPLETED: '답변완료', 답변완료: '답변완료',
|
|
HOLD: '보류', 보류: '보류'
|
|
}[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 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 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 sameOriginProxy = !config.apiBaseUrl && (path === config.createFeedbackPath || /^\/api\/feedbacks\/[^/]+(?:\/comments)?$/.test(path));
|
|
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;
|
|
|
|
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 = getPosts().filter(function (post) {
|
|
const matchesCategory = categoryFilter === '전체' || categoryFilter === post.category;
|
|
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);
|
|
if (resultCount) 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 status = normalizeStatus(post.status);
|
|
const statusClass = status === '답변완료' ? 'done' : (status === '문의검토' || status === '정밀검토' ? 'review' : '');
|
|
return '<tr class="' + (isNotice ? 'notice-row' : '') + '" data-id="' + escapeHtml(post.id) + '">' +
|
|
'<td>' + number + '</td><td class="category-cell">' + escapeHtml(post.category.replace('문의', '')) + '</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>' + (status ? '<span class="status ' + statusClass + '">' + escapeHtml(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();
|
|
refreshRemoteStatuses(getPosts()).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.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 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 = '문의 등록';
|
|
}
|
|
});
|
|
}
|
|
|
|
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 post = getPosts().find(function (item) { return item.id === id; });
|
|
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;
|
|
qs('[data-detail-status]').textContent = normalizeStatus(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 attachmentBox = qs('[data-detail-attachments]');
|
|
const attachments = Array.isArray(post.attachments) ? post.attachments : [];
|
|
if (attachmentBox) attachmentBox.innerHTML = attachments.length ? attachments.map(function (attachment) {
|
|
return '<div class="attachment-item">📎 ' + escapeHtml(attachment.originalFileName || '첨부파일') + '</div>';
|
|
}).join('') : '<span class="help-text">첨부된 파일이 없습니다.</span>';
|
|
const editButton = qs('#editButton');
|
|
const deleteButton = qs('#deleteButton');
|
|
const editForm = qs('#editForm');
|
|
const editError = qs('#editError');
|
|
const ownerActions = function () {
|
|
const owner = isPostOwner(post, getAuthUser());
|
|
if (editButton) editButton.hidden = !owner;
|
|
if (deleteButton) deleteButton.hidden = !owner;
|
|
};
|
|
ownerActions();
|
|
if (authReady) authReady.then(ownerActions);
|
|
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 '<div class="comment-attachment-name">📎 ' + escapeHtml(attachment.name) + '</div>';
|
|
const linkUrl = attachment.url || imageUrl;
|
|
return '<a class="comment-image-link" href="' + escapeHtml(linkUrl) + '" target="_blank" rel="noopener noreferrer"><img class="comment-image" src="' + escapeHtml(imageUrl) + '" alt="' + escapeHtml(attachment.name) + '" loading="lazy"></a>';
|
|
}).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 '<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>' + (comment.attachments.length ? '<div class="comment-attachments-list">' + renderCommentAttachments(comment.attachments) + '</div>' : '') + '</div>'; }).join('') : '<div class="comment-empty">등록된 답변이 없습니다.</div>';
|
|
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 '<div class="comment-image-preview-item"><img src="' + escapeHtml(url) + '" alt="' + escapeHtml(file.name) + '"><span>' + escapeHtml(file.name) + '</span></div>';
|
|
}).join('');
|
|
});
|
|
refreshRemoteStatus(post).then(function (changed) {
|
|
if (changed) qs('[data-detail-status]').textContent = normalizeStatus(post.status) || '공지';
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function () { normalizePageHeading(); bindGlobal(); initList(); initWrite(); initDetail(); });
|
|
})();
|