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.
1254 lines
68 KiB
JavaScript
1254 lines
68 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 = {
|
||
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 { '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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(); }
|
||
});
|
||
})();
|