상세페이지 UI 수정
Deploy EG-BIM QA Gateway / deploy (push) Successful in 32s

This commit is contained in:
root
2026-09-22 15:23:00 +09:00
parent f196791191
commit c6b430a197
5 changed files with 141 additions and 15 deletions
+10 -5
View File
@@ -274,6 +274,11 @@ button { cursor: pointer; }
.attachment-item { padding: 7px 0; color: #555; font-size: 13px; }
.attachments a { color: var(--navy); text-decoration: underline; }
.detail-actions { display: flex; justify-content: space-between; padding: 23px 22px 0; }
.owner-actions { display: flex; gap: 8px; }
.button-danger { border-color: #c43d3d; background: #c43d3d; color: #fff; }
.button-danger:hover { border-color: #a52f2f; background: #a52f2f; }
.edit-form { padding-top: 24px; border-top: 2px solid #242424; }
.edit-form h3 { margin: 0; padding: 0 22px 18px; font-size: 18px; }
.comment-box { margin-top: 54px; }
.comment-box h3 { padding-bottom: 15px; margin: 0; border-bottom: 2px solid #222; font-size: 17px; }
.comment { padding: 19px 10px; border-bottom: 1px solid var(--line); }
@@ -282,9 +287,9 @@ button { cursor: pointer; }
.comment-empty { padding: 32px; border-bottom: 1px solid var(--line); color: #999; text-align: center; }
.comment-form { padding: 20px 10px 0; }
.comment-form .textarea { min-height: 120px; }
.comment-attachment-input { padding-top: 14px; color: #555; font-size: 13px; }
.comment-attachment-input label { display: block; margin-bottom: 8px; font-weight: 600; }
.comment-attachment-input input { max-width: 100%; }
.comment-attachment-input { padding-top: 14px; }
.comment-attachment-input input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); }
.comment-image-button { height: 34px; padding: 0 13px; font-size: 13px; }
.comment-image-preview, .comment-attachments-list { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
.comment-image-preview-item { width: 104px; overflow: hidden; border: 1px solid #ddd; background: #fafafa; }
.comment-image-preview-item img { display: block; width: 104px; height: 78px; object-fit: cover; }
@@ -332,7 +337,7 @@ button { cursor: pointer; }
.qa-list-page .sub-nav a[aria-current='page'], .egbim-page .sub-nav a[aria-current='page'] { background: rgba(0, 121, 116, .5); color: #ffc400; }
.qa-list-page .sub-nav a.disabled-tab, .egbim-page .sub-nav a.disabled-tab { cursor: default; }
.qa-list-page .sub-nav a.disabled-tab:hover, .egbim-page .sub-nav a.disabled-tab:hover { background: transparent; color: #fff; }
.qa-list-page .page-shell { max-width: none; padding: 51px 10px 62px; }
.qa-list-page .page-shell { width: calc(100% - 80px); max-width: 1200px; padding: 51px 0 62px; margin-right: auto; margin-left: auto; }
.egbim-page .page-shell { padding-top: 51px; }
.qa-list-heading { margin: 0 0 40px; text-align: center; }
.qa-list-heading h1 { margin: 0; font-size: 30px; line-height: 1.35; letter-spacing: -.06em; }
@@ -380,7 +385,7 @@ button { cursor: pointer; }
.qa-list-page .page-hero p, .egbim-page .page-hero p { font-size: 16px; }
.qa-list-page .sub-nav-inner, .egbim-page .sub-nav-inner { max-width: none; }
.qa-list-page .sub-nav a, .egbim-page .sub-nav a { padding: 14px 10px; font-size: 14px; }
.qa-list-page .page-shell { padding: 39px 14px 55px; }
.qa-list-page .page-shell { width: 100%; max-width: none; padding: 39px 14px 55px; }
.qa-list-heading { margin-bottom: 28px; }
.qa-list-heading h1 { font-size: 26px; }
.qa-list-page .filter-row { flex-wrap: wrap; }
+8
View File
@@ -85,6 +85,14 @@
- [x] 상세페이지 진입 시 최신 상태 재조회
- [ ] 실제 관리페이지에서 상태 변경 후 Q&A 화면 반영 테스트
## 5차 범위: 작성자 문의 수정·삭제
- [x] 작성자 본인 확인 후 상세페이지 수정·삭제 버튼 표시
- [x] Worker `PATCH /api/feedbacks/{feedbackId}` 프록시 추가
- [x] Worker `DELETE /api/feedbacks/{feedbackId}` 프록시 추가
- [x] 제목·내용·카테고리·비밀글 수정 UI 추가
- [ ] 실제 배포 환경에서 본인/타인 수정·삭제 권한 테스트
## 보안 요구사항
- [x] API Key를 정적 JavaScript, HTML, `egbim/config.js`에 넣지 않음
+91 -1
View File
@@ -46,6 +46,19 @@
localStorage.setItem(STORAGE_KEY, JSON.stringify(posts));
}
function removeStoredPost(id) {
const posts = readJson(STORAGE_KEY, []).filter(function (post) { return post.id !== id; });
localStorage.setItem(STORAGE_KEY, JSON.stringify(posts));
}
function isPostOwner(post, user) {
if (!post || !user) return false;
const postIds = [post.requesterId, post.requesterUuid].filter(Boolean);
const userIds = [user.requesterId, user.ssoSubject, user.userUuid].filter(Boolean);
if (postIds.length && userIds.length) return postIds.some(function (id) { return userIds.indexOf(id) > -1; });
return Boolean(post.author && [user.name, user.loginId, user.email].filter(Boolean).indexOf(post.author) > -1);
}
function normalizeStatus(value) {
if (value && typeof value === 'object') value = value.code || value.key || value.value || value.name || '';
const raw = String(value || '').trim();
@@ -365,7 +378,7 @@
if (!qs('#category').value || !title || !content) { errorBox.textContent = '구분, 제목, 내용을 모두 입력해주세요.'; errorBox.style.display = 'block'; return; }
const feedbackId = makeUuid();
const createdAt = new Date().toISOString();
const post = { id: feedbackId, feedbackId: feedbackId, category: qs('#category').value, company: currentUser.familyCompany || currentUser.company || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: createdAt.slice(0, 10), createdAt: createdAt, status: '접수', secret: qs('#secret').checked, content: content };
const post = { id: feedbackId, feedbackId: feedbackId, requesterId: currentUser.requesterId, requesterTenantId: currentUser.requesterTenantId, category: qs('#category').value, company: currentUser.familyCompany || currentUser.company || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: createdAt.slice(0, 10), createdAt: createdAt, status: '접수', secret: qs('#secret').checked, content: content };
const submitButton = qs('#submitButton');
submitButton.disabled = true;
submitButton.textContent = '등록 중...';
@@ -413,6 +426,83 @@
if (attachmentBox) attachmentBox.innerHTML = attachments.length ? attachments.map(function (attachment) {
return '<div class="attachment-item">📎 ' + escapeHtml(attachment.originalFileName || '첨부파일') + '</div>';
}).join('') : '<span class="help-text">첨부된 파일이 없습니다.</span>';
const editButton = qs('#editButton');
const deleteButton = qs('#deleteButton');
const editForm = qs('#editForm');
const editError = qs('#editError');
const ownerActions = function () {
const owner = isPostOwner(post, getAuthUser());
if (editButton) editButton.hidden = !owner;
if (deleteButton) deleteButton.hidden = !owner;
};
ownerActions();
if (authReady) authReady.then(ownerActions);
if (editButton && editForm) editButton.addEventListener('click', function () {
qs('#editCategory').value = post.category || '일반문의';
qs('#editTitle').value = post.title || '';
qs('#editContent').value = post.content || '';
qs('#editSecret').checked = Boolean(post.secret);
editError.hidden = true;
editError.style.display = 'none';
editForm.hidden = false;
editForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
const editCancel = qs('#editCancel');
if (editCancel && editForm) editCancel.addEventListener('click', function () { editForm.hidden = true; });
if (editForm) editForm.addEventListener('submit', async function (event) {
event.preventDefault();
editError.hidden = true;
editError.style.display = 'none';
const title = qs('#editTitle').value.trim();
const content = qs('#editContent').value.trim();
const category = qs('#editCategory').value;
if (!title || !content || !category) {
editError.textContent = '구분, 제목, 내용을 모두 입력해주세요.';
editError.hidden = false;
editError.style.display = 'block';
return;
}
const saveButton = qs('button[type="submit"]', editForm);
saveButton.disabled = true;
saveButton.textContent = '저장 중...';
try {
await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title, contents: content, Category: CATEGORY_CODES[category], is_secret: qs('#editSecret').checked ? 'true' : 'false' })
});
post.category = category;
post.title = title;
post.content = content;
post.secret = qs('#editSecret').checked;
updateStoredPost(post);
qs('[data-detail-category]').textContent = post.category;
qs('[data-detail-title]').textContent = post.title;
qs('[data-detail-content]').textContent = post.content;
qs('[data-detail-secret]').hidden = !post.secret;
editForm.hidden = true;
showToast('문의가 수정되었습니다.');
} catch (error) {
editError.textContent = error.message || '문의 수정에 실패했습니다.';
editError.hidden = false;
editError.style.display = 'block';
} finally {
saveButton.disabled = false;
saveButton.textContent = '수정 저장';
}
});
if (deleteButton) deleteButton.addEventListener('click', async function () {
if (!window.confirm('이 문의를 삭제하시겠습니까?')) return;
deleteButton.disabled = true;
try {
await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), { method: 'DELETE' });
removeStoredPost(post.id);
window.location.href = 'index.html';
} catch (error) {
deleteButton.disabled = false;
showToast(error.message || '문의 삭제에 실패했습니다.');
}
});
function safeMediaUrl(value) {
value = String(value || '');
return /^(https?:\/\/|\/|blob:)/i.test(value) ? value : '';
+1 -1
View File
@@ -3,6 +3,6 @@
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>문의 상세 | EG-BIM Q&amp;A</title><link rel="stylesheet" href="../assets/styles.css?v=20260921-4"></head>
<body>
<main class="egbim-page"><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&amp;A</h1><p>EG-BIM 관련 문의하기</p></div><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&amp;A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section>
<section class="page-shell" id="detail"><div class="section-heading"><div><h2>문의 상세</h2><p>문의 내용과 답변을 확인할 수 있습니다.</p></div></div><article class="detail-card"><div class="detail-head"><div class="detail-title-line"><h2><span class="category" data-detail-category>문의</span> <span data-detail-title>문의 제목</span></h2><span class="status review" data-detail-status>문의접수</span></div><div class="detail-meta"><span data-detail-date>2026-09-17</span><span data-detail-author>작성자</span><span data-detail-company>회사</span><span data-detail-department>부서</span><span data-detail-secret hidden>🔒 비밀글</span></div></div><div class="detail-body" data-detail-content></div><div class="attachments"><h3>첨부파일</h3><div data-detail-attachments><span class="help-text">첨부된 파일이 없습니다.</span></div></div><div class="detail-actions"><a class="button button-outline" href="index.html">목록</a></div></article><section class="comment-box"><h3>답변 <span id="commentCount">0</span></h3><div id="comments"></div><form id="commentForm" class="comment-form"><label class="sr-only" for="commentContent">댓글 내용</label><textarea id="commentContent" class="textarea" maxlength="5000" placeholder="답변 또는 추가 문의를 입력해주세요" required></textarea><div class="comment-attachment-input"><label for="commentImages">이미지 첨부</label><input id="commentImages" type="file" accept="image/*" multiple><div id="commentImagePreview" class="comment-image-preview"></div><span class="help-text">이미지 파일만 등록할 수 있으며, 파일당 최대 30MB입니다.</span></div><div class="comment-form-actions"><span id="commentError" class="form-error" role="alert" hidden></span><button class="button button-primary" id="commentSubmit" type="submit">댓글 등록</button></div></form></section><div class="form-footer"><button class="button button-outline" id="backButton" type="button">목록으로</button></div></section>
<section class="page-shell" id="detail"><div class="section-heading"><div><h2>문의 상세</h2><p>문의 내용과 답변을 확인할 수 있습니다.</p></div></div><article class="detail-card"><div class="detail-head"><div class="detail-title-line"><h2><span class="category" data-detail-category>문의</span> <span data-detail-title>문의 제목</span></h2><span class="status review" data-detail-status>문의접수</span></div><div class="detail-meta"><span data-detail-date>2026-09-17</span><span data-detail-author>작성자</span><span data-detail-company>회사</span><span data-detail-department>부서</span><span data-detail-secret hidden>🔒 비밀글</span></div></div><div class="detail-body" data-detail-content></div><div class="attachments"><h3>첨부파일</h3><div data-detail-attachments><span class="help-text">첨부된 파일이 없습니다.</span></div></div><div class="detail-actions"><div class="owner-actions"><button class="button button-outline" id="editButton" type="button" hidden>수정</button><button class="button button-danger" id="deleteButton" type="button" hidden>삭제</button></div><a class="button button-outline" href="index.html">목록</a></div></article><form id="editForm" class="edit-form" hidden><h3>문의 수정</h3><div class="form-row"><label class="form-label" for="editCategory">구분</label><div class="form-control"><select id="editCategory" class="select"><option value="오류문의">오류문의</option><option value="개선문의">개선문의</option><option value="일반문의">일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="editTitle">제목</label><div class="form-control"><input id="editTitle" class="input" maxlength="200" required></div></div><div class="form-row"><label class="form-label" for="editContent">내용</label><div class="form-control column"><textarea id="editContent" class="textarea" maxlength="10000" required></textarea></div></div><div class="form-row"><div class="form-label">설정</div><div class="form-control"><label class="check"><input id="editSecret" type="checkbox"> 비밀글</label></div></div><div class="form-footer"><button class="button button-outline" id="editCancel" type="button">취소</button><button class="button button-primary" type="submit">수정 저장</button></div><div id="editError" class="form-error" role="alert" hidden></div></form><section class="comment-box"><h3>답변 <span id="commentCount">0</span></h3><div id="comments"></div><form id="commentForm" class="comment-form"><label class="sr-only" for="commentContent">댓글 내용</label><textarea id="commentContent" class="textarea" maxlength="5000" placeholder="답변 또는 추가 문의를 입력해주세요" required></textarea><div class="comment-attachment-input"><label class="button button-outline comment-image-button" for="commentImages">이미지 첨부</label><input id="commentImages" type="file" accept="image/*" multiple><div id="commentImagePreview" class="comment-image-preview"></div></div><div class="comment-form-actions"><span id="commentError" class="form-error" role="alert" hidden></span><button class="button button-primary" id="commentSubmit" type="submit">댓글 등록</button></div></form></section><div class="form-footer"><button class="button button-outline" id="backButton" type="button">목록으로</button></div></section>
</main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20260921-3"></script>
</body></html>
+29 -6
View File
@@ -506,7 +506,7 @@ async function feedbackComments(request, env, url) {
}
async function feedbackDetail(request, env, url) {
if (request.method !== 'GET') return json({ error: 'method_not_allowed' }, 405, { allow: 'GET' });
if (!['GET', 'PATCH', 'DELETE'].includes(request.method)) return json({ error: 'method_not_allowed' }, 405, { allow: 'GET, PATCH, DELETE' });
const user = await getSession(request, env);
if (!user) return json({ error: 'unauthenticated' }, 401);
if (!env.ABC_API_KEY) return json({ error: 'feedback_api_not_configured' }, 503);
@@ -520,16 +520,37 @@ async function feedbackDetail(request, env, url) {
if (!requesterId || !requesterTenantId) return json({ error: 'requester_identity_missing' }, 422);
const apiBaseUrl = String(env.ABC_API_BASE_URL || 'https://feedback.hmac.kr').replace(/\/$/, '');
const endpoint = `${apiBaseUrl}/api/projects/${encodeURIComponent(env.ABC_PROJECT_ID)}/channels/${encodeURIComponent(env.ABC_CHANNEL_ID)}/feedbacks/${encodeURIComponent(feedbackId)}`;
let response;
try {
response = await fetch(endpoint, {
method: 'GET',
headers: {
const headers = {
accept: 'application/json',
'x-api-key': env.ABC_API_KEY,
'x-requester-id': requesterId,
'x-requester-tenant-id': requesterTenantId
};
let body;
if (request.method === 'PATCH') {
let input;
try {
input = await request.json();
} catch (error) {
return json({ error: 'invalid_json' }, 400);
}
if (!input || typeof input !== 'object' || Array.isArray(input)) return json({ error: 'invalid_json' }, 400);
const payload = {};
if (input.title != null) payload.title = String(input.title).trim();
if (input.contents != null) payload.contents = String(input.contents).trim();
if (input.Category != null) payload.Category = String(input.Category).trim();
if (input.is_secret != null) payload.is_secret = input.is_secret === true || input.is_secret === 'true' || input.is_secret === 1 || input.is_secret === '1' ? 'true' : 'false';
if (payload.title === '' || payload.contents === '' || payload.Category === '') return json({ error: 'invalid_feedback_update' }, 400);
if (!Object.keys(payload).length) return json({ error: 'feedback_update_required' }, 400);
headers['content-type'] = 'application/json';
body = JSON.stringify(payload);
}
let response;
try {
response = await fetch(endpoint, {
method: request.method,
headers,
body
});
} catch (error) {
console.error('feedback_detail_api_request_failed', error instanceof Error ? error.message : 'unknown');
@@ -540,6 +561,8 @@ async function feedbackDetail(request, env, url) {
const upstreamMessage = String(result.message || result.error || result.code || '').slice(0, 240);
return json({ error: 'feedback_api_rejected', message: upstreamMessage || '문의 상세 조회에 실패했습니다.', status: response.status }, response.status >= 500 ? 502 : response.status);
}
if (request.method === 'DELETE') return json({ ok: true, id: feedbackId });
if (request.method === 'PATCH') return json({ feedback: result.feedback || result.data || result });
return json({ feedback: result.feedback || result.data || result });
}