Files
baron_qa_write/egbim/app.js
T
root c62630e22d feat(ui): apply inquiry list and modal design
Apply the supplied inquiry layout to the list and native write/detail dialogs while preserving existing API requests, author controls, attachments, comments and search. Keep legacy entry links and confirm before discarding drafts.

Validation: 98 tests and syntax checks pass; mocked Chromium desktop/mobile and six author/session visibility scenarios pass.
2026-11-02 13:11:47 +09:00

1254 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;
let resolvedAuthUser = null;
let inquiryUI = null;
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 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 formatKoreanDateTime(value, includeTime) {
const raw = String(value == null ? '' : value).trim();
if (!raw) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
let timestamp = raw.replace(' ', 'T');
if (!/[zZ]$|[+-]\d{2}:?\d{2}$/.test(timestamp)) timestamp += 'Z';
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return raw;
const options = { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit' };
if (includeTime) Object.assign(options, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
const parts = new Intl.DateTimeFormat('en-CA', options).formatToParts(date).reduce(function (result, part) {
result[part.type] = part.value;
return result;
}, {});
const day = parts.year + '-' + parts.month + '-' + parts.day;
return includeTime ? day + ' ' + parts.hour + ':' + parts.minute : day;
}
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: value(["requester_affiliation", "requester_company", "company", "organization", "tenant_name", "tenantName"]) || getCompanyNameFromEmail(requesterEmail),
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: formatKoreanDateTime(createdAt, false),
dateTime: formatKoreanDateTime(createdAt, true),
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(onPage) {
const posts = [];
const seen = new Set();
let meta = {};
for (let page = 1; ; page += 1) {
const result = await requestJson('/api/feedbacks?limit=100&page=' + page, { method: 'GET' });
const items = Array.isArray(result) ? result : (result.items || result.data && result.data.items || []);
if (!Array.isArray(items)) throw new Error('문의 목록 응답을 확인할 수 없습니다.');
const previousCount = posts.length;
items.map(normalizeRemotePost).forEach(function (post) {
if (post.id && !seen.has(post.id)) { seen.add(post.id); posts.push(post); }
});
if (page > 1 && items.length && posts.length === previousCount) throw new Error('문의 목록의 다음 페이지를 불러오지 못했습니다.');
meta = result.meta || result.data && result.data.meta || {};
const totalPages = Number(meta.totalPages ?? meta.total_pages);
const total = Number(meta.total ?? meta.totalCount ?? meta.total_count);
if (!items.length && (Number.isFinite(total) && posts.length < total || Number.isFinite(totalPages) && page < totalPages)) {
throw new Error('문의 목록을 끝까지 불러오지 못했습니다.');
}
const hasMore = items.length > 0 && (Number.isFinite(totalPages) ? page < totalPages : Number.isFinite(total) ? posts.length < total : items.length === 100);
if (onPage) onPage({ posts: posts.slice(), meta: meta, complete: !hasMore });
if (!hasMore) return { posts: posts, meta: meta };
}
}
const HANGUL_INITIALS = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
const searchIndex = new WeakMap();
function normalizeSearchText(value) {
return String(value || '').normalize('NFKC').toLowerCase()
.replace(/[\u1100-\u1112]/g, function (char) { return HANGUL_INITIALS[char.charCodeAt(0) - 0x1100]; })
.replace(/[\p{P}\p{S}]+/gu, ' ').replace(/\s+/g, ' ').trim();
}
function hangulInitials(value) {
return Array.from(value).map(function (char) {
const code = char.charCodeAt(0) - 0xac00;
return code >= 0 && code <= 11171 ? HANGUL_INITIALS[Math.floor(code / 588)] : char;
}).join('');
}
// Find a near substring, allowing insertions, deletions, substitutions and adjacent swaps.
function fuzzySubstringDistance(query, text, limit) {
const cap = limit + 1;
const characters = Array.from({ length: query.length }, function (_, index) { return query.charCodeAt(index); });
let previous = new Uint8Array(query.length + 1);
let beforePrevious = new Uint8Array(query.length + 1);
let current = new Uint8Array(query.length + 1);
for (let j = 0; j <= query.length; j += 1) previous[j] = Math.min(j, cap);
let best = limit + 1;
for (let i = 0; i < text.length; i += 1) {
const character = text.charCodeAt(i);
const preceding = text.charCodeAt(i - 1);
current[0] = 0;
for (let j = 1; j <= query.length; j += 1) {
let distance = previous[j - 1] + (character === characters[j - 1] ? 0 : 1);
if (previous[j] + 1 < distance) distance = previous[j] + 1;
if (current[j - 1] + 1 < distance) distance = current[j - 1] + 1;
if (i > 0 && j > 1 && character === characters[j - 2] && preceding === characters[j - 1] && beforePrevious[j - 2] + 1 < distance) {
distance = beforePrevious[j - 2] + 1;
}
current[j] = distance > cap ? cap : distance;
}
if (current[query.length] < best) best = current[query.length];
// Exact substrings are checked by the caller; one edit is the best remaining match.
if (best <= 1) return best;
const reusable = beforePrevious;
beforePrevious = previous;
previous = current;
current = reusable;
}
return best;
}
function searchPosts(posts, query) {
const terms = normalizeSearchText(query).split(' ').filter(Boolean);
if (!terms.length) return posts.slice();
return posts.map(function (post, index) {
let fields = searchIndex.get(post);
if (!fields) {
fields = [post.title, post.content, getPostCompanyLabel(post), post.department, post.author].map(function (value, field) {
const text = normalizeSearchText(value).replace(/ /g, '');
return { text: text, initials: hangulInitials(text), characters: new Set(text), weight: field === 0 ? 0 : field === 1 ? 3 : 2 };
});
searchIndex.set(post, fields);
}
let score = 0;
for (const term of terms) {
let best = Infinity;
const initialsOnly = /^[ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ]{2,}$/.test(term);
const limit = term.length >= 6 ? 2 : term.length >= 3 ? 1 : 0;
for (const field of fields) {
if (field.text.includes(term)) best = Math.min(best, field.weight);
else if (initialsOnly && field.initials.includes(term)) best = Math.min(best, 1 + field.weight);
else if (!initialsOnly && limit && term.length <= 32 && field.text) {
if (Array.from(term).filter(function (char) { return !field.characters.has(char); }).length > limit) continue;
const distance = fuzzySubstringDistance(term, field.text, limit);
if (distance <= limit) best = Math.min(best, 10 * distance + field.weight);
}
if (best === 0) break;
}
if (!Number.isFinite(best)) return { post: post, index: index, score: Infinity };
score += best;
}
return { post: post, index: index, score: score };
}).filter(function (match) { return Number.isFinite(match.score); })
.sort(function (a, b) { return a.score - b.score || a.index - b.index; })
.map(function (match) { return match.post; });
}
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 matchesAuthorId(authorId, user) {
if (!user) return false;
const normalizeId = function (id) { return String(id || '').trim().toLowerCase(); };
const ownerId = normalizeId(authorId);
const userId = normalizeId(user.requesterId || user.ssoSubject || user.userUuid);
return Boolean(ownerId && userId && ownerId === userId);
}
function isPostOwner(post, user) {
return Boolean(post) && matchesAuthorId(post.requesterId || post.requesterUuid, user);
}
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 savedCompany = raw.requester_affiliation || (raw.extra_fields || {}).requester_affiliation;
if (savedCompany) return savedCompany;
if (post.company && post.company !== "외부 사용자") return post.company;
const emailCompany = getCompanyNameFromEmail(post.requesterEmail || raw.requester_email || raw.requesterEmail);
if (emailCompany) return emailCompany;
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.requesterId || raw.userUuid || raw.userId || sessionStorage.getItem('descopeUserId') || '';
const userUuid = raw.userUuid || raw.userId || claims.userId || claims.user_id || ssoSubject || 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 = raw.company || custom.company || claims.company || sessionStorage.getItem('company') || getCompanyNameFromEmail(email) || '';
return {
userUuid: userUuid,
ssoSubject: ssoSubject,
requesterId: raw.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: raw.department || custom.team || custom.department || claims.department || sessionStorage.getItem('team') || '',
rawClaims: claims
};
}
function getAuthUser() {
if (resolvedAuthUser) return resolvedAuthUser;
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 (!config.ssoSessionEndpoint) {
resolvedAuthUser = localUser;
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 rawUser = body.user || body;
if (body.authenticated === false || !rawUser || !(rawUser.requesterId || rawUser.ssoSubject || rawUser.userUuid || rawUser.userId || rawUser.subject)) {
resolvedAuthUser = null;
return null;
}
const remoteUser = normalizeAuthUser(rawUser, body.claims || body.scope || {});
if (remoteUser.loginId || remoteUser.ssoSubject) {
resolvedAuthUser = remoteUser;
return remoteUser;
}
} catch (error) {
console.warn('[QA] SSO session bridge unavailable:', error.message);
}
resolvedAuthUser = null;
return null;
}
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;
let loadFailed = false;
let receivedRemotePage = false;
const searchStatus = qs('#searchStatus');
const changes = new Map();
const deleted = new Set();
function mergeChanges(remotePosts) {
const result = remotePosts.filter(function (post) { return !deleted.has(post.id); }).map(function (post) { return changes.get(post.id) || post; });
for (const post of changes.values()) if (!deleted.has(post.id) && !result.some(function (item) { return item.id === post.id; })) result.unshift(post);
return result;
}
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 = searchPosts(posts.filter(function (post) {
const matchesCategory = categoryFilter === "전체" || categoryFilter === post.category;
const matchesMine = !onlyMine || isPostOwner(post, user);
return matchesCategory && matchesMine;
}), query);
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 (searchStatus) {
searchStatus.hidden = remoteLoaded && !loadFailed && !query;
searchStatus.textContent = loadFailed ? '목록을 모두 불러오지 못했습니다. 현재 불러온 글에서 검색합니다. 새로고침해 주세요.' : !remoteLoaded ? '문의 목록을 불러오는 중입니다. 검색 결과가 추가될 수 있습니다.' : '검색 결과 ' + 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 === '신규' ? 'new' : status === '보류' ? 'hold' : status === '진행중' ? 'patch' : 'review';
const company = getPostCompany(post, user);
return "<tr class=\"" + (isNotice ? "notice-row" : "") + "\" data-id=\"" + escapeHtml(post.id) + "\">" +
"<td>" + number + "</td><td class=\"category-cell\">" + escapeHtml(post.category.replace("문의", "")) + "</td>" +
'<td title="' + escapeHtml(company) + '">' + escapeHtml(company) + '</td><td title="' + escapeHtml(post.department) + '">' + escapeHtml(post.department) + '</td><td>' + escapeHtml(post.author) + '</td>' +
'<td class="inquiry-subject"><a class="subject-link" title="' + escapeHtml(post.title) + '" href="detail.html?id=' + encodeURIComponent(post.id) + '">' + (post.secret ? '<span class="lock" aria-label="비밀글">🔒</span>' : '') + '<span>' + escapeHtml(post.title) + '</span></a></td>' +
'<td title="' + escapeHtml(post.dateTime || post.date) + '">' + escapeHtml(post.dateTime || post.date) + '</td><td>' + (status ? '<span class="inquiry-status inquiry-status--' + statusClass + '">' + escapeHtml(status) + '</span>' : '-') + '</td></tr>';
}).join("");
qsa("#qaRows tr").forEach(function (row) { row.addEventListener("click", function (event) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (inquiryUI) { event.preventDefault(); inquiryUI.open('detail', row.dataset.id, row.querySelector('a')); }
else window.location.href = 'detail.html?id=' + encodeURIComponent(row.dataset.id);
}); });
const visiblePages = Array.from(new Set([1, totalPages, page - 2, page - 1, page, page + 1, page + 2])).filter(function (value) { return value >= 1 && value <= totalPages; }).sort(function (a, b) { return a - b; });
pagination.innerHTML = '<button type="button" data-page="' + Math.max(1, page - 1) + '" aria-label="이전 페이지"' + (page === 1 ? ' disabled' : '') + '>‹</button>' + visiblePages.map(function (value, index) {
const gap = index > 0 && value - visiblePages[index - 1] > 1 ? '<span aria-hidden="true">…</span>' : '';
return gap + '<button type="button" class="' + (value === page ? 'active' : '') + '" data-page="' + value + '"' + (value === page ? ' aria-current="page"' : '') + '>' + value + '</button>';
}).join('') + '<button type="button" data-page="' + Math.min(totalPages, page + 1) + '" aria-label="다음 페이지"' + (page === totalPages ? ' disabled' : '') + '>›</button>';
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(function (result) { receivedRemotePage = true; posts = mergeChanges(result.posts); render(); }).then(function (result) {
posts = mergeChanges(result.posts);
remoteLoaded = true;
render();
}).catch(function (error) {
console.warn("[QA] feedback list load failed:", error.message);
remoteLoaded = true;
loadFailed = true;
render();
if (!receivedRemotePage) refreshRemoteStatuses(posts).then(function (changed) { if (changed) render(); });
});
return {
upsert: function (post) { changes.set(post.id, post); posts = mergeChanges(posts); render(); },
remove: function (id) { changes.delete(id); deleted.add(id); posts = posts.filter(function (post) { return post.id !== id; }); render(); }
};
}
function initWrite(root) {
const find = function (selector, container) { return qs(selector, container || root); };
const form = find('#qaForm');
if (!form) return;
const user = getAuthUser();
const errorBox = find('#formError');
Promise.resolve(authReady || user).then(function (profile) {
for (const [selector, value] of [['#writeCompany', profile && profile.company], ['#writeDepartment', profile && profile.department], ['#writeAuthor', profile && (profile.name || profile.loginId)], ['#writeEmail', profile && profile.email]]) {
const field = find(selector);
if (field) field.value = value || '-';
}
const notice = find('[data-auth-state]');
if (notice) notice.textContent = profile ? '로그인한 사용자 정보로 문의가 등록됩니다.' : '로그인 상태를 확인하지 못했습니다. 새로고침 후 다시 시도해주세요.';
});
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 = find('#title').value.trim();
const content = find('#content').value.trim();
if (!find('#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: find('#category').value, company: currentUser.company || currentUser.tenantName || currentUser.familyCompany || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: formatKoreanDateTime(createdAt, false), dateTime: formatKoreanDateTime(createdAt, true), createdAt: createdAt, status: '신규', secret: find('#secret').checked, content: content };
const submitButton = find('#submitButton');
submitButton.disabled = true;
submitButton.textContent = '등록 중...';
try {
const submission = await buildFeedbackSubmission(post, feedbackId, find('#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가 생성되었습니다.' : '문의가 등록되었습니다.');
if (inquiryUI) inquiryUI.saved(post);
else 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(root, requestedId) {
const find = function (selector, container) { return qs(selector, container || root); };
const detail = find('#detail');
if (!detail) return;
const detailListLink = find('.detail-actions > a', detail);
if (detailListLink) detailListLink.textContent = '목록으로';
const bottomBackButton = find('#backButton', detail);
if (bottomBackButton && bottomBackButton.parentElement) bottomBackButton.parentElement.remove();
const id = requestedId || 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) {
find('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
find('[data-detail-content]').textContent = '목록에 등록된 문의가 없습니다.';
const commentBox = find('.comment-box');
if (commentBox) commentBox.hidden = true;
return;
}
find('[data-detail-category]').textContent = post.category;
find('[data-detail-title]').textContent = post.title;
const renderDetailStatus = function () {
const statusElement = find("[data-detail-status]");
const status = normalizeStatus(post.status) || "공지";
if (!statusElement) return;
statusElement.textContent = status;
statusElement.classList.toggle("done", status === "완료");
statusElement.classList.toggle("review", status !== "완료");
if (statusElement.setAttribute) statusElement.setAttribute('data-status', status);
};
renderDetailStatus();
find('[data-detail-date]').textContent = post.dateTime || post.date;
find('[data-detail-author]').textContent = post.author;
const detailUser = await (authReady || Promise.resolve(getAuthUser()));
find('[data-detail-company]').textContent = getPostCompanyLabel(post, detailUser);
find('[data-detail-department]').textContent = post.department;
find('[data-detail-content]').textContent = post.content;
find('[data-detail-secret]').hidden = !post.secret;
const attachmentBox = find('[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 '<div class="attachment-item">📎 ' + escapeHtml(file.name) + '</div>';
return '<div class="attachment-item"><a href="' + escapeHtml(file.url) + '" target="_blank" rel="noopener noreferrer">📎 ' + escapeHtml(file.name) + '</a></div>';
}).join('') : '<span class="help-text">첨부된 파일이 없습니다.</span>';
const editButton = find('#editButton');
const deleteButton = find('#deleteButton');
const editForm = find('#editForm');
const editError = find('#editError');
const completionNotice = find("#completionNotice");
const completionDoneNotice = find("#completionDoneNotice");
const completionButton = find("#completionButton");
const renderCompletionNotice = function () {
const owner = isPostOwner(post, detailUser);
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);
if (editButton) editButton.hidden = !owner;
if (deleteButton) deleteButton.hidden = !owner;
renderCompletionNotice();
};
ownerActions();
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);
if (inquiryUI) inquiryUI.changed(post);
renderDetailStatus();
renderCompletionNotice();
showToast("문의가 완료 처리되었습니다.");
} catch (error) {
showToast(error.message || "문의 완료 확인에 실패했습니다.");
} finally {
completionButton.disabled = false;
completionButton.textContent = "완료 확인";
}
});
if (editButton && editForm) editButton.addEventListener('click', function () {
find('#editCategory').value = post.category || '일반문의';
find('#editTitle').value = post.title || '';
find('#editContent').value = post.content || '';
find('#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' });
if (inquiryUI) inquiryUI.markClean(editForm);
});
const editCancel = find('#editCancel');
if (editCancel && editForm) editCancel.addEventListener('click', function () {
if (inquiryUI && !inquiryUI.discardForm(editForm)) return;
editForm.reset(); editForm.hidden = true; detail.classList.remove('is-editing');
if (inquiryUI) inquiryUI.markClean(editForm);
});
if (editForm) editForm.addEventListener('submit', async function (event) {
event.preventDefault();
editError.hidden = true;
editError.style.display = 'none';
const title = find('#editTitle').value.trim();
const content = find('#editContent').value.trim();
const category = find('#editCategory').value;
if (!title || !content || !category) {
editError.textContent = '구분, 제목, 내용을 모두 입력해주세요.';
editError.hidden = false;
editError.style.display = 'block';
return;
}
const saveButton = find('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: find('#editSecret').checked ? 'true' : 'false' })
});
post.category = category;
post.title = title;
post.content = content;
post.secret = find('#editSecret').checked;
updateStoredPost(post);
if (inquiryUI) { inquiryUI.changed(post); inquiryUI.markClean(editForm); }
find('[data-detail-category]').textContent = post.category;
find('[data-detail-title]').textContent = post.title;
find('[data-detail-content]').textContent = post.content;
find('[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);
if (inquiryUI) inquiryUI.deleted(post.id);
else 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: String(comment.id || comment.comment_id || comment.commentId || '').trim(),
authorId: comment.author_id || comment.authorId || comment.requester_id || comment.requesterId || '',
author: comment.author_name || comment.authorName || comment.author?.name || comment.author || '작성자',
date: comment.date || formatKoreanDateTime(comment.created_at || comment.createdAt || comment.updated_at || '', true),
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('');
}
let editingCommentId = null;
const objectUrls = new Set();
const previewUrls = new Set();
function imageObjectUrl(file, preview) {
const url = URL.createObjectURL(file);
objectUrls.add(url);
if (preview) previewUrls.add(url);
return url;
}
function clearImagePreview() {
previewUrls.forEach(function (url) { URL.revokeObjectURL(url); objectUrls.delete(url); });
previewUrls.clear();
}
if (inquiryUI) inquiryUI.onCleanup(root, function () { objectUrls.forEach(function (url) { URL.revokeObjectURL(url); }); });
function isCommentOwner(comment, user) {
return matchesAuthorId(comment.authorId, user);
}
function renderComments(comments) {
comments = (comments || []).map(normalizeComment).filter(function (comment) { return comment.content; });
const usedUrls = new Set(comments.flatMap(function (comment) {
return comment.attachments.flatMap(function (attachment) { return [attachment.url, attachment.thumbnailUrl]; });
}));
objectUrls.forEach(function (url) {
if (!usedUrls.has(url) && !previewUrls.has(url)) { URL.revokeObjectURL(url); objectUrls.delete(url); }
});
find('#commentCount').textContent = comments.length;
const user = detailUser;
find('#comments').innerHTML = comments.length ? comments.map(function (comment) {
const owner = Boolean(comment.id) && isCommentOwner(comment, user);
const editing = Boolean(editingCommentId) && editingCommentId === comment.id && owner;
const actions = owner ? '<div class="comment-actions"><button class="button button-outline" type="button" data-comment-edit="' + escapeHtml(comment.id) + '">수정</button><button class="button button-danger" type="button" data-comment-delete="' + escapeHtml(comment.id) + '">삭제</button></div>' : '';
const content = editing ? '<textarea class="textarea comment-edit-input" data-comment-edit-input maxlength="5000">' + escapeHtml(comment.content) + '</textarea><div class="comment-actions"><button class="button button-outline" type="button" data-comment-cancel>취소</button><button class="button button-primary" type="button" data-comment-save="' + escapeHtml(comment.id) + '">저장</button></div>' : '<div class="comment-content">' + escapeHtml(comment.content) + '</div>';
return '<div class="comment" data-comment-id="' + escapeHtml(comment.id) + '"><div class="comment-meta"><strong>' + escapeHtml(comment.author) + '</strong><span>' + escapeHtml(comment.date) + '</span></div>' + content + (comment.attachments.length ? '<div class="comment-attachments-list">' + renderCommentAttachments(comment.attachments) + '</div>' : '') + (editing ? '' : actions) + '</div>';
}).join('') : '<div class="comment-empty">등록된 답변이 없습니다.</div>';
const storedComments = getComments();
storedComments[post.id] = comments;
localStorage.setItem(COMMENTS_KEY, JSON.stringify(storedComments));
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);
});
find('#comments').addEventListener('click', async function (event) {
const editButton = event.target.closest('[data-comment-edit]');
const cancelButton = event.target.closest('[data-comment-cancel]');
const saveButton = event.target.closest('[data-comment-save]');
const deleteButton = event.target.closest('[data-comment-delete]');
if (editButton) {
editingCommentId = editButton.getAttribute('data-comment-edit');
comments = renderComments(comments);
const input = find('[data-comment-edit-input]');
if (input) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }
return;
}
if (cancelButton) {
const input = find('[data-comment-edit-input]');
if (inquiryUI && input && !inquiryUI.discardEditor(input)) return;
editingCommentId = null; comments = renderComments(comments); return;
}
if (saveButton) {
const commentId = saveButton.getAttribute('data-comment-save');
const comment = comments.find(function (item) { return item.id === commentId; });
const input = find('[data-comment-edit-input]');
const content = input ? input.value.trim() : '';
if (!content) { showToast('댓글 내용을 입력해주세요.'); return; }
saveButton.disabled = true;
try {
const result = await requestJson(commentsPath + '/' + encodeURIComponent(commentId), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: content }) });
const updatedComment = normalizeComment(Object.assign({}, comment, result.comment || result, { content: content }));
comments = comments.map(function (item) { return item.id === commentId ? updatedComment : item; });
editingCommentId = null;
renderComments(comments);
showToast('댓글이 수정되었습니다.');
} catch (error) { showToast(error.message || '댓글 수정에 실패했습니다.'); saveButton.disabled = false; }
return;
}
if (deleteButton) {
const commentId = deleteButton.getAttribute('data-comment-delete');
if (!window.confirm('이 댓글을 삭제하시겠습니까?')) return;
deleteButton.disabled = true;
try {
await requestJson(commentsPath + '/' + encodeURIComponent(commentId), { method: 'DELETE' });
comments = comments.filter(function (item) { return item.id !== commentId; });
renderComments(comments);
showToast('댓글이 삭제되었습니다.');
} catch (error) { showToast(error.message || '댓글 삭제에 실패했습니다.'); deleteButton.disabled = false; }
}
});
const commentForm = find('#commentForm');
if (commentForm) commentForm.addEventListener('submit', async function (event) {
event.preventDefault();
const contentInput = find('#commentContent');
const errorBox = find('#commentError');
const submitButton = find('#commentSubmit');
const imageInput = find('#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 = imageObjectUrl(file); return { name: file.name, url: url, thumbnailUrl: url }; });
const savedComment = normalizeComment(Object.assign({
content: content,
author: (getAuthUser() || {}).name || '작성자',
createdAt: new Date().toISOString(),
attachments: localAttachments
}, result.comment || result.data || result));
if (!savedComment.id) savedComment.id = normalizeComment(result).id;
if (!savedComment.authorId) savedComment.authorId = (getAuthUser() || {}).requesterId || '';
if (!savedComment.attachments.length) savedComment.attachments = localAttachments;
comments.push(savedComment);
renderComments(comments);
contentInput.value = '';
if (imageInput) imageInput.value = '';
const imagePreview = find('#commentImagePreview');
if (imagePreview) imagePreview.innerHTML = '';
clearImagePreview();
if (inquiryUI) inquiryUI.markClean(commentForm);
} catch (error) {
errorBox.textContent = error.message || '댓글 등록에 실패했습니다.';
errorBox.hidden = false;
} finally {
submitButton.disabled = false;
submitButton.textContent = '댓글 등록';
}
});
const commentImages = find('#commentImages');
const commentImagePreview = find('#commentImagePreview');
const commentImageLabel = find('label[for="commentImages"]');
if (commentImageLabel) commentImageLabel.textContent = '댓글 이미지 첨부';
if (commentImages && commentImagePreview) commentImages.addEventListener('change', function () {
clearImagePreview();
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 = imageObjectUrl(file, true);
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 && (!inquiryUI || inquiryUI.isCurrent(root))) {
renderDetailStatus();
if (inquiryUI) inquiryUI.changed(post);
}
});
}
function initInquiryUI(list) {
const dialogs = { write: qs('#composeModal'), detail: qs('#detailModal') };
if (!dialogs.write || !dialogs.detail) return;
const baselines = new WeakMap();
let active = null;
let restoringHistory = false;
let depth = 0;
function snapshot(form) {
return JSON.stringify(Array.from(form.elements).filter(function (input) {
return !input.readOnly && !input.disabled && !['submit', 'button'].includes(input.type);
}).map(function (input) {
return [input.id, input.type === 'file' ? Array.from(input.files).map(function (file) { return [file.name, file.size, file.lastModified]; }) : input.type === 'checkbox' ? input.checked : input.value];
}));
}
function dirty(form) { return !form.hidden && snapshot(form) !== baselines.get(form); }
function dirtyEditor(input) { return input.value !== input.defaultValue; }
function busy() { return active && Boolean(active.root.querySelector('button:disabled')); }
function hasChanges() {
return active && (qsa('form', active.root).some(dirty) || qsa('[data-comment-edit-input]', active.root).some(dirtyEditor));
}
function confirmDiscard() { return window.confirm('작성 중인 내용이 있습니다. 닫으면 입력한 내용이 사라집니다. 닫으시겠습니까?'); }
function canLeave(force) {
if (force || !active) return true;
if (busy()) { showToast('처리가 끝난 후 닫을 수 있습니다.'); return false; }
return !hasChanges() || confirmDiscard();
}
function clear(restoreFocus) {
if (!active) return;
const previous = active;
active = null;
previous.dialog.close();
previous.cleanups.forEach(function (cleanup) { cleanup(); });
const toast = qs('#toast');
if (toast) document.body.append(toast);
previous.dialog.querySelector('[data-modal-content]').replaceChildren();
document.documentElement.classList.remove('inquiry-modal-open');
if (restoreFocus) {
window.scrollTo({ top: previous.scroll, behavior: 'instant' });
const opener = previous.opener && previous.opener.isConnected ? previous.opener : qs('#writeButton') || qs('#boardHeading');
if (opener) opener.focus({ preventScroll: true });
}
}
function routeURL(view, id) {
const url = new URL(window.location.href);
url.searchParams.delete('view'); url.searchParams.delete('id');
if (view) url.searchParams.set('view', view);
if (view === 'detail' && id) url.searchParams.set('id', id);
return url;
}
async function mount(view, id, opener) {
const scroll = active ? active.scroll : window.scrollY;
const previousOpener = active && active.opener;
clear(false);
const dialog = dialogs[view];
if (!dialog) return;
const root = document.createElement('div');
root.setAttribute('aria-busy', view === 'detail' ? 'true' : 'false');
root.append(qs(view === 'write' ? '#writeTemplate' : '#detailTemplate').content.cloneNode(true));
dialog.querySelector('[data-modal-content]').replaceChildren(root);
const toast = qs('#toast');
if (toast) dialog.append(toast);
active = { view: view, id: id, root: root, dialog: dialog, opener: opener || previousOpener, scroll: scroll, cleanups: [] };
qsa('form', root).forEach(function (form) { baselines.set(form, snapshot(form)); });
document.documentElement.classList.add('inquiry-modal-open');
dialog.showModal();
const heading = root.querySelector('h2');
if (heading) heading.focus({ preventScroll: true });
try {
if (view === 'write') initWrite(root);
else if (id) await initDetail(root, id);
else {
root.querySelector('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
root.querySelector('.comment-box').hidden = true;
}
} catch (error) {
if (active && active.root === root) showToast('화면을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.');
console.warn('[QA] dialog initialization failed:', error.message);
} finally {
root.setAttribute('aria-busy', 'false');
}
}
function open(view, id, opener, options) {
options = options || {};
if (!canLeave(options.force)) return;
const url = routeURL(view, id);
if (active || options.replace) history.replaceState({ qaModal: true }, '', url);
else history.pushState({ qaModal: true }, '', url);
depth = 1;
mount(view, id, opener);
}
function close(force) {
if (!canLeave(force)) return;
clear(true);
if (depth === 1) history.back();
else history.replaceState({ qaModal: false }, '', routeURL());
}
inquiryUI = {
open: open,
isCurrent: function (root) { return Boolean(active && active.root === root); },
markClean: function (form) { baselines.set(form, snapshot(form)); },
discardForm: function (form) { return !dirty(form) || confirmDiscard(); },
discardEditor: function (input) { return !dirtyEditor(input) || confirmDiscard(); },
onCleanup: function (root, callback) { if (active && active.root === root) active.cleanups.push(callback); else callback(); },
changed: function (post) { if (list) list.upsert(post); },
saved: function (post) { if (list) list.upsert(post); open('detail', post.id, null, { force: true, replace: true }); },
deleted: function (id) { if (list) list.remove(id); close(true); }
};
qsa('[data-open-write]').forEach(function (button) {
button.addEventListener('click', function (event) { event.preventDefault(); open('write', null, button); });
});
Object.values(dialogs).forEach(function (dialog) {
dialog.addEventListener('cancel', function (event) { event.preventDefault(); close(false); });
dialog.addEventListener('click', function (event) {
if (event.target.closest('[data-modal-close]')) { close(false); return; }
const rect = dialog.getBoundingClientRect();
if (event.target === dialog && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) close(false);
});
});
window.addEventListener('beforeunload', function (event) {
if (hasChanges() || busy()) { event.preventDefault(); event.returnValue = ''; }
});
window.addEventListener('popstate', function (event) {
if (restoringHistory) { restoringHistory = false; return; }
const nextDepth = event.state && event.state.qaModal ? 1 : 0;
if (!canLeave(false)) {
restoringHistory = true;
history.go(depth > nextDepth ? 1 : -1);
return;
}
depth = nextDepth;
const url = new URL(window.location.href);
const view = url.searchParams.get('view');
if (view === 'write' || view === 'detail') mount(view, url.searchParams.get('id'));
else clear(true);
});
const initial = new URL(window.location.href);
const view = initial.searchParams.get('view');
history.replaceState({ qaModal: false }, '', routeURL());
if (view === 'write' || view === 'detail') open(view, initial.searchParams.get('id'));
}
document.addEventListener('DOMContentLoaded', function () {
normalizePageHeading(); bindGlobal();
const list = initList();
if (qs('#composeModal')) initInquiryUI(list);
else { initWrite(); initDetail(); }
});
})();