diff --git a/assets/styles.css b/assets/styles.css index 76b13cd..b7d46b3 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -276,6 +276,9 @@ button { cursor: pointer; } .attachments h3 { margin: 0 0 8px; font-size: 14px; } .attachment-item { padding: 7px 0; color: #555; font-size: 13px; } .attachments a { color: var(--navy); text-decoration: underline; } +.completion-notice { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 20px 22px 0; padding: 15px 17px; border: 1px solid #f0c45c; background: #fff9e8; color: #5b4500; } +.completion-notice strong { display: block; margin-bottom: 4px; } +.completion-notice p { margin: 0; font-size: 13px; } .detail-actions { display: flex; justify-content: space-between; padding: 23px 22px 0; } .egbim-page #backButton { display: none; } .egbim-page .form-footer:has(#backButton) { display: none; } diff --git a/egbim/app.js b/egbim/app.js index 31f6714..1cdcd4a 100644 --- a/egbim/app.js +++ b/egbim/app.js @@ -45,6 +45,11 @@ return ""; } + function toBoolean(value) { + if (value === true || value === 1) return true; + return ["true", "1", "yes", "y"].indexOf(String(value || "").trim().toLowerCase()) > -1; + } + function normalizeCategory(value) { const raw = String(value || "").trim(); return ({ @@ -60,13 +65,17 @@ function normalizeRemotePost(item) { item = item || {}; const requester = item.requester || item.author || item.user || {}; - const fields = item.fields && typeof item.fields === "object" && !Array.isArray(item.fields) ? item.fields : {}; + const fields = [item.fields, item.metadata, item.meta, item.extra_fields, item.data].find(function (candidate) { return candidate && typeof candidate === "object" && !Array.isArray(candidate); }) || {}; function value(keys) { return firstValue(item, keys) || firstValue(fields, keys) || firstValue(requester, keys); } const createdAt = value(["createdAt", "created_at", "date"]) || new Date().toISOString(); const secret = value(["is_secret", "isSecret", "secret"]); const requesterEmail = value(["requester_email", "requesterEmail", "email"]); + const completionRequested = value(["completion_requested", "completionRequested"]); + const completionConfirmed = value(["completion_confirmed", "completionConfirmed"]); + const completionRequestedAt = value(["completion_requested_at", "completionRequestedAt"]); + const completionConfirmedAt = value(["completion_confirmed_at", "completionConfirmedAt"]); return { id: value(["id", "feedbackId", "feedback_id"]), feedbackId: value(["id", "feedbackId", "feedback_id"]), @@ -82,7 +91,11 @@ date: String(createdAt).slice(0, 10), createdAt: createdAt, status: normalizeStatus(value(["feedback_status", "feedbackStatus", "status_code", "status"])), - secret: secret === true || String(secret).toLowerCase() === "true" || String(secret) === "1", + secret: toBoolean(secret), + completionRequested: toBoolean(completionRequested) || Boolean(completionRequestedAt), + completionRequestedAt: completionRequestedAt, + completionConfirmed: toBoolean(completionConfirmed) || Boolean(completionConfirmedAt), + completionConfirmedAt: completionConfirmedAt, attachments: value(["attachments", "images"]) || [], raw: item }; @@ -356,7 +369,7 @@ async function requestJson(path, options) { const requestPath = String(path).split("?")[0]; - const sameOriginProxy = !config.apiBaseUrl && (requestPath === config.createFeedbackPath || requestPath === "/api/feedbacks" || /^\/api\/feedbacks\/[^\/]+(?:\/comments)?$/.test(requestPath)); + const sameOriginProxy = !config.apiBaseUrl && (requestPath === config.createFeedbackPath || requestPath === "/api/feedbacks" || /^\/api\/feedbacks\/[^\/]+(?:\/comments|\/completion-confirmation)?$/.test(requestPath)); if (!config.apiBaseUrl && !sameOriginProxy) return { local: true }; const target = config.apiBaseUrl ? config.apiBaseUrl.replace(/\/$/, '') + path : path; const response = await fetch(target, Object.assign({ credentials: 'include' }, options)); @@ -529,15 +542,15 @@ if (detailListLink) detailListLink.textContent = '목록으로'; const bottomBackButton = qs('#backButton', detail); if (bottomBackButton && bottomBackButton.parentElement) bottomBackButton.parentElement.remove(); - const id = new URLSearchParams(window.location.search).get('id') || 'EG-1048'; - let post = getPosts().find(function (item) { return item.id === id; }); - if (!post) { - try { - const result = await requestJson("/api/feedbacks/" + encodeURIComponent(id), { method: "GET" }); - post = normalizeRemotePost(result.feedback || result.data || result); - } catch (error) { - console.warn("[QA] feedback detail load failed:", error.message); - } + const id = new URLSearchParams(window.location.search).get("id") || "EG-1048"; + const storedPost = getPosts().find(function (item) { return item.id === id; }); + let post = storedPost; + try { + const result = await requestJson("/api/feedbacks/" + encodeURIComponent(id), { method: "GET" }); + const remotePost = normalizeRemotePost(result.feedback || result.data || result); + if (remotePost.id) post = Object.assign({}, storedPost || {}, remotePost); + } catch (error) { + console.warn("[QA] feedback detail load failed:", error.message); } if (!post) { qs('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.'; @@ -546,10 +559,18 @@ } qs('[data-detail-category]').textContent = post.category; qs('[data-detail-title]').textContent = post.title; - qs('[data-detail-status]').textContent = normalizeStatus(post.status) || '공지'; + const renderDetailStatus = function () { + const statusElement = qs("[data-detail-status]"); + const status = normalizeStatus(post.status) || "공지"; + if (!statusElement) return; + statusElement.textContent = status; + statusElement.classList.toggle("done", status === "완료"); + statusElement.classList.toggle("review", status !== "완료"); + }; + renderDetailStatus(); qs('[data-detail-date]').textContent = post.date; qs('[data-detail-author]').textContent = post.author; - const detailUser = getAuthUser(); + let detailUser = getAuthUser(); qs('[data-detail-company]').textContent = getPostCompanyLabel(post, detailUser); qs('[data-detail-department]').textContent = post.department; qs('[data-detail-content]').textContent = post.content; @@ -563,13 +584,41 @@ const deleteButton = qs('#deleteButton'); const editForm = qs('#editForm'); const editError = qs('#editError'); + const completionNotice = qs("#completionNotice"); + const completionButton = qs("#completionButton"); + const renderCompletionNotice = function () { + const owner = isPostOwner(post, detailUser || getAuthUser()); + const visible = owner && post.completionRequested && !post.completionConfirmed; + if (completionNotice) completionNotice.hidden = !visible; + }; const ownerActions = function () { - const owner = isPostOwner(post, getAuthUser()); + const owner = isPostOwner(post, detailUser || getAuthUser()); if (editButton) editButton.hidden = !owner; if (deleteButton) deleteButton.hidden = !owner; + renderCompletionNotice(); }; ownerActions(); - if (authReady) authReady.then(ownerActions); + if (authReady) authReady.then(function (user) { detailUser = user || detailUser; ownerActions(); renderCompletionNotice(); }); + if (completionButton) completionButton.addEventListener("click", async function () { + if (!post.completionRequested || post.completionConfirmed) return; + completionButton.disabled = true; + completionButton.textContent = "확인 처리 중..."; + try { + await requestJson("/api/feedbacks/" + encodeURIComponent(post.id) + "/completion-confirmation", { method: "POST" }); + post.completionRequested = false; + post.completionConfirmed = true; + post.status = "완료"; + updateStoredPost(post); + renderDetailStatus(); + renderCompletionNotice(); + showToast("문의가 완료 처리되었습니다."); + } catch (error) { + showToast(error.message || "문의 완료 확인에 실패했습니다."); + } finally { + completionButton.disabled = false; + completionButton.textContent = "완료 확인"; + } + }); if (editButton && editForm) editButton.addEventListener('click', function () { qs('#editCategory').value = post.category || '일반문의'; qs('#editTitle').value = post.title || ''; @@ -741,7 +790,7 @@ }).join(''); }); refreshRemoteStatus(post).then(function (changed) { - if (changed) qs('[data-detail-status]').textContent = normalizeStatus(post.status) || '공지'; + if (changed) renderDetailStatus(); }); } diff --git a/egbim/detail.html b/egbim/detail.html index a3264ab..2f3a6a2 100644 --- a/egbim/detail.html +++ b/egbim/detail.html @@ -1,8 +1,8 @@ -문의 상세 | EG-BIM Q&A +문의 상세 | EG-BIM Q&A
EG-BIM SUPPORT

Q&A

EG-BIM 관련 문의하기

-

문의 상세

문의 내용과 답변을 확인할 수 있습니다.

문의 문의 제목

문의접수
2026-09-17작성자회사부서

첨부파일

첨부된 파일이 없습니다.
목록

답변 0

-
+

문의 상세

문의 내용과 답변을 확인할 수 있습니다.

문의 문의 제목

문의접수
2026-09-17작성자회사부서

첨부파일

첨부된 파일이 없습니다.
목록

답변 0

+ diff --git a/egbim/index.html b/egbim/index.html index 4280272..efd071a 100644 --- a/egbim/index.html +++ b/egbim/index.html @@ -5,7 +5,7 @@ EG-BIM Q&A | 바론컨설턴트 - +
@@ -23,6 +23,6 @@
- + diff --git a/egbim/write.html b/egbim/write.html index b9fe109..0b13185 100644 --- a/egbim/write.html +++ b/egbim/write.html @@ -1,8 +1,8 @@ -문의 등록 | EG-BIM Q&A +문의 등록 | EG-BIM Q&A
EG-BIM SUPPORT

Q&A

EG-BIM 관련 문의하기

문의 등록

로그인한 사용자 정보로 문의가 등록됩니다.

로그인 상태를 확인하고 있습니다.
공개 설정
개인정보, 비밀번호, 인증번호 등 민감한 정보는 입력하지 마세요.
파일은 문의와 함께 안전하게 업로드됩니다. 파일당 최대 30MB
-
+
diff --git a/src/index.js b/src/index.js index 36a3965..4d7e1e9 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,7 @@ export default { if (request.method === 'GET') return listFeedbacks(request, env, url); return createFeedback(request, env); } + if (/^\/api\/feedbacks\/[^/]+\/completion-confirmation$/.test(url.pathname)) return completionConfirmation(request, env, url); if (/^\/api\/feedbacks\/[^/]+\/comments$/.test(url.pathname)) return feedbackComments(request, env, url); if (/^\/api\/feedbacks\/[^/]+$/.test(url.pathname)) return feedbackDetail(request, env, url); @@ -484,6 +485,51 @@ async function listFeedbacks(request, env, url) { return json({ items: Array.isArray(items) ? items : [], meta }); } +async function completionConfirmation(request, env, url) { + if (request.method !== "POST") return json({ error: "method_not_allowed" }, 405, { allow: "POST" }); + 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); + if (!env.ABC_PROJECT_ID || !env.ABC_CHANNEL_ID) return json({ error: "feedback_target_not_configured" }, 503); + + const feedbackId = decodeURIComponent(url.pathname.split("/")[3] || ""); + if (!isUuidv7(feedbackId)) return json({ error: "invalid_feedback_id" }, 400); + + const requesterId = String(user.requesterId || user.ssoSubject || user.userUuid || ""); + const requesterTenantId = String(user.requesterTenantId || user.tenantId || ""); + if (!requesterId || !requesterTenantId) return json({ error: "requester_identity_missing" }, 422); + + const apiBaseUrlValue = String(env.ABC_API_BASE_URL || "https://feedback.hmac.kr"); + const apiBaseUrl = apiBaseUrlValue.endsWith("/") ? apiBaseUrlValue.slice(0, -1) : apiBaseUrlValue; + const endpoint = apiBaseUrl + "/api/projects/" + encodeURIComponent(env.ABC_PROJECT_ID) + "/channels/" + encodeURIComponent(env.ABC_CHANNEL_ID) + "/feedbacks/" + encodeURIComponent(feedbackId) + "/completion-confirmation"; + const idempotencyConsumer = env.ABC_IDEMPOTENCY_CONSUMER || env.ABC_SOURCE_NAMESPACE || "EGBIM_QA"; + const headers = { + accept: "application/json", + "content-type": "application/json", + "x-api-key": env.ABC_API_KEY, + "x-requester-id": requesterId, + "x-requester-tenant-id": requesterTenantId, + "x-idempotency-consumer": idempotencyConsumer, + "idempotency-key": feedbackId, + "x-source-namespace": idempotencyConsumer, + "x-source-record-id": feedbackId + }; + const body = JSON.stringify({ requester_id: requesterId, requester_tenant_id: requesterTenantId }); + let response; + try { + response = await fetch(endpoint, { method: "POST", headers, body }); + } catch (error) { + console.error("feedback_completion_confirmation_request_failed", error instanceof Error ? error.message : "unknown"); + return json({ error: "feedback_api_unreachable" }, 502); + } + const result = await response.json().catch(() => ({})); + if (!response.ok) { + const upstreamMessage = String(result.message || result.error || result.code || "").slice(0, 240); + return json({ error: "feedback_completion_confirmation_rejected", message: upstreamMessage || "문의 완료 확인에 실패했습니다.", status: response.status }, response.status >= 500 ? 502 : response.status); + } + return json({ ok: true, feedback: result.feedback || result.data || result }); +} + async function feedbackComments(request, env, url) { if (request.method !== 'GET' && request.method !== 'POST') return json({ error: 'method_not_allowed' }, 405, { allow: 'GET, POST' }); const user = await getSession(request, env);