",
+ "title": "문의 제목",
+ "contents": "문의 내용",
+ "Category": "GENERAL_QNA",
+ "is_secret": "false"
+}
```
-### B. 중앙 SSO
+- 첨부가 없으면 JSON으로 전송한다.
+- 첨부가 있으면 같은 필드와 반복 `images` 파일 필드를 `FormData`에 넣는다. `Content-Type`의 boundary는 브라우저가 지정한다.
+- 기존 Worker가 세션과 첨부를 검증하고 API Key 및 서버 작성자 정보를 붙여 ABC에 전달한다. 파일당 제한은 30MB다.
+- 브라우저가 presign API나 R2 PUT을 직접 호출하지 않는다. API base가 빈 문자열이면 현재 호스트의 Worker를 사용한다.
+- API 성공 후 기존 localStorage 캐시를 갱신하고 상세 모달을 연다. 실패를 로컬 저장 성공으로 처리하지 않는다.
+- 등록 중에는 중복 클릭과 모달 닫기를 막고, 실패하면 입력·파일을 유지하며 재시도할 수 있게 한다.
-- Q&A RP는 `https://sso.hmac.kr/oidc`를 사용한다.
-- 최초 사용자는 SSO 로그인 및 동의 화면을 볼 수 있다.
-- 같은 중앙 SSO 세션이 있으면 다음 접근부터 로그인 화면 없이 authorization code가 발급된다.
-- Q&A RP는 PKCE Public Client이므로 client secret을 보내지 않는다.
+## 상세·수정·댓글
-### C. Q&A 목록
+| 동작 | 기존 Worker 요청 |
+| --- | --- |
+| 목록 | `GET /api/feedbacks?limit=100&page=...` |
+| 상세 | `GET /api/feedbacks/{id}` |
+| 수정 | `PATCH /api/feedbacks/{id}`: `title`, `contents`, `Category`, `is_secret` |
+| 삭제 | `DELETE /api/feedbacks/{id}` |
+| 댓글 조회·등록 | `GET/POST /api/feedbacks/{id}/comments` |
+| 댓글 수정·삭제 | `PATCH/DELETE /api/feedbacks/{id}/comments/{commentId}` |
+| 완료 확인 | `POST /api/feedbacks/{id}/completion-confirmation` |
-- Worker가 `baron_qa_session_v2`를 검사한다.
-- 세션이 없으면 `/auth/login?return_url=...`로 이동한다.
-- 세션이 있으면 R2의 `egbim/index.html`을 제공한다.
+댓글 텍스트는 JSON `content`, 이미지 첨부 시에는 multipart `content`와 반복 `attachments` 필드를 사용한다. 기존 다중 이미지·형식·30MB 제한과 미리보기를 유지한다. 폐기된 미리보기와 모달을 닫을 때 임시 Blob URL을 해제한다.
-### D. Q&A 작성
+수정·삭제 버튼은 검증된 기본 requester UUID가 해당 게시글·댓글의 작성자 ID와 같을 때만 표시한다. 이름·이메일 일치로 권한을 부여하지 않는다. 로그인 확인 중·세션 만료·확인 실패·작성자 ID 없음 상태에서는 숨긴다. 게시글과 댓글의 소유자는 각각 판단한다.
-- 브라우저가 `GET /auth/session`을 호출한다.
-- 다음 작성자 정보를 화면과 payload에 사용한다.
+수정·완료 확인은 목록에도 반영하고, 삭제 후에는 목록으로 돌아간다. 늦게 도착한 이전 상세 응답은 닫힌 모달의 분리된 DOM에서 처리해 현재 모달을 덮지 않는다. 오류는 모달 안에 표시한다.
-```text
-userUuid
-ssoSubject / requesterId
-tenantId / requesterTenantId
-tenantIds
-scope
-roles
-email
-name
-department
-phone
-```
+## 확인과 배포
-- `requesterId` 또는 `requesterTenantId`가 없으면 제출을 차단한다.
-
-### E. 글 저장
-
-- 브라우저에서 `feedbackId` UUID를 1회 생성한다.
-- 같은 UUID를 다음 식별자로 재사용한다.
-
-```text
-feedback.id
-feedback.source_record_id
-support_tickets.idempotency_key
-```
-
-- 첨부파일은 `qa_cdn`에 저장하고, DB에는 bucket/key 및 파일 메타데이터를 기록한다.
-- 실제 운영에서는 feedback API가 서버 측에서 Q&A 세션 또는 SSO token을 검증해야 한다.
-
-## 4. 현재 테스트 모드와 운영 모드
-
-현재 `egbim/config.js`는 다음과 같이 `apiBaseUrl`이 비어 있다.
-
-```js
-apiBaseUrl: ''
-```
-
-따라서 현재 등록 결과는 다음과 같이 동작한다.
-
-```text
-글 작성 → UUID 생성 → localStorage 저장 → 상세 페이지 이동
-```
-
-운영 API 주소를 설정하면 다음 흐름으로 변경된다.
-
-```text
-첨부 presign 요청
-→ qa_cdn 직접 업로드
-→ feedback.hmac.kr/v1/qa/feedbacks 호출
-→ feedbacks/support_tickets 트랜잭션 저장
-→ 상세 페이지 이동
-```
-
-## 5. 주요 실패 전이
-
-| 실패 지점 | 사용자 화면 | 원인/확인 항목 |
-|---|---|---|
-| SSO 로그인 전 | 로그인 화면 | 중앙 SSO 세션 없음 또는 다른 SSO 환경 |
-| callback | `invalid_oauth_state` | OAuth cookie 만료, 호스트 변경, state 불일치 |
-| token 교환 | `oauth_token_exchange_failed` | issuer/endpoint/client ID/PKCE 설정 불일치 |
-| 세션 확인 | `authenticated:false` | `baron_qa_session_v2` 없음·만료·`SESSION_SECRET` 불일치 |
-| 작성자 확인 | UUID/tenant 오류 | SSO scope 또는 claim mapping 누락 |
-| 첨부 업로드 | 업로드 실패 | presign API 또는 `qa_cdn` 권한 오류 |
-| DB 저장 | API 요청 오류 | `apiBaseUrl`, CORS, API 인증, DB envelope 규격 오류 |
+- 기본 검사: `npm run check`, `npm test`.
+- 별도 브라우저 검사: `npm run test:ui`. Playwright/Chromium 준비와 검증 범위는 [UI 작업 목록](qa-inquiry-ui-redesign-tasks.md#브라우저-회귀-검증)을 참고한다. 모든 요청을 모의 처리하며 실제 서버 데이터를 변경하지 않는다.
+- 원격 push는 사용자가 진행한다. 스테이징은 기존 main push 자동 배포, 운영은 기존 수동 배포 절차를 사용한다.
+- 실제 로그인·관리페이지 저장 연동은 스테이징 배포 후 확인한다. 모의 API 검증만으로 실서버 검증 완료로 표시하지 않는다.
diff --git a/egbim/app.js b/egbim/app.js
index 0391b5d..a669933 100644
--- a/egbim/app.js
+++ b/egbim/app.js
@@ -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 "" +
"| " + number + " | " + escapeHtml(post.category.replace("문의", "")) + " | " +
- "" + escapeHtml(company) + " | " + escapeHtml(post.department) + " | " + escapeHtml(post.author) + " | " +
- "" + (post.secret ? "🔒" : "") + escapeHtml(post.title) + "" + (!isNotice && post.date === "2026-09-17" ? "N" : "") + " | " +
- "" + escapeHtml(post.dateTime || post.date) + " | " + (status ? "" + escapeHtml(status) + "" : "-") + " |
";
+ '' + escapeHtml(company) + ' | ' + escapeHtml(post.department) + ' | ' + escapeHtml(post.author) + ' | ' +
+ '' + (post.secret ? '🔒' : '') + '' + escapeHtml(post.title) + ' | ' +
+ '' + escapeHtml(post.dateTime || post.date) + ' | ' + (status ? '' + escapeHtml(status) + '' : '-') + ' | ';
}).join("");
- qsa("#qaRows tr").forEach(function (row) { row.addEventListener("click", function () { window.location.href = "detail.html?id=" + encodeURIComponent(row.dataset.id); }); });
+ 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 = '' + visiblePages.map(function (value, index) {
const gap = index > 0 && value - visiblePages[index - 1] > 1 ? '…' : '';
@@ -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 '📎 ' + escapeHtml(file.name) + '
';
return '';
}).join('') : '첨부된 파일이 없습니다.';
- const editButton = qs('#editButton');
- const deleteButton = qs('#deleteButton');
- const editForm = qs('#editForm');
- const editError = qs('#editError');
- const completionNotice = qs("#completionNotice");
- const completionDoneNotice = qs("#completionDoneNotice");
- const completionButton = qs("#completionButton");
+ const 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 ? '' : '';
@@ -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 '';
}).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(); }
+ });
})();
diff --git a/egbim/detail.html b/egbim/detail.html
index c718c34..83ac042 100644
--- a/egbim/detail.html
+++ b/egbim/detail.html
@@ -1,8 +1 @@
-
-
-문의 상세 | EG-BIM Q&A
-
- EG-BIM SUPPORTQ&A
EG-BIM 관련 문의하기
- 문의 상세
문의 내용과 답변을 확인할 수 있습니다.
문의 문의 제목
문의접수2026-09-17작성자회사부서🔒 비밀글
처리 완료 안내담당자가 처리를 완료했습니다. 내용을 확인하셨다면 완료 확인을 눌러주세요.
완료 처리 된 요청 건입니다.
-
-
+S/W 문의 | 바론컨설턴트문의 화면으로 이동합니다. 문의 목록
diff --git a/egbim/index.html b/egbim/index.html
index a6b5587..98e1ca2 100644
--- a/egbim/index.html
+++ b/egbim/index.html
@@ -1,29 +1,208 @@
-
-
-
-
-
- EG-BIM Q&A | 바론컨설턴트
-
-
-
-
-
- EG-BIM SUPPORTQ&A
EG-BIM 관련 문의하기
-
-
-
- 문의하기(Q&A)
-
-
- EG-BIM 문의 목록| 번호 | 구분 | 회사 | 부서 | 작성자 | 제목 | 등록일 | 상태 |
|---|
검색 조건에 맞는 문의가 없습니다.
-
-
-
-
-
-
-
+S/W 문의 | 바론컨설턴트
+
+
+
+
S/W 문의
+
소프트웨어 관련 문의하기
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+문의등록
+로그인 상태를 확인하고 있습니다.
+
+
+
+
+
+
문의 내용을 불러오는 중입니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🔒 비밀글
+
+
+
+
+
+
첨부파일
+
+ 첨부된 파일이 없습니다.
+
+
+
+
+
처리 완료 안내
+
담당자가 처리를 완료했습니다. 내용을 확인하셨다면 완료 확인을 눌러주세요.
+
+
+
+ 완료 처리 된 요청 건입니다.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/egbim/write.html b/egbim/write.html
index ae7d7d8..83ac042 100644
--- a/egbim/write.html
+++ b/egbim/write.html
@@ -1,8 +1 @@
-
-
-문의 등록 | EG-BIM Q&A
-
- EG-BIM SUPPORTQ&A
EG-BIM 관련 문의하기
- 문의 등록
로그인한 사용자 정보로 문의가 등록됩니다.
로그인 상태를 확인하고 있습니다.
-
-
+S/W 문의 | 바론컨설턴트문의 화면으로 이동합니다. 문의 목록
diff --git a/package.json b/package.json
index 8469fe5..9a87df2 100644
--- a/package.json
+++ b/package.json
@@ -6,8 +6,9 @@
"deploy": "wrangler deploy",
"deploy:production": "node scripts/deploy-production.mjs",
"r2:upload": "bash scripts/upload-r2.sh",
- "check": "node --check src/index.js && node --check src/org-context.js && node --check egbim/app.js && node --check scripts/verify-org-context.mjs && node --check scripts/production-settings.mjs && node --check scripts/deploy-production.mjs && node --check scripts/verify-production.mjs && bash -n scripts/upload-r2.sh",
+ "check": "node --check src/index.js && node --check src/org-context.js && node --check egbim/app.js && node --check assets/inquiry/entry.js && node --check scripts/verify-org-context.mjs && node --check scripts/production-settings.mjs && node --check scripts/deploy-production.mjs && node --check scripts/verify-production.mjs && bash -n scripts/upload-r2.sh",
"test": "node --test --test-isolation=none tests/*.test.js",
+ "test:ui": "node tests/browser/inquiry-ui.mjs",
"org:verify": "node scripts/verify-org-context.mjs",
"production:verify": "node scripts/verify-production.mjs"
},
diff --git a/scripts/upload-r2.sh b/scripts/upload-r2.sh
index 47c8316..f22f48e 100755
--- a/scripts/upload-r2.sh
+++ b/scripts/upload-r2.sh
@@ -33,6 +33,7 @@ while IFS= read -r file; do
*.woff2) content_type="font/woff2" ;;
*.svg) content_type="image/svg+xml" ;;
*.png) content_type="image/png" ;;
+ *.webp) content_type="image/webp" ;;
*.jpg|*.jpeg) content_type="image/jpeg" ;;
*) content_type="application/octet-stream" ;;
esac
diff --git a/tests/browser/inquiry-ui.mjs b/tests/browser/inquiry-ui.mjs
new file mode 100644
index 0000000..abcd032
--- /dev/null
+++ b/tests/browser/inquiry-ui.mjs
@@ -0,0 +1,185 @@
+// Isolated browser regression: every network request is served locally or mocked.
+// Requires Playwright and Chromium; see docs/qa-inquiry-ui-redesign-tasks.md.
+import assert from 'node:assert/strict';
+import {readFile, mkdir} from 'node:fs/promises';
+import {createRequire} from 'node:module';
+import {fileURLToPath} from 'node:url';
+import {join} from 'node:path';
+import {tmpdir} from 'node:os';
+const require = createRequire(import.meta.url);
+const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
+const root = fileURLToPath(new URL('../../', import.meta.url)).replace(/\/$/, '');
+const OWNER = '9951229e-eea5-4376-96f4-78989bc3a506';
+const id = n => '01900000-0000-7000-8000-' + String(n).padStart(12, '0');
+const browser = await chromium.launch({executablePath:process.env.CHROMIUM_EXECUTABLE_PATH || undefined,headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
+const images = process.env.UI_SCREENSHOT_DIR || join(tmpdir(), 'qa-inquiry-ui');
+await mkdir(images,{recursive:true});
+const errors = [];
+try {
+ const context = await browser.newContext({viewport:{width:1900,height:1000}});
+ const page = await context.newPage();page.setDefaultTimeout(8000);page.setDefaultNavigationTimeout(8000);
+ page.on('pageerror', error => errors.push(error.message));
+ const records = Array.from({length:15},(_,i)=>({id:id(i+1),requester_id:OWNER,requester_name:'송대일',requester_affiliation:'총괄기획&기술개발센터',requester_department:'IS3',title:['PDF 출력 시 이미지 표시 관련 문의','EG-BIM 로그인 오류 문의','모델링 기능 개선 요청'][i%3] + (i>2?' '+(i+1):''),contents:'EG-BIM 이용 중 궁금한 사항을 문의합니다.\n\n이 영역에 문의 내용과 첨부파일, 답변이 표시됩니다.',Category:['ERROR_QNA','GENERAL_QNA','IMPROVEMENT_QNA'][i%3],created_at:'2026-11-02T01:14:00Z',feedback_status:['NEW','RECEIVED','IN_PROGRESS','PENDING','COMPLETED'][i%5],is_secret:i===0,attachments:i===0?[{url:'/fixture/manual.pdf',original_file_name:'화면 설명.pdf'}]:[]}));
+ const comments = new Map(records.map(r=>[r.id,[{id:id(101),author_id:OWNER,author_name:'송대일',content:'확인 부탁드립니다.',created_at:'2026-11-02T02:00:00Z'},{id:id(102),author_id:'other',author_name:'고객지원',content:'접수되었습니다.',created_at:'2026-11-02T02:15:00Z'}]]));
+ const mutations = [];
+ let failCreate = false, holdCreate = null, slowId = '', releaseSlow;
+ await page.route('**/*',async route=>{
+ const request = route.request(); const url = new URL(request.url());
+ if(url.origin!=='https://qa.example.test') return route.abort();
+ const send = (body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)});
+ if(url.pathname==='/auth/session') return send({authenticated:true,user:{requesterId:OWNER,userUuid:OWNER,tenantId:id(555),name:'송대일',email:'fixture@example.test',company:'총괄기획&기술개발센터',department:'IS3'}});
+ if(url.pathname.startsWith('/api/')) {
+ const method = request.method(); if(method!=='GET') mutations.push({url:url.pathname,method,body:request.postData(),type:request.headers()['content-type']});
+ if(url.pathname==='/api/feedbacks') {
+ if(method==='GET') return send({items:records,meta:{total:records.length}});
+ if(holdCreate) await holdCreate;
+ if(failCreate) {failCreate=false;return send({message:'모의 등록 오류'},500);}
+ let payload; if(request.headers()['content-type'].includes('application/json')) payload=JSON.parse(request.postData());
+ else {const fields={};const text=request.postData(); for(const name of ['feedbackId','title','contents','Category','is_secret']) fields[name]=text.match(new RegExp('name="'+name+'"\\r\\n\\r\\n([^\\r]*)'))?.[1];payload=fields;}
+ const record={...records[0],...payload,id:payload.feedbackId,attachments:[]};records.unshift(record); comments.set(record.id,[]);return send({id:record.id});
+ }
+ const parts = url.pathname.split('/'); const record=records.find(r=>r.id===parts[3]);
+ if(parts[3]===slowId && method==='GET' && parts.length===4) await new Promise(r=>{releaseSlow=r;});
+ if(!record) return send({message:'not found'},404);
+ if(parts[4]==='comments') {
+ let list=comments.get(record.id)||[];
+ if(method==='GET') return send({comments:list});
+ if(method==='POST') { const content=request.headers()['content-type'].includes('application/json')?JSON.parse(request.postData()).content:request.postData().match(/name="content"\r\n\r\n([^\r]*)/)[1];const comment={id:id(200+list.length),author_id:OWNER,author_name:'송대일',content};list.push(comment);comments.set(record.id,list);return send({comment}); }
+ if(method==='PATCH') {const comment=list.find(c=>c.id===parts[5]);Object.assign(comment,JSON.parse(request.postData()));return send({comment});}
+ if(method==='DELETE') {comments.set(record.id,list.filter(c=>c.id!==parts[5]));return send({ok:true});}
+ }
+ if(parts[4]==='completion-confirmation') {record.feedback_status='COMPLETED'; record.completion_requested=false;return send({ok:true});}
+ if(method==='PATCH') {Object.assign(record,JSON.parse(request.postData()));return send(record);}
+ if(method==='DELETE') {records.splice(records.indexOf(record),1);return send({ok:true});}
+ return send({feedback:record});
+ }
+ if(url.pathname.startsWith('/fixture/')) return route.fulfill({body:'fixture'});
+ const path=url.pathname.endsWith('/')?url.pathname+'index.html':url.pathname;
+ const type={css:'text/css',js:'application/javascript',html:'text/html',svg:'image/svg+xml',webp:'image/webp',woff2:'font/woff2'}[path.split('.').pop()]||'application/octet-stream';
+ try{return route.fulfill({contentType:type,body:await readFile(root+path)});}catch{return route.abort();}
+ });
+ const open = async n=>{await page.locator('#qaRows tr[data-id="'+id(n)+'"] a').click();await page.waitForFunction(()=>document.querySelector('#commentCount')?.textContent==='2');};
+ const close = async()=>{await page.locator('dialog[open] [data-modal-close]').first().click();await page.waitForFunction(()=>!document.querySelector('dialog[open]'));await page.waitForURL(url=>!url.searchParams.has('view'));};
+ const decide = accept => page.once('dialog',d=>accept?d.accept():d.dismiss());
+ await page.goto('https://qa.example.test/egbim/');
+ await page.waitForFunction(()=>document.querySelector('#resultCount').textContent==='총 15건');
+ assert.equal(await page.locator('#tab-tova').isDisabled(),true);assert.equal(await page.locator('.inquiry-remote').getAttribute('href'),'https://939.co.kr/saman/');
+ await page.screenshot({path:images+'/desktop-list.png',fullPage:true});
+ await page.locator('#query').fill('로그임');await page.locator('#searchForm').evaluate(f=>f.requestSubmit());assert.equal(await page.locator('#qaRows tr').count(),5);
+ await page.locator('#qaRows tr a').first().click();await page.waitForSelector('#detailModal[open]');await close();assert.equal(await page.locator('#query').inputValue(),'로그임');
+ await page.locator('#query').fill('');await page.locator('#searchForm').evaluate(f=>f.requestSubmit());
+ await page.locator('#pagination [data-page="2"]').first().click();assert.equal(await page.locator('#qaRows tr').count(),5);
+ await page.locator('#pagination [data-page="1"]').first().click();
+ await open(1);assert.equal(await page.locator('#editButton').isVisible(),true);assert.equal(await page.locator('#editForm').isVisible(),false);
+ assert.equal(await page.locator('[data-detail-attachments] a').getAttribute('href'),'/fixture/manual.pdf');
+ assert.equal(await page.locator('[data-comment-edit="'+id(102)+'"]').count(),0);
+ await page.screenshot({path:images+'/desktop-detail.png'});
+ await page.locator('#editButton').click();await page.locator('#editTitle').fill('수정한 제목');decide(false);await page.locator('#editCancel').click();assert.equal(await page.locator('#editForm').isVisible(),true);
+ await page.locator('#editForm').evaluate(f=>f.requestSubmit());await page.waitForFunction(()=>document.querySelector('[data-detail-title]').textContent==='수정한 제목');
+ await page.locator('[data-comment-edit="'+id(101)+'"]').click();await page.locator('[data-comment-edit-input]').fill('수정한 댓글');decide(false);await page.keyboard.press('Escape');assert.equal(await page.locator('[data-comment-edit-input]').inputValue(),'수정한 댓글');
+ await page.locator('[data-comment-save]').click();await page.waitForFunction(()=>document.querySelector('.comment-content')?.textContent==='수정한 댓글');
+ await page.locator('#commentContent').fill('이미지 댓글');await page.locator('#commentImages').setInputFiles({name:'example.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jqnsAAAAASUVORK5CYII=','base64')});assert.equal(await page.locator('#commentImagePreview img').count(),1);
+ await page.locator('#commentForm').evaluate(f=>f.requestSubmit());await page.waitForFunction(()=>document.querySelector('#commentCount').textContent==='3');assert.match(mutations.at(-1).body,/name="attachments"; filename="example.png"/);
+ decide(true);await page.locator('[data-comment-delete="'+id(202)+'"]').click();await page.waitForFunction(()=>document.querySelector('#commentCount').textContent==='2');await close();assert.match(await page.locator('#qaRows').textContent(),/수정한 제목/);
+ await page.locator('#writeButton').click();await page.waitForFunction(()=>document.querySelector('#writeDepartment')?.value==='IS3');assert.equal(await page.locator('#writeCompany').inputValue(),'총괄기획&기술개발센터');assert.equal(await page.locator('#writeCompany').getAttribute('readonly'),'');
+ await page.screenshot({path:images+'/desktop-write.png'});
+ await page.keyboard.press('Shift+Tab');
+ assert.equal(await page.evaluate(()=>Boolean(document.activeElement.closest('#composeModal'))),true);
+ await page.keyboard.press('Tab');
+ assert.equal(await page.evaluate(()=>Boolean(document.activeElement.closest('#composeModal'))),true);
+ await page.locator('#category').selectOption('일반문의');await page.locator('#title').fill('등록 테스트');await page.locator('#content').fill('문의 내용');await page.locator('#secret').check();await page.locator('#attachment').setInputFiles([{name:'a.txt',mimeType:'text/plain',buffer:Buffer.from('one')},{name:'b.txt',mimeType:'text/plain',buffer:Buffer.from('two')}]);
+ decide(false);await page.keyboard.press('Escape');assert.equal(await page.locator('#attachment').evaluate(i=>i.files.length),2);
+ decide(false);await page.goBack();await page.waitForURL(url=>url.searchParams.get('view')==='write');assert.equal(await page.locator('#title').inputValue(),'등록 테스트');
+ failCreate=true;await page.locator('#qaForm').evaluate(f=>f.requestSubmit());await page.waitForFunction(()=>document.querySelector('#formError').textContent.includes('모의 등록 오류'));assert.equal(await page.locator('#attachment').evaluate(i=>i.files.length),2);
+ let releaseCreate;holdCreate=new Promise(r=>releaseCreate=r);await page.locator('#qaForm').evaluate(f=>f.requestSubmit());await page.waitForFunction(()=>document.querySelector('#submitButton').disabled);await page.keyboard.press('Escape');assert.equal(await page.locator('#composeModal').isVisible(),true);releaseCreate();holdCreate=null;
+ await page.waitForFunction(()=>document.querySelector('#detailModal[open] [data-detail-title]')?.textContent==='등록 테스트');await page.waitForFunction(()=>document.querySelector('#commentCount')?.textContent==='0');
+ assert.match(mutations.at(-1).body,/name="images"; filename="a.txt"/);assert.match(mutations.at(-1).body,/name="images"; filename="b.txt"/);assert.equal(mutations.filter(m=>m.url==='/api/feedbacks'&&m.method==='POST').length,2);
+ decide(true);await page.locator('#deleteButton').click();await page.waitForFunction(()=>!document.querySelector('dialog[open]'));await page.waitForURL(url=>!url.searchParams.has('view'));assert.equal(await page.locator('#resultCount').textContent(),'총 15건');
+ // Direct old addresses and clean browser back/forward.
+ await page.goto('https://qa.example.test/egbim/detail.html?id='+id(2));await page.waitForFunction(()=>document.querySelector('#detailModal[open] [data-detail-title]')?.textContent==='EG-BIM 로그인 오류 문의');await page.goBack();await page.waitForFunction(()=>!document.querySelector('dialog[open]'));await page.goForward();await page.waitForSelector('#detailModal[open]');await close();
+ await page.goto('https://qa.example.test/egbim/write.html');await page.waitForSelector('#composeModal[open]');await page.locator('#title').fill('폐기 확인');decide(false);await page.mouse.click(2,2);assert.equal(await page.locator('#composeModal').isVisible(),true);decide(true);await page.mouse.click(2,2);await page.waitForFunction(()=>!document.querySelector('dialog[open]'));await page.waitForURL(url=>!url.searchParams.has('view'));
+ // Late detail responses stay in detached modal roots.
+ slowId=id(4);await page.locator('#qaRows tr[data-id="'+slowId+'"] a').click();await page.waitForFunction(()=>document.querySelector('#detailModal[open]'));
+ for(let tries=0;!releaseSlow&&tries<400;tries++) await new Promise(r=>setTimeout(r,20));assert.equal(typeof releaseSlow,'function');await close();await open(2);releaseSlow();slowId='';await page.waitForTimeout(100);assert.equal(await page.locator('[data-detail-title]').textContent(),'EG-BIM 로그인 오류 문의');await close();
+ records[0].completion_requested=true;await page.locator('#qaRows tr[data-id="'+records[0].id+'"] a').click();await page.waitForSelector('#completionButton:visible');await page.locator('#completionButton').click();await page.waitForSelector('#completionDoneNotice:visible');await close();assert.equal(await page.locator('#qaRows tr').first().locator('.inquiry-status').textContent(),'완료');
+ // Repeat open/close: one request per submit, no previous draft retained.
+ for(let n=0;n<3;n++){await page.locator('#writeButton').click();assert.equal(await page.locator('#title').inputValue(),'');await close();}
+ await page.locator('#writeButton').click();await page.locator('#category').selectOption('오류문의');await page.locator('#title').fill('JSON 등록');await page.locator('#content').fill('첨부 없이 등록');await page.locator('#qaForm').evaluate(f=>f.requestSubmit());await page.waitForFunction(()=>document.querySelector('[data-detail-title]')?.textContent==='JSON 등록');assert.match(mutations.at(-1).type,/application\/json/);assert.equal(mutations.filter(m=>m.url==='/api/feedbacks'&&m.method==='POST').length,3);await close();
+ await page.setViewportSize({width:390,height:844});await page.evaluate(()=>scrollTo(0,0));await page.screenshot({path:images+'/mobile-list.png',fullPage:true});
+ assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
+ await page.locator('#writeButton').click();assert.equal(await page.locator('.inquiry-form-grid').evaluate(e=>getComputedStyle(e).gridTemplateColumns.split(' ').length),1);await page.screenshot({path:images+'/mobile-write.png'});
+ assert.equal(await page.locator('#composeModal').evaluate(d=>d.scrollHeight>d.clientHeight),true);await close();
+ await page.locator('#qaRows tr a').first().evaluate(a=>a.click());await page.waitForSelector('#commentForm');await page.screenshot({path:images+'/mobile-detail.png'});assert.equal(await page.locator('#detailModal').evaluate(d=>d.getBoundingClientRect().right<=innerWidth),true);await close();
+ await page.goto('https://qa.example.test/egbim/?view=detail&id=missing');
+ await page.waitForFunction(()=>document.querySelector('[data-detail-title]')?.textContent==='문의 내용을 찾을 수 없습니다.');
+ assert.equal(await page.locator('#editButton').isVisible(),false);
+ assert.equal(await page.locator('#commentForm').isVisible(),false);
+ await close();
+ assert.deepEqual(errors,[]);console.log(JSON.stringify({result:'PASS',mutations:mutations.length,screenshots:images}));await context.close();
+ for (const scenario of ['owner', 'other', 'same-name-no-id', 'anonymous-with-stale-user', 'session-unavailable', 'delayed-session']) {
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ const viewer = scenario === 'owner' || scenario === 'delayed-session' ? OWNER : 'ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916';
+ const currentUser = { requesterId: viewer, userUuid: viewer, ssoSubject: viewer, tenantId: 'fixture-tenant', name: '동명이인', email: 'fixture@example.test', loginId: 'fixture@example.test' };
+ let releaseSession;
+ const sessionGate = new Promise(resolve => { releaseSession = resolve; });
+ if (scenario.includes('stale') || scenario === 'session-unavailable') await page.addInitScript(({ OWNER }) => {
+ sessionStorage.setItem('descopeUserId', OWNER);
+ sessionStorage.setItem('loginId', 'fixture@example.test');
+ sessionStorage.setItem('userName', '동명이인');
+ }, { OWNER });
+ await page.route('**/*', async route => {
+ const url = new URL(route.request().url());
+ if (url.origin !== 'https://qa.example.test') return route.abort();
+ if (route.request().method() !== 'GET') throw new Error('Unexpected mutation during visibility test');
+ const send = body => route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
+ if (url.pathname === '/auth/session') {
+ if (scenario === 'delayed-session') await sessionGate;
+ if (scenario === 'session-unavailable') return route.fulfill({ status: 503, contentType: 'application/json', body: '{}' });
+ return send(scenario === 'anonymous-with-stale-user' ? { authenticated: false, user: null } : { authenticated: true, user: currentUser });
+ }
+ if (url.pathname === '/api/feedbacks') return send({items: [], meta: {total: 0}});
+ if (url.pathname.endsWith('/comments')) return send({ comments: [
+ { id: id(101), author_id: viewer, author_name: '동명이인', content: '본인 댓글' },
+ { id: id(102), author_id: scenario === 'same-name-no-id' ? '' : 'another-user', author_name: '동명이인', content: '다른 사람 댓글' }
+ ] });
+ if (url.pathname === '/api/feedbacks/' + id(1)) return send({ feedback: {
+ id: id(1), requester_id: scenario === 'same-name-no-id' ? '' : OWNER, requester_name: '동명이인', title: '테스트 문의', contents: '본문', Category: 'GENERAL_QNA'
+ } });
+ try {
+ const path = url.pathname.endsWith('/') ? url.pathname + 'index.html' : url.pathname;
+ const contentType = path.endsWith('.css') ? 'text/css' : path.endsWith('.js') ? 'application/javascript' : path.endsWith('.html') ? 'text/html' : 'application/octet-stream';
+ return route.fulfill({ contentType, body: await readFile(root + path) });
+ } catch { return route.abort(); }
+ });
+ await page.goto('https://qa.example.test/egbim/?view=detail&id=' + id(1), { waitUntil: 'domcontentloaded' });
+ if (scenario === 'delayed-session') {
+ await page.waitForFunction(() => document.querySelector('[data-detail-title]').textContent === '테스트 문의');
+ assert.equal(await page.locator('#editButton').isVisible(), false, 'edit must be hidden while session is pending');
+ assert.equal(await page.locator('#deleteButton').isVisible(), false, 'delete must be hidden while session is pending');
+ releaseSession();
+ }
+ await page.waitForFunction(() => document.querySelector('#commentCount').textContent === '2');
+ await page.waitForTimeout(50);
+ const result = {
+ scenario, postEdit: await page.locator('#editButton').isVisible(), postDelete: await page.locator('#deleteButton').isVisible(),
+ ownComment: await page.locator('[data-comment-edit="' + id(101) + '"]').isVisible(),
+ otherCommentCount: await page.locator('[data-comment-edit="' + id(102) + '"], [data-comment-delete="' + id(102) + '"]').count()
+ };
+ console.log(JSON.stringify(result));
+ const expectedPost = scenario === 'owner' || scenario === 'delayed-session';
+ const expectedComment = !['anonymous-with-stale-user', 'session-unavailable'].includes(scenario);
+ assert.equal(result.postEdit, expectedPost, scenario + ' post edit');
+ assert.equal(result.postDelete, expectedPost, scenario + ' post delete');
+ assert.equal(result.ownComment, expectedComment, scenario + ' own comment');
+ assert.equal(result.otherCommentCount, 0, scenario + ' other comment');
+ if (scenario === 'owner') {
+ await page.locator('#editButton').click();
+ assert.equal(await page.locator('#editForm').isVisible(), true);
+ await page.locator('#editCancel').click();
+ assert.equal(await page.locator('#editForm').isVisible(), false);
+ }
+ await context.close();
+ }
+
+}finally{await browser.close();}
diff --git a/tests/page-entry.test.js b/tests/page-entry.test.js
new file mode 100644
index 0000000..51a85f4
--- /dev/null
+++ b/tests/page-entry.test.js
@@ -0,0 +1,22 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+
+const script = readFileSync(new URL('../assets/inquiry/entry.js', import.meta.url), 'utf8');
+
+for (const [name, source, expected] of [
+ ['write link', '/egbim/write.html', '/egbim/?view=write'],
+ ['detail link preserves its feedback ID', '/egbim/detail.html?id=feedback-uuid', '/egbim/?id=feedback-uuid&view=detail'],
+ ['write link cannot inherit a detail ID', '/egbim/write.html?view=detail&id=old&check=1#form', '/egbim/?view=write&check=1#form'],
+ ['detail link preserves encoded IDs and query values', '/egbim/detail.html?id=a%26b&check=x%20y', '/egbim/?id=a%26b&check=x+y&view=detail']
+]) {
+ test(name + ' opens the matching list modal using replace navigation', () => {
+ const navigations = [];
+ vm.runInNewContext(script, {
+ URL,
+ window: { location: { href: 'https://qa.example.test' + source, replace: value => navigations.push(value) } }
+ });
+ assert.deepEqual(navigations, ['https://qa.example.test' + expected]);
+ });
+}
diff --git a/tests/production-deployment.test.js b/tests/production-deployment.test.js
index b8c9a97..2043422 100644
--- a/tests/production-deployment.test.js
+++ b/tests/production-deployment.test.js
@@ -103,10 +103,17 @@ test('production uploader rejects root uploads and sends every file to the selec
mkdirSync(join(dir, 'assets'));
for (const file of ['index.html', 'write.html', 'detail.html', 'app.js', 'config.js']) writeFileSync(join(dir, 'egbim', file), 'fixture');
writeFileSync(join(dir, 'assets/styles.css'), 'body {}');
+ mkdirSync(join(dir, 'assets/inquiry'));
+ writeFileSync(join(dir, 'assets/inquiry/inquiry-hero.webp'), 'fixture-image');
+ writeFileSync(join(dir, 'assets/inquiry/entry.js'), 'fixture-entry');
const uploaded = spawnSync('bash', [script, 'baron-qa-write', 'wrangler.production.toml', 'releases/' + SHA], { env, cwd: dir, encoding: 'utf8' });
assert.equal(uploaded.status, 0, uploaded.stderr);
const commands = readFileSync(capture, 'utf8').trim().split('\n').map(JSON.parse);
- assert.equal(commands.length, 6);
+ assert.equal(commands.length, 8);
+ const heroUpload = commands.find(command => command.args[5].endsWith('/assets/inquiry/inquiry-hero.webp'));
+ assert.equal(heroUpload.args[heroUpload.args.indexOf('--content-type') + 1], 'image/webp');
+ const entryUpload = commands.find(command => command.args[5].endsWith('/assets/inquiry/entry.js'));
+ assert.equal(entryUpload.args[entryUpload.args.indexOf('--content-type') + 1], 'application/javascript');
for (const command of commands) {
assert.ok(command.args[5].startsWith('baron-qa-write/releases/' + SHA + '/'));
assert.ok(command.args.includes('wrangler.production.toml'));