Q&A
EG-BIM 관련 문의하기
문의 상세
문의 내용과 답변을 확인할 수 있습니다.
답변 0
문의 상세
문의 내용과 답변을 확인할 수 있습니다.
diff --git a/README.md b/README.md index d888cb4..40cf80f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ python3 -m http.server 4173 - `feedback_comment_attachments`: 답변 댓글 이미지가 생길 때 `comment_id`와 `qa_cdn` 메타데이터 - `support_attachments`: 운영 호환 레이어가 필요할 때 동일 파일의 티켓 첨부 메타데이터 -상세 페이지에는 공개 댓글 입력 UI가 있으며, 댓글은 같은 Worker를 통해 ABC의 `feedback_comments`에 저장하고 `is_internal=false`인 공개 댓글만 조회합니다. 댓글 첨부파일은 별도 댓글 첨부 API 계약을 확인한 뒤 추가합니다. +상세 페이지에는 공개 댓글 입력 UI가 있으며, 댓글은 같은 Worker를 통해 ABC의 `feedback_comments`에 저장하고 `is_internal=false`인 공개 댓글만 조회합니다. 댓글 이미지가 있으면 `attachments` multipart 필드로 Worker가 ABC에 전달하며, 응답으로 받은 이미지 URL을 썸네일로 표시합니다. 목록·상세 진입 시에는 ABC의 `feedback_status`를 다시 조회해 관리페이지에서 변경된 상태를 반영합니다. ## 연동 지점 diff --git a/assets/styles.css b/assets/styles.css index 4e1c3dd..4e8cc1c 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -282,6 +282,16 @@ 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-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; } +.comment-image-preview-item span { display: block; overflow: hidden; padding: 5px 6px; color: #666; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.comment-image-link { display: block; width: 124px; } +.comment-image { display: block; width: 124px; height: 94px; border: 1px solid #ddd; object-fit: cover; } +.comment-attachment-name { color: #666; font-size: 13px; } .comment-form-actions { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding-top: 10px; } .comment-form-actions .form-error { display: block; flex: 1; margin: 0; } diff --git a/docs/qa-feedback-api-integration-task.md b/docs/qa-feedback-api-integration-task.md index 2271595..3c9ef1e 100644 --- a/docs/qa-feedback-api-integration-task.md +++ b/docs/qa-feedback-api-integration-task.md @@ -70,11 +70,21 @@ ## 3차 범위: 공개 댓글 - [x] 상세페이지 댓글 입력 UI 추가 +- [x] 댓글 이미지 multipart(`attachments`) 전송 +- [x] 댓글 이미지 선택 시 썸네일 미리보기 - [x] Worker 댓글 목록 조회 라우트 추가 - [x] Worker 공개 댓글 생성 라우트 추가 - [x] `is_internal=false` 공개 댓글만 조회 - [ ] 실제 관리자 답변 등록 후 Q&A 상세페이지 표시 테스트 +## 4차 범위: 관리자 상태 동기화 + +- [x] Worker에 `GET /api/feedbacks/{feedbackId}` 상세 조회 프록시 추가 +- [x] ABC `feedback_status`를 사용자 화면 상태명으로 변환 +- [x] 목록 진입 시 저장된 문의의 최신 상태 재조회 +- [x] 상세페이지 진입 시 최신 상태 재조회 +- [ ] 실제 관리페이지에서 상태 변경 후 Q&A 화면 반영 테스트 + ## 보안 요구사항 - [x] API Key를 정적 JavaScript, HTML, `egbim/config.js`에 넣지 않음 diff --git a/egbim/app.js b/egbim/app.js index 2d54446..cc8975f 100644 --- a/egbim/app.js +++ b/egbim/app.js @@ -6,6 +6,14 @@ const STORAGE_KEY = 'baron.qa.posts.egbim'; const COMMENTS_KEY = 'baron.qa.comments.egbim'; const CATEGORY_CODES = { '오류문의': 'ERROR_QNA', '개선문의': 'IMPROVEMENT_QNA', '일반문의': 'GENERAL_QNA' }; + const STATUS_LABELS = { + INIT: '접수', + ON_REVIEW: '문의검토', + DETAILED_REVIEW: '정밀검토', + IN_PROGRESS: '처리중', + RESOLVED: '답변완료', + PENDING: '보류' + }; let authReady; function qs(selector, root) { return (root || document).querySelector(selector); } @@ -30,6 +38,63 @@ localStorage.setItem(STORAGE_KEY, JSON.stringify(posts)); } + function updateStoredPost(post) { + const posts = readJson(STORAGE_KEY, []); + const index = posts.findIndex(function (item) { return item.id === post.id; }); + if (index < 0) return; + posts[index] = Object.assign({}, posts[index], post); + localStorage.setItem(STORAGE_KEY, JSON.stringify(posts)); + } + + function normalizeStatus(value) { + if (value && typeof value === 'object') value = value.code || value.key || value.value || value.name || ''; + const raw = String(value || '').trim(); + if (!raw) return ''; + const upper = raw.toUpperCase(); + return STATUS_LABELS[upper] || ({ + NEW: '접수', RECEIVED: '접수', 접수: '접수', 문의접수: '접수', + REVIEW: '문의검토', 문의검토: '문의검토', + DEEP: '정밀검토', 정밀검토: '정밀검토', + PATCH: '처리중', 처리중: '처리중', + DONE: '답변완료', COMPLETED: '답변완료', 답변완료: '답변완료', + HOLD: '보류', 보류: '보류' + }[upper] || raw); + } + + function extractFeedbackStatus(payload) { + const root = payload && payload.feedback ? payload.feedback : payload; + const candidates = [root, root && root.data, root && root.feedback, root && root.fields]; + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + if (!candidate || typeof candidate !== 'object') continue; + const value = candidate.feedback_status || candidate.feedbackStatus || candidate.status_code || candidate.status; + if (value != null && value !== '') return normalizeStatus(value); + } + const fields = root && (Array.isArray(root.fields) ? root.fields : (root.data && Array.isArray(root.data.fields) ? root.data.fields : [])); + const statusField = fields.find(function (field) { return field && (field.key === 'feedback_status' || field.fieldKey === 'feedback_status' || field.name === 'feedback_status'); }); + return statusField ? normalizeStatus(statusField.value || statusField.val || statusField.data) : ''; + } + + async function refreshRemoteStatus(post) { + if (!post || !post.id || post.category === '공지사항') return false; + try { + const result = await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), { method: 'GET' }); + const status = extractFeedbackStatus(result); + if (!status || status === post.status) return false; + post.status = status; + updateStoredPost(post); + return true; + } catch (error) { + console.warn('[QA] feedback status sync failed:', post.id, error.message); + return false; + } + } + + async function refreshRemoteStatuses(posts) { + const changed = await Promise.all((posts || []).map(refreshRemoteStatus)); + return changed.some(Boolean); + } + function getComments() { return readJson(COMMENTS_KEY, {}); } function parseJson(value, fallback) { @@ -177,7 +242,7 @@ } async function requestJson(path, options) { - const sameOriginProxy = !config.apiBaseUrl && (path === config.createFeedbackPath || /^\/api\/feedbacks\/[^/]+\/comments$/.test(path)); + const sameOriginProxy = !config.apiBaseUrl && (path === config.createFeedbackPath || /^\/api\/feedbacks\/[^/]+(?:\/comments)?$/.test(path)); 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)); @@ -260,12 +325,13 @@ tableBody.innerHTML = rows.map(function (post, index) { const isNotice = post.category === '공지사항'; const number = isNotice ? '공지' : filtered.length - ((page - 1) * pageSize + index); - const statusClass = post.status === '답변완료' ? 'done' : (post.status === '문의검토' || post.status === '정밀검토' ? 'review' : ''); + const status = normalizeStatus(post.status); + const statusClass = status === '답변완료' ? 'done' : (status === '문의검토' || status === '정밀검토' ? 'review' : ''); return '
EG-BIM 관련 문의하기
문의 내용과 답변을 확인할 수 있습니다.
문의 내용과 답변을 확인할 수 있습니다.