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.
This commit is contained in:
root committed 2026-11-02 13:11:47 +09:00
1 parent dbf5dc3611
commit c62630e22d
19 files changed
+1362 -277

No files matched your search

+301 -80
View File
@@ -20,6 +20,7 @@
};
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)); }
@@ -590,6 +591,14 @@
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); }
@@ -621,15 +630,19 @@
const isNotice = post.category === "공지사항";
const number = isNotice ? "공지" : filtered.length - ((page - 1) * pageSize + index);
const status = normalizeStatus(post.status);
const statusClass = status === "답변완료" ? "done" : (status === "문의검토" || status === "정밀검토" ? "review" : "");
const 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>" + escapeHtml(company) + "</td><td>" + escapeHtml(post.department) + "</td><td>" + escapeHtml(post.author) + "</td>" +
"<td class=\"subject\"><span class=\"subject-link\">" + (post.secret ? "<span class=\"lock\">🔒</span>" : "") + escapeHtml(post.title) + "</span>" + (!isNotice && post.date === "2026-09-17" ? "<span class=\"new-mark\">N</span>" : "") + "</td>" +
"<td>" + escapeHtml(post.dateTime || post.date) + "</td><td>" + (status ? "<span class=\"status " + statusClass + "\">" + escapeHtml(status) + "</span>" : "-") + "</td></tr>";
'<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 () { window.location.href = "detail.html?id=" + encodeURIComponent(row.dataset.id); }); });
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>' : '';
@@ -642,8 +655,8 @@
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 = result.posts; render(); }).then(function (result) {
posts = result.posts;
loadRemotePosts(function (result) { receivedRemotePage = true; posts = mergeChanges(result.posts); render(); }).then(function (result) {
posts = mergeChanges(result.posts);
remoteLoaded = true;
render();
}).catch(function (error) {
@@ -653,16 +666,26 @@
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() {
const form = qs('#qaForm');
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 = qs('#formError');
if (!user) {
qs('#writeFields').setAttribute('aria-disabled', 'true');
}
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');
@@ -671,17 +694,17 @@
console.info('[QA] SSO user resolved:', Boolean(currentUser), currentUser ? { hasRequesterId: Boolean(currentUser.requesterId), hasTenantId: Boolean(currentUser.requesterTenantId), hasPhone: Boolean(currentUser.phone) } : null);
if (!currentUser) { errorBox.textContent = '로그인 후 문의를 등록할 수 있습니다.'; errorBox.style.display = 'block'; return; }
if (!currentUser.requesterId || !currentUser.requesterTenantId) { errorBox.textContent = 'SSO에서 작성자 UUID와 테넌트 정보를 확인하지 못했습니다. 다시 로그인한 뒤 시도해주세요.'; errorBox.style.display = 'block'; return; }
const title = qs('#title').value.trim();
const content = qs('#content').value.trim();
if (!qs('#category').value || !title || !content) { errorBox.textContent = '구분, 제목, 내용을 모두 입력해주세요.'; errorBox.style.display = 'block'; return; }
const 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: qs('#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: qs('#secret').checked, content: content };
const submitButton = qs('#submitButton');
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, qs('#attachment').files);
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);
@@ -689,7 +712,8 @@
post.attachments = submission.attachments;
savePost(post);
showToast(result.local ? '테스트 글로 저장했습니다. UUID가 생성되었습니다.' : '문의가 등록되었습니다.');
window.setTimeout(function () { window.location.href = 'detail.html?id=' + encodeURIComponent(post.id); }, 500);
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 || '등록 중 오류가 발생했습니다.';
@@ -700,14 +724,15 @@
});
}
async function initDetail() {
const detail = qs('#detail');
async function initDetail(root, requestedId) {
const find = function (selector, container) { return qs(selector, container || root); };
const detail = find('#detail');
if (!detail) return;
const detailListLink = qs('.detail-actions > a', detail);
const detailListLink = find('.detail-actions > a', detail);
if (detailListLink) detailListLink.textContent = '목록으로';
const bottomBackButton = qs('#backButton', detail);
const bottomBackButton = find('#backButton', detail);
if (bottomBackButton && bottomBackButton.parentElement) bottomBackButton.parentElement.remove();
const id = new URLSearchParams(window.location.search).get("id") || "EG-1048";
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 {
@@ -718,42 +743,45 @@
console.warn("[QA] feedback detail load failed:", error.message);
}
if (!post) {
qs('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
qs('[data-detail-content]').textContent = '목록에 등록된 문의가 없습니다.';
find('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
find('[data-detail-content]').textContent = '목록에 등록된 문의가 없습니다.';
const commentBox = find('.comment-box');
if (commentBox) commentBox.hidden = true;
return;
}
qs('[data-detail-category]').textContent = post.category;
qs('[data-detail-title]').textContent = post.title;
find('[data-detail-category]').textContent = post.category;
find('[data-detail-title]').textContent = post.title;
const renderDetailStatus = function () {
const statusElement = qs("[data-detail-status]");
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();
qs('[data-detail-date]').textContent = post.dateTime || post.date;
qs('[data-detail-author]').textContent = post.author;
find('[data-detail-date]').textContent = post.dateTime || post.date;
find('[data-detail-author]').textContent = post.author;
const detailUser = await (authReady || Promise.resolve(getAuthUser()));
qs('[data-detail-company]').textContent = getPostCompanyLabel(post, detailUser);
qs('[data-detail-department]').textContent = post.department;
qs('[data-detail-content]').textContent = post.content;
qs('[data-detail-secret]').hidden = !post.secret;
const attachmentBox = qs('[data-detail-attachments]');
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 = qs('#editButton');
const deleteButton = qs('#deleteButton');
const editForm = qs('#editForm');
const editError = qs('#editError');
const completionNotice = qs("#completionNotice");
const completionDoneNotice = qs("#completionDoneNotice");
const completionButton = qs("#completionButton");
const 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;
@@ -778,6 +806,7 @@
post.completionConfirmed = true;
post.status = "완료";
updateStoredPost(post);
if (inquiryUI) inquiryUI.changed(post);
renderDetailStatus();
renderCompletionNotice();
showToast("문의가 완료 처리되었습니다.");
@@ -789,49 +818,55 @@
}
});
if (editButton && editForm) editButton.addEventListener('click', function () {
qs('#editCategory').value = post.category || '일반문의';
qs('#editTitle').value = post.title || '';
qs('#editContent').value = post.content || '';
qs('#editSecret').checked = Boolean(post.secret);
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);
});
const editCancel = qs('#editCancel');
if (editCancel && editForm) editCancel.addEventListener('click', function () { editForm.hidden = true; detail.classList.remove('is-editing'); });
if (editForm) editForm.addEventListener('submit', async function (event) {
event.preventDefault();
editError.hidden = true;
editError.style.display = 'none';
const title = qs('#editTitle').value.trim();
const content = qs('#editContent').value.trim();
const category = qs('#editCategory').value;
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 = qs('button[type="submit"]', editForm);
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: qs('#editSecret').checked ? 'true' : 'false' })
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 = qs('#editSecret').checked;
post.secret = find('#editSecret').checked;
updateStoredPost(post);
qs('[data-detail-category]').textContent = post.category;
qs('[data-detail-title]').textContent = post.title;
qs('[data-detail-content]').textContent = post.content;
qs('[data-detail-secret]').hidden = !post.secret;
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('문의가 수정되었습니다.');
@@ -850,7 +885,8 @@
try {
await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), { method: 'DELETE' });
removeStoredPost(post.id);
window.location.href = 'index.html';
if (inquiryUI) inquiryUI.deleted(post.id);
else window.location.href = 'index.html';
} catch (error) {
deleteButton.disabled = false;
showToast(error.message || '문의 삭제에 실패했습니다.');
@@ -896,15 +932,34 @@
}
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; });
qs('#commentCount').textContent = comments.length;
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;
qs('#comments').innerHTML = comments.length ? comments.map(function (comment) {
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>' : '';
@@ -923,7 +978,7 @@
}).catch(function (error) {
console.warn('[QA] comments load failed:', error.message);
});
qs('#comments').addEventListener('click', async function (event) {
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]');
@@ -931,15 +986,19 @@
if (editButton) {
editingCommentId = editButton.getAttribute('data-comment-edit');
comments = renderComments(comments);
const input = qs('[data-comment-edit-input]');
const input = find('[data-comment-edit-input]');
if (input) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }
return;
}
if (cancelButton) { editingCommentId = null; comments = renderComments(comments); 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 = qs('[data-comment-edit-input]');
const input = find('[data-comment-edit-input]');
const content = input ? input.value.trim() : '';
if (!content) { showToast('댓글 내용을 입력해주세요.'); return; }
saveButton.disabled = true;
@@ -965,13 +1024,13 @@
} catch (error) { showToast(error.message || '댓글 삭제에 실패했습니다.'); deleteButton.disabled = false; }
}
});
const commentForm = qs('#commentForm');
const commentForm = find('#commentForm');
if (commentForm) commentForm.addEventListener('submit', async function (event) {
event.preventDefault();
const contentInput = qs('#commentContent');
const errorBox = qs('#commentError');
const submitButton = qs('#commentSubmit');
const imageInput = qs('#commentImages');
const 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;
@@ -988,7 +1047,7 @@
files.forEach(function (file) { payload.append('attachments', file, file.name); });
}
const result = await postToApi(commentsPath, payload);
const localAttachments = files.map(function (file) { const url = URL.createObjectURL(file); return { name: file.name, url: url, thumbnailUrl: url }; });
const 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 || '작성자',
@@ -1002,8 +1061,10 @@
renderComments(comments);
contentInput.value = '';
if (imageInput) imageInput.value = '';
const imagePreview = qs('#commentImagePreview');
const imagePreview = find('#commentImagePreview');
if (imagePreview) imagePreview.innerHTML = '';
clearImagePreview();
if (inquiryUI) inquiryUI.markClean(commentForm);
} catch (error) {
errorBox.textContent = error.message || '댓글 등록에 실패했습니다.';
errorBox.hidden = false;
@@ -1012,21 +1073,181 @@
submitButton.textContent = '댓글 등록';
}
});
const commentImages = qs('#commentImages');
const commentImagePreview = qs('#commentImagePreview');
const commentImageLabel = qs('label[for="commentImages"]');
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 = URL.createObjectURL(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) renderDetailStatus();
if (changed && (!inquiryUI || inquiryUI.isCurrent(root))) {
renderDetailStatus();
if (inquiryUI) inquiryUI.changed(post);
}
});
}
document.addEventListener('DOMContentLoaded', function () { normalizePageHeading(); bindGlobal(); initList(); initWrite(); initDetail(); });
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(); }
});
})();