feat(ui): apply inquiry list and modal design

Apply the supplied inquiry layout to the list and native write/detail dialogs while preserving existing API requests, author controls, attachments, comments and search. Keep legacy entry links and confirm before discarding drafts.

Validation: 98 tests and syntax checks pass; mocked Chromium desktop/mobile and six author/session visibility scenarios pass.
This commit is contained in:
root committed 2026-11-02 13:11:47 +09:00
1 parent dbf5dc3611
commit c62630e22d
19 files changed
+1362 -277

No files matched your search

+7 -5
View File
@@ -1,6 +1,8 @@
# EG-BIM Q&A static pages
`egbim/` 아래의 `index.html`, `write.html`, `detail.html` 세 페이지로 구성한 EG-BIM Q&A UI입니다. 원본 `egbim_homepage`의 Q&A 화면 구성과 패키지 S/W 메뉴 방향을 참고해, PHP/그누보드/DB 의존성 없이 정적 호스팅에서 동작하도록 분리했습니다.
`egbim/index.html`의 목록 위에 작성·상세 모달을 여는 EG-BIM Q&A UI입니다. `SW문의_소스패키지`의 히어로·제품 탭·목록·모달 디자인을 적용하고, 기존 첨부·댓글·비밀글·작성자 권한·검색을 유지합니다. 회사 홈페이지의 헤더·메뉴·푸터는 포함하지 않습니다. 기존 `write.html`, `detail.html?id=...` 주소는 같은 목록의 모달로 연결됩니다.
EG-BIM을 우선 제공하며 TOVA·GAIA는 준비중 탭입니다. 원격지원은 `https://939.co.kr/saman/`으로 열립니다. UI 적용 범위·검증·남은 배포 확인은 [작업 목록](docs/qa-inquiry-ui-redesign-tasks.md)에 정리했습니다.
로그인부터 글 저장까지의 화면·처리 전이맵은 [`docs/qa-login-to-save-transition.md`](docs/qa-login-to-save-transition.md)에 정리했습니다.
@@ -12,7 +14,7 @@
python3 -m http.server 4173
```
그 다음 `http://localhost:4173/egbim/`에 접속합니다. 브라우저 보안 정책 때문에 `file://` 직접 열기보다 정적 서버를 사용하는 편이 안전합니다.
그 다음 `http://localhost:4173/egbim/`에 접속합니다. 정적 서버는 화면 배치 확인용이며 로그인과 저장 API를 제공하지 않습니다. 브라우저 보안 정책 때문에 `file://` 직접 열기보다 정적 서버를 사용하는 편이 안전합니다. 실제 서버 데이터를 변경하지 않는 모달 회귀 검증은 별도 Playwright 환경에서 `npm run test:ui`로 실행할 수 있습니다.
## feedback.hmac.kr DB 기준 연동
@@ -24,7 +26,7 @@ python3 -m http.server 4173
- 신규 상태: `support_tickets.status_code = RECEIVED`, `feedback_status = NEW`
- 첨부 저장소: `storage_bucket = qa_cdn`
작성 시 브라우저가 UUID를 하나 생성합니다. 같은 UUID를 `feedback.id`, `feedback.source_record_id`, `support_tickets.idempotency_key`에 사용하므로 중복 제출을 판별할 수 있습니다. API 서버는 envelope를 받아 다음 DB 레코드를 하나의 트랜잭션으로 생성해야 합니다.
작성 시 브라우저가 기존 UUID v7 방식으로 `feedbackId`를 생성하고 같은 Worker의 `POST /api/feedbacks`에 전달합니다. API 서버의 저장 구조와 작성자·조직 매핑은 이번 UI 변경 범위에 포함하지 않습니다. 기존 저장 대상은 다음과 같습니다.
- `feedbacks`: `id`, `channel_id`, `source_namespace`, `source_record_id`, `data`
- `support_tickets`: 작성자/테넌트/제목/내용/분류/상태/`idempotency_key`
@@ -42,7 +44,7 @@ python3 -m http.server 4173
## 연동 지점
- SSO: Q&A는 홈페이지 로그인 정보를 전달받지 않고, `https://sso.hmac.kr/oidc`에 등록된 독립적인 PKCE Public Client로 직접 인증합니다. callback은 `https://qa-test.baroncs.co.kr/auth/callback`이며, Worker가 검증한 claim을 버전이 지정된 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 Q&A 세션에 저장된 작성자 정보를 반환합니다. feedback 서버는 반드시 SSO 세션 또는 토큰을 서버 측에서 검증해야 합니다.
- 작성자 식별자: `ssoSubject`/`requesterId`, `userUuid`, `tenantId`/`requesterTenantId`, `tenantIds`, `scope`, `roles`, 이메일·이름·부서·전화번호를 payload에 넣습니다. `requester_id`와 `requester_tenant_id`가 없으면 제출을 차단합니다.
- 작성자 식별자: Worker가 검증한 세션의 `requesterId`와 `requesterTenantId`를 사용합니다. 브라우저는 회사·부서·이름·이메일을 읽기 전용으로 표시하고, 필수 UUID가 없으면 제출을 차단합니다. 서버가 요청의 작성자·조직 정보를 결정합니다.
- API: 작성페이지는 같은 Worker의 `POST /api/feedbacks`를 호출합니다. Worker가 Q&A 세션 쿠키를 검증하고, SSO requester 정보와 `ABC_API_KEY` Secret을 추가한 뒤 `POST https://feedback.hmac.kr/api/projects/{projectId}/channels/{channelId}/feedbacks`로 전달합니다. API Key와 내부 ABC 주소는 브라우저에 노출하지 않습니다.
- 첨부파일: 작성페이지가 `multipart/form-data`의 반복 `images` 필드로 파일을 Worker에 전달하고, Worker가 검증된 requester 정보와 API Key를 추가해 ABC API로 전달합니다. ABC API가 `qa_cdn` 저장소에 파일을 저장하므로 R2 access key/secret은 정적 페이지에 넣지 않습니다.
- 텍스트만 등록할 때는 기존 JSON 요청을 사용하고, 파일이 있을 때만 multipart 요청을 사용합니다.
@@ -99,7 +101,7 @@ Cloudflare API Token에는 최소한 다음 권한이 필요합니다.
`.gitea/workflows/deploy.yml`은 `main` push 또는 수동 실행 시 세션 secret을 Worker에 등록하고 R2 업로드 후 `baron-qa-gateway-test`를 배포합니다. 실제 secret 값은 로그에 출력하지 않습니다.
현재 화면은 API 설정 전에도 QA 흐름을 확인할 수 있도록 샘플 글과 로컬 테스트 저장을 포함합니다. 운영 배포 시에는 Gitea Secret `ABC_API_KEY`가 Worker에 등록되어야 하며, 작성페이지는 Worker 프록시를 통해 ABC API를 호출합니다.
화면에는 디자인 목업의 예시 글·공지를 넣지 않습니다. 브라우저는 실제 Worker API를 호출하며 기존 localStorage는 조회·저장 결과의 캐시로 유지합니다. API 설정 전 로컬 저장 성공으로 대체하지 않습니다. 운영은 [production 파이프라인 안내](docs/qa-production-pipeline.md)의 `PROD_` 등록값과 기존 수동 배포 절차를 사용합니다.
## 회사·부서 조직 연동 (스테이징)
+4
View File
@@ -0,0 +1,4 @@
<svg width="49" height="25" viewBox="0 0 49 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.0908 13.3448C22.6196 13.866 21.8444 13.3729 21.499 13.0076L17.569 17.1999C14.2479 20.7127 10.2668 22.0667 7.24255 20.9018L22.942 4.29842C22.942 4.29842 24.5028 2.40791 26.2963 1.17907C26.4242 1.08965 26.4729 1.00279 26.4319 0.836734C26.327 0.422864 25.2754 0.149506 24.0243 0.154615L20.9003 0.16228C20.9003 0.16228 19.7515 0.116294 18.9916 0.164834C18.8329 0.172499 16.8296 0.256806 16.1515 0.31301C15.4607 0.369215 13.6748 0.645128 13.1759 0.765201C9.98791 1.53929 6.94063 2.91119 4.5765 5.34076C1.46271 8.53931 -0.156877 12.3229 0.0119895 15.9966C0.13736 18.7021 1.24523 21.0806 3.13346 22.6926C4.904 24.2051 7.12485 24.997 9.52225 24.9945C10.1389 24.9945 10.7657 24.9434 11.4002 24.8386C14.5345 24.3175 17.5971 22.5547 20.2632 19.7317L25.9458 13.6718C25.7565 13.4444 25.1961 12.9539 24.0908 13.3448ZM3.70914 15.828C3.58633 13.1839 4.82469 10.3634 7.22975 7.91595C10.0749 5.02142 12.3981 4.16047 14.5882 4.03528C15.6168 3.97652 16.5583 4.0966 17.5767 4.5539L4.42299 18.5003C4.00082 17.7288 3.7552 16.8193 3.70914 15.828Z" fill="#CA2829"/>
<path d="M48.4759 8.82779C48.2174 6.07377 46.8537 3.61355 44.7352 2.07559C39.6743 -1.59559 33.7819 -0.356538 28.565 5.47341L22.6214 11.8092L22.125 12.3406C22.2427 12.4964 22.67 12.8771 23.783 12.4836C25.1569 11.9982 26.1036 12.5041 26.5744 13.0023L27.411 12.1055L31.2796 7.98473L31.3103 7.95151C35.2838 3.50369 39.0679 2.53033 42.5604 5.06465C43.8192 5.97925 44.6329 7.47633 44.7915 9.17269C45.032 11.7147 43.896 14.6169 41.3707 17.1333C37.8373 20.6537 34.442 21.7063 31.0084 20.6205L36.1025 15.0869L33.3802 12.5858L25.7633 20.8556C25.7633 20.8556 24.2512 22.6873 22.4934 23.9162C22.3015 24.0286 22.2248 24.1154 22.2734 24.3121C22.3732 24.7132 23.3659 24.9815 24.5659 24.9943L24.5761 24.9994H27.7539C27.7539 24.9994 30.4557 25.007 31.0263 24.9687C31.1338 24.9611 33.0502 24.8103 33.4288 24.7797C33.764 24.7541 35.4117 24.4629 35.7802 24.3709C39.0117 23.5713 41.6163 22.2658 44.0649 19.6676C47.2503 16.2902 48.8161 12.4402 48.4759 8.82779Z" fill="#263B7C"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_레이어_2" data-name="레이어_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 567.43 97.85">
<defs>
<style>
.cls-1 {
fill: #231815;
}
.cls-2 {
fill: #ca2829;
}
.cls-3 {
fill: #263b7c;
}
</style>
</defs>
<g id="_레이어_1-2" data-name="레이어_1">
<g>
<path class="cls-2" d="M94.16,52.23c-5.75,2.04-8.78.11-10.13-1.32l-15.36,16.41c-12.98,13.75-28.54,19.05-40.36,14.49L89.67,16.82s6.1-7.4,13.11-12.21h0c.5-.35.69-.69.53-1.34-.41-1.62-4.52-2.69-9.41-2.67h0l-12.21.03s-4.49-.18-7.46.01c-.62.03-8.45.36-11.1.58-2.7.22-9.68,1.3-11.63,1.77-12.46,3.03-24.37,8.4-33.61,17.91C5.72,33.42-.61,48.23.05,62.61c.49,10.59,4.82,19.9,12.2,26.21,6.92,5.92,15.6,9.02,24.97,9.01,2.41,0,4.86-.2,7.34-.61,12.25-2.04,24.22-8.94,34.64-19.99l22.21-23.72c-.74-.89-2.93-2.81-7.25-1.28h0ZM14.5,61.95c-.48-10.35,4.36-21.39,13.76-30.97,11.12-11.33,20.2-14.7,28.76-15.19,4.02-.23,7.7.24,11.68,2.03l-51.41,54.59c-1.65-3.02-2.61-6.58-2.79-10.46h0Z"/>
<path class="cls-3" d="M189.47,34.55c-1.01-10.78-6.34-20.41-14.62-26.43-19.78-14.37-42.81-9.52-63.2,13.3l-23.23,24.8h0l-1.94,2.08c.46.61,2.13,2.1,6.48.56,5.37-1.9,9.07.08,10.91,2.03l3.27-3.51,15.12-16.13.12-.13c15.53-17.41,30.32-21.22,43.97-11.3,4.92,3.58,8.1,9.44,8.72,16.08.94,9.95-3.5,21.31-13.37,31.16-13.81,13.78-27.08,17.9-40.5,13.65l19.91-21.66-10.64-9.79-29.77,32.37s-5.91,7.17-12.78,11.98c-.75.44-1.05.78-.86,1.55.39,1.57,4.27,2.62,8.96,2.67l.04.02h12.42s10.56.03,12.79-.12c.42-.03,7.91-.62,9.39-.74,1.31-.1,7.75-1.24,9.19-1.6,12.63-3.13,22.81-8.24,32.38-18.41,12.45-13.22,18.57-28.29,17.24-42.43h0Z"/>
<path class="cls-1" d="M219.27,89.13h48.33v-12.41h-33.74v-21.64h29.18v-12.31h-29.18v-19.75h33.25v-12.41h-47.84v78.5ZM276.24,49.83c0,20.64,6.25,40.69,29.97,40.69,9.33,0,16.18-4.47,18.96-10.82l1.98,9.43h10.42v-41.38h-29.67v11.61h15.58c-.69,11.21-4.66,18.76-16.08,18.76s-16.67-6.25-16.67-28.28,5.66-28.09,16.67-28.09c9.73,0,15.28,5.86,15.78,15.38h14.49c-.79-16.57-11.81-27.89-30.27-27.89-25.51,0-31.16,20.44-31.16,40.59h0ZM347.89,58.57h35.53v-11.91h-35.53v11.91ZM426.1,89.13c16.77,0,24.91-7.84,24.91-22.53,0-9.23-5.16-16.28-13.7-18.36,7.05-2.38,11.41-8.04,11.41-16.38,0-14.09-8.34-21.24-24.71-21.24h-29.67v78.5h31.76ZM436.42,65.91c0,7.94-4.27,10.82-13.2,10.82h-14.29v-21.73h14.29c8.93,0,13.2,2.98,13.2,10.92h0ZM434.24,32.86c0,7.54-4.57,9.83-12.9,9.83h-12.41v-19.65h12.41c8.34,0,12.9,2.28,12.9,9.83h0ZM475.43,10.63h-14.59v78.5h14.59V10.63ZM534.18,89.13l19.05-49.82v49.82h14.19V10.63h-15.48l-24.12,63.02-24.22-63.02h-15.48v78.5h14.19v-49.82l19.05,49.82h12.8Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+8
View File
@@ -0,0 +1,8 @@
(function () {
const url = new URL(window.location.href);
const view = url.pathname.endsWith('/write.html') ? 'write' : 'detail';
url.pathname = url.pathname.replace(/[^/]+$/, '');
url.searchParams.set('view', view);
if (view === 'write') url.searchParams.delete('id');
window.location.replace(url.toString());
})();
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 300 120">
<!-- Generator: Adobe Illustrator 28.7.0, SVG Export Plug-In . SVG Version: 1.2.0 Build 136) -->
<g>
<g id="Layer_1">
<g id="Layer_1-2" data-name="Layer_1">
<path d="M0,33.8C0,11.5,16.9.4,32.8.4s32.8,11.1,32.8,33.4v6.9h-19.6v-6.9c0-9.4-6.1-13.8-13.2-13.8s-13.2,4.4-13.2,13.8v52.4c0,9.4,6.1,13.8,13.2,13.8s13.2-4.4,13.2-13.8v-15.8h-15.6v-17.3h35.1v33.1c0,22.3-16.9,33.5-32.8,33.5S0,108.5,0,86.2c0,0,0-52.4,0-52.4ZM116,1.5h16.3l30.5,117h-19.6l-5.8-25.1h-26.7l-5.8,25.1h-19.6L116,1.5ZM124.2,35.2h-.4l-9.6,42.4h19.6s-9.6-42.4-9.6-42.4ZM181.5,1.5h19.6v117h-19.6V1.5ZM253.1,1.5h16.3l30.5,117h-19.6l-5.8-25.1h-26.7l-5.8,25.1h-19.6L253.1,1.5ZM261.4,35.2h-.4l-9.6,42.4h19.6s-9.6-42.4-9.6-42.4Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

+234
View File
@@ -0,0 +1,234 @@
.inquiry-page { --inquiry-blue: #164b91; --inquiry-dark: #26374f; --inquiry-soft: #edf2f6; padding-top: 0; color: #202a38; }
.inquiry-page *, .inquiry-dialog * { box-sizing: border-box; }
.inquiry-page button, .inquiry-dialog button { font: inherit; }
.inquiry-sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; overflow: hidden !important; clip-path: inset(50%); white-space: nowrap; }
.inquiry-page :focus-visible, .inquiry-dialog :focus-visible { outline: 2px solid #0c367f; outline-offset: 3px; }
.inquiry-page input, .inquiry-dialog input, .inquiry-dialog textarea { user-select: text; }
.inquiry-hero { --inquiry-hero-shade: transparent; height: 480px; position: relative; background: #0b2743 linear-gradient(var(--inquiry-hero-shade), var(--inquiry-hero-shade)) center / cover no-repeat; background-image: linear-gradient(var(--inquiry-hero-shade), var(--inquiry-hero-shade)), url(./inquiry-hero.webp); }
.inquiry-hero__copy { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #fff; text-align: center; padding: 40px 24px; }
.inquiry-hero h1 { font-size: 70px; font-weight: 700; line-height: 1.4; letter-spacing: -0.04em; }
.inquiry-hero p { font-size: 22px; line-height: 1.6; }
.inquiry-workspace { position: relative; z-index: 1; width: min(1248px, 100%); padding: 0 24px; margin: -98px auto 120px; }
.inquiry-tabs-shell { --inquiry-tab-curve: 28px; padding: 19px 32px 0; border-radius: 48px 48px 0 0; background: linear-gradient(180deg, #174b64a6, #082f43d9); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); box-shadow: inset 0 1px 0 #74c7f4b3, inset 0 0 0 1px #5fadd666, inset 0 8px 16px #ffffff12, 0 -18px 12px #0a2f4843; }
.inquiry-tabs { display: flex; gap: 60px; position: relative; isolation: isolate; }
.inquiry-tabs button { position: relative; flex: 1; min-width: 0; min-height: 76px; padding: 8px 12px 10px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border: 0; border-radius: 34px 34px 0 0; color: #b9d4e0a6; background: transparent; cursor: pointer; transition: background-color 0.2s ease, color 0.2s ease; }
.inquiry-tabs button:not([aria-selected="true"]):hover { color: #d6f0ff; background: #ffffff12; }
.inquiry-tabs [aria-selected="true"] { z-index: 1; color: #00a2ef; background: linear-gradient(180deg, #e8f8ff 0%, #fff 58%); box-shadow: inset 0 2px 0 #ffffffb3, 0 -3px 10px #163f5b08; }
/* Concave lower corners join the selected tab to the white board beneath it. */
.inquiry-tabs [aria-selected="true"]::before,
.inquiry-tabs [aria-selected="true"]::after { content: ""; position: absolute; bottom: 0; width: var(--inquiry-tab-curve); height: var(--inquiry-tab-curve); pointer-events: none; }
.inquiry-tabs [aria-selected="true"]::before { left: calc(-1 * var(--inquiry-tab-curve)); background: radial-gradient(circle at 0 0, transparent calc(var(--inquiry-tab-curve) - 0.5px), #fff var(--inquiry-tab-curve)); }
.inquiry-tabs [aria-selected="true"]::after { right: calc(-1 * var(--inquiry-tab-curve)); background: radial-gradient(circle at 100% 0, transparent calc(var(--inquiry-tab-curve) - 0.5px), #fff var(--inquiry-tab-curve)); }
.inquiry-tab-logo { width: 156px; max-width: 94%; height: 33px; display: flex; align-items: center; justify-content: center; }
.inquiry-tab-logo img { display: block; max-width: 100%; max-height: 33px; object-fit: contain; opacity: 0.48; filter: brightness(0) invert(1); transition: opacity 0.2s ease; }
.inquiry-tabs [aria-selected="true"] .inquiry-tab-logo img { opacity: 1; filter: none; }
.inquiry-tabs button:not([aria-selected="true"]):hover .inquiry-tab-logo img { opacity: 0.8; }
.inquiry-tab-description { font-size: 16px; line-height: 1.5; font-weight: 500; letter-spacing: -0.02em; white-space: nowrap; }
.inquiry-board { padding: 72px 6px 0; background: #fff; }
.inquiry-board__heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 28px; }
.inquiry-board h2 { font-size: 24px; line-height: 1.5; font-weight: 600; text-align: left; word-break: keep-all; }
.inquiry-remote { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 18px; border: 1px solid #c8d9ef; border-radius: 999px; color: var(--inquiry-blue); background: #fff; font-size: 14px; font-weight: 600; line-height: 1.4; white-space: nowrap; transition: background-color 0.2s ease; }
.inquiry-remote:hover { background: #f0f5fc; }
.inquiry-remote svg { width: 20px; height: 20px; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.inquiry-controls { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding-bottom: 28px; }
.inquiry-filters { display: flex; align-items: center; gap: 24px; flex-shrink: 0; }
.inquiry-categories { display: flex; gap: 6px; border: 0; }
.inquiry-categories label { position: relative; cursor: pointer; }
.inquiry-categories input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.inquiry-categories span { display: flex; align-items: center; justify-content: center; height: 44px; min-width: 60px; padding: 0 16px; border-radius: 999px; color: #758094; font-size: 15px; }
.inquiry-categories label:hover span { background: var(--inquiry-soft); }
.inquiry-categories input:checked + span { color: var(--inquiry-blue); background: #edf3fd; font-weight: 700; }
.inquiry-categories input:focus-visible + span { outline: 2px solid #0c367f; outline-offset: 3px; }
.inquiry-mine { display: inline-flex; align-items: center; gap: 8px; color: #697386; font-size: 13px; white-space: nowrap; cursor: pointer; }
.inquiry-mine input { appearance: auto; width: 16px; height: 16px; accent-color: var(--inquiry-blue); }
.inquiry-search { width: min(360px, 100%); display: flex; align-items: center; gap: 8px; padding: 0 6px 0 20px; border: 1px solid #e6e9ed; border-radius: 999px; background: #f7f8fa; }
.inquiry-search input { flex: 1; min-width: 0; width: 100%; height: 44px; border: 0; padding: 0; color: #273448; font-size: 14px; background: transparent; }
.inquiry-search input::placeholder { color: #9aa2b0; }
.inquiry-search:focus-within { outline: 2px solid #528bdc; outline-offset: 2px; }
.inquiry-search input:focus { outline: none; }
.inquiry-search-button { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 36px; height: 36px; border: 0; border-radius: 50%; color: #35445b; background: transparent; cursor: pointer; }
.inquiry-search-button:hover { background: #e9eef6; }
.inquiry-search-button svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
.inquiry-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 45px; padding: 0 20px; border: 1px solid transparent; border-radius: 3px; background: #27241d; color: #fff; font-size: 16px !important; font-weight: 700 !important; white-space: nowrap; cursor: pointer; }
.inquiry-button:hover { background: #454035; }
.inquiry-button svg { width: 18px; height: 18px; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.inquiry-table-scroll { overflow-x: auto; scrollbar-width: thin; }
.inquiry-scroll-hint { display: none; }
.inquiry-table { table-layout: fixed; min-width: 1024px; width: 100%; font-size: 16px; }
.inquiry-table .col-number { width: 5%; }
.inquiry-table .col-category { width: 8%; }
.inquiry-table .col-company, .inquiry-table .col-team { width: 11%; }
.inquiry-table .col-author { width: 8%; }
.inquiry-table .col-date { width: 10%; }
.inquiry-table .col-status { width: 9%; }
.inquiry-table th { height: 48px; color: #657083; border-top: 1.5px solid var(--inquiry-dark); border-bottom: 1px solid #e5e9ef; font-weight: 500; text-align: center; white-space: nowrap; }
.inquiry-table td { height: 60px; padding: 0 10px; text-align: center; color: #555; border-bottom: 1px solid #eee; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.inquiry-table .inquiry-notice { background: #f6f8fc; font-weight: 500; }
.inquiry-notice .inquiry-subject { color: var(--inquiry-blue); }
.inquiry-table tbody tr:hover { background: #fafafa; }
.inquiry-table .inquiry-subject { text-align: left; }
.inquiry-subject button { display: flex; align-items: center; gap: 6px; width: 100%; color: inherit; text-align: left; }
.inquiry-subject button:hover { color: #08734c; }
.inquiry-subject button > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.inquiry-comments { display: inline-flex; align-items: center; gap: 2px; color: #008cff; font-size: 14px; font-weight: 700; flex-shrink: 0; }
.inquiry-comments svg { width: 15px; height: 15px; fill: currentColor; }
.inquiry-status { display: inline-block; padding: 6px 10px; border-radius: 4px; font-size: 14px; line-height: 1.5; font-weight: 600; }
.inquiry-status--new { color: #ef4444; background: #ef44441a; }
.inquiry-status--done { color: #38b000; background: #38b00014; }
.inquiry-status--review, .inquiry-status--patch { color: #777; background: #f0f0f0; }
.inquiry-table .inquiry-empty { height: 280px; white-space: normal; }
.inquiry-pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin: 30px 0; }
.inquiry-pagination button { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; font-size: 14px; color: #555; }
.inquiry-pagination [aria-current="page"] { background: #edf3fd; color: var(--inquiry-blue); }
.inquiry-pagination button:disabled { opacity: 0.3; cursor: default; }
.inquiry-pagination button:not(:disabled):hover { background: #eee; color: #111; }
.inquiry-board__bottom { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.inquiry-preview-note { color: #999; font-size: 13px; font-weight: 400; line-height: 1.6; }
.inquiry-write { min-width: 120px; border-radius: 999px; }
.inquiry-dialog { position: fixed; inset: 0; width: min(780px, calc(100% - 40px)); max-height: 88dvh; overflow: auto; margin: auto; border: 0; border-radius: 12px; padding: 32px; color: #222; background: #fff; box-shadow: 0 16px 60px #0004; }
.inquiry-dialog::backdrop { background: #17212b99; }
.inquiry-dialog__heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; border-bottom: 2px solid #27241d; padding-bottom: 20px; margin-bottom: 20px; }
.inquiry-dialog h2 { font-size: 24px; font-weight: 700; line-height: 1.5; word-break: keep-all; }
.inquiry-close { font-size: 32px !important; line-height: 1; width: 36px; height: 36px; flex-shrink: 0; }
.inquiry-detail__meta { color: #777; font-size: 14px; line-height: 1.8; }
.inquiry-detail__body { min-height: 180px; padding: 28px 0; line-height: 1.8; white-space: pre-line; user-select: text; }
.inquiry-dialog__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
.inquiry-button--light { color: #333; background: #eee; }
.inquiry-button--light:hover { background: #ddd; }
.inquiry-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin: 24px 0; }
.inquiry-form-grid label, .inquiry-form-wide { display: flex; flex-direction: column; gap: 8px; font-size: 14px; font-weight: 600; }
.inquiry-form-wide { margin-top: 20px; }
.inquiry-dialog input, .inquiry-dialog select, .inquiry-dialog textarea { width: 100%; border: 1px solid #ddd; border-radius: 3px; padding: 10px 12px; min-height: 44px; font-size: 14px; background: #fff; font-weight: 400; }
.inquiry-dialog textarea { resize: vertical; }
.inquiry-dialog input:focus, .inquiry-dialog select:focus, .inquiry-dialog textarea:focus { border: 1px solid #1d8467; outline: 2px solid #1d846733; }
.inquiry-form-feedback { font-size: 14px; color: #08734c; margin-top: 16px; }
body:has(.inquiry-dialog[open]) { overflow: hidden; }
/* Lock background scrolling only while a dialog is open; !important is needed to override layout.css html/body { overflow: visible !important }. */
html.inquiry-modal-open,
html.inquiry-modal-open body { overflow: hidden !important; }
@media (max-width: 1000px) {
.inquiry-scroll-hint { display: block; margin: 0 0 12px; text-align: right; color: #888; font-size: 12px; }
.inquiry-controls { flex-wrap: wrap; gap: 20px; }
.inquiry-search { width: 100%; max-width: none; }
}
@media (max-width: 600px) {
.inquiry-hero { --inquiry-hero-shade: rgba(5, 24, 44, 0.38); height: 340px; background-position: 22% center; }
.inquiry-hero h1 { font-size: 48px; }
.inquiry-hero p { font-size: 17px; }
.inquiry-workspace { margin: -96px auto 72px; padding: 0 16px; }
.inquiry-tabs-shell { --inquiry-tab-curve: 13px; padding: 12px 13px 0; border-radius: 30px 30px 0 0; }
.inquiry-tabs { gap: 5px; }
.inquiry-tabs button { min-height: 84px; padding: 8px 4px 12px; gap: 4px; border-radius: 22px 22px 0 0; }
.inquiry-tab-logo { width: 145px; max-width: 100%; height: 28px; }
.inquiry-tab-logo img { height: auto; max-height: 28px; }
.inquiry-tabs [data-service="egbim"] img { width: 49px; height: 25px; }
.inquiry-tabs [data-service="tova"] img { width: 80px; }
.inquiry-tabs [data-service="gaia"] img { width: 58px; }
.inquiry-tab-description { display: flex; align-items: center; justify-content: center; min-height: 29px; font-size: 11px; line-height: 1.3; white-space: normal; word-break: keep-all; }
.inquiry-board { padding: 24px 4px 0; }
.inquiry-board__heading { flex-wrap: nowrap; gap: 12px; margin-bottom: 22px; }
.inquiry-board h2 { flex: 1 1 auto; font-size: 20px; }
.inquiry-board__heading .inquiry-remote { flex-shrink: 0; margin-left: auto; }
.inquiry-remote { gap: 6px; min-height: 40px; padding: 0 12px; font-size: 12px; }
.inquiry-remote svg { width: 18px; height: 18px; }
.inquiry-filters { width: 100%; flex-wrap: wrap; gap: 16px; }
.inquiry-categories { width: 100%; gap: 6px; }
.inquiry-categories label { flex: 1; }
.inquiry-categories span { min-width: 0; padding: 0 12px; height: 44px; font-size: 14px; }
.inquiry-controls { padding-bottom: 24px; }
.inquiry-search input { font-size: 16px; }
.inquiry-search-button { width: 40px; height: 40px; }
.inquiry-board__bottom { align-items: flex-end; }
.inquiry-preview-note { font-size: 12px; }
.inquiry-dialog { padding: 24px 20px; width: calc(100% - 24px); }
.inquiry-dialog h2 { font-size: 20px; }
.inquiry-form-grid { grid-template-columns: 1fr; gap: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.inquiry-tabs button, .inquiry-tab-logo img, .inquiry-remote { transition: none; }
}
/* Q&A uses its existing authentication, forms and data inside the reference layout. */
.inquiry-page { overflow-x: clip; }
.inquiry-page h1, .inquiry-page h2, .inquiry-page p, .inquiry-dialog h2, .inquiry-dialog h3 { margin: 0; }
.inquiry-page fieldset { margin: 0; padding: 0; }
.inquiry-table { border-collapse: collapse; }
.inquiry-table tbody tr { cursor: pointer; }
.inquiry-tabs button:disabled { cursor: default; }
.inquiry-tabs button:disabled:hover { background: transparent; color: #b9d4e0a6; }
.inquiry-tabs button:disabled:hover img { opacity: .48; }
.inquiry-coming-soon { margin-left: 5px; font-size: 11px; font-weight: 400; }
.inquiry-page .help-text { margin: 0 0 12px; }
.inquiry-board__bottom .help-text { margin: 0; }
.inquiry-table .subject-link { display: flex; align-items: center; gap: 5px; min-width: 0; color: inherit; }
.inquiry-table .subject-link > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.inquiry-table .subject-link:hover { color: #08734c; }
.inquiry-table .col-date { width: 14%; }
.inquiry-table .col-status { width: 8%; }
.inquiry-table .col-author { width: 7%; }
.inquiry-table .lock { flex-shrink: 0; font-size: 12px; }
.inquiry-table .table-empty { min-width: 1024px; }
.inquiry-status--hold { color: #9b6414; background: #fff5df; }
.inquiry-pagination { flex-wrap: wrap; }
.inquiry-pagination button { border: 0; background: transparent; font-family: inherit; cursor: pointer; }
.inquiry-dialog { overscroll-behavior: contain; }
.inquiry-dialog [aria-busy="true"] .detail-card,
.inquiry-dialog [aria-busy="true"] .comment-box { display: none; }
.inquiry-dialog [hidden], .inquiry-page [hidden] { display: none !important; }
.inquiry-dialog .inquiry-dialog__heading { align-items: flex-start; }
.inquiry-dialog .inquiry-dialog__heading h2 { margin: 0; }
.inquiry-close { border: 0; padding: 0; background: transparent; color: #111; cursor: pointer; }
.inquiry-dialog .help-text { margin: 12px 0 0; line-height: 1.7; }
.inquiry-dialog .inquiry-form-grid { margin: 24px 0; }
.inquiry-dialog .inquiry-form-grid label { min-width: 0; }
.inquiry-dialog .inquiry-form-wide { font-weight: 600; }
.inquiry-dialog input[readonly], .inquiry-dialog select:disabled { color: #687282; background: #f7f8fa; }
.inquiry-dialog input[type="checkbox"] { flex: 0 0 auto; width: 17px; height: 17px; min-height: 17px; margin: 0; accent-color: #164b91; }
.inquiry-secret { display: flex; align-items: center; gap: 8px; margin: 16px 0 0; font-size: 14px; }
.inquiry-dialog textarea { min-height: 136px; line-height: 1.8; }
.inquiry-dialog input[type="file"] { padding: 9px 12px; font-size: 13px; }
.inquiry-dialog .form-error { margin: 16px 0 0; padding: 12px 14px; border-radius: 4px; font-size: 14px; }
.inquiry-dialog .detail-card { border-top: 0; padding-bottom: 0; }
.inquiry-dialog .detail-head { padding: 0; border-bottom: 0; }
.inquiry-detail__summary { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.inquiry-dialog .category { padding: 0; min-width: 0; color: #777; background: none; font-size: 13px; }
.inquiry-dialog .status { min-width: 0; padding: 4px 8px; border: 0; border-radius: 4px; font-size: 12px; color: #777; background: #f0f0f0; }
.inquiry-dialog .status[data-status="신규"] { color: #ef4444; background: #ef44441a; }
.inquiry-dialog .status[data-status="완료"] { color: #38b000; background: #38b00014; }
.inquiry-dialog .status[data-status="보류"] { color: #9b6414; background: #fff5df; }
.inquiry-dialog .detail-meta { padding-top: 10px; gap: 6px; font-size: 13px; line-height: 1.8; }
.inquiry-dialog .detail-meta span + span::before { content: '·'; width: auto; height: auto; margin: 0 6px 0 0; background: none; }
.inquiry-dialog .detail-body { min-height: 160px; padding: 28px 0; line-height: 1.8; }
.inquiry-dialog .attachments { padding: 16px 0; background: none; }
.inquiry-dialog .attachments h3 { margin: 0 0 8px; }
.inquiry-dialog .detail-actions { padding: 0; margin: 20px 0 0; }
.inquiry-dialog .button { min-height: 38px; height: auto; padding: 9px 16px; border-radius: 3px; font-size: 13px; }
.inquiry-dialog .button-primary { border-color: #27241d; background: #27241d; }
.inquiry-dialog .button-primary:hover { background: #454035; }
.inquiry-dialog .button:disabled, .inquiry-button:disabled { opacity: .6; cursor: wait; }
.inquiry-dialog .comment-box { margin-top: 32px; }
.inquiry-dialog .comment-box h3 { padding-bottom: 14px; font-size: 16px; }
.inquiry-dialog .comment { padding: 16px 0; }
.inquiry-dialog .comment-form { padding: 18px 0 0; }
.inquiry-dialog .comment-form textarea { min-height: 120px; }
.inquiry-dialog .comment-form-actions { flex-wrap: wrap; }
.inquiry-dialog .comment-edit-input { min-height: 96px; }
.inquiry-dialog .edit-form { padding-top: 8px; }
.inquiry-dialog .is-editing > .detail-card { display: none; }
.inquiry-dialog .edit-form .select { max-width: none; }
.inquiry-dialog .form-row { display: block; min-height: 0; padding: 0; margin: 20px 0 0; border: 0; }
.inquiry-dialog .form-label { display: block; padding: 0; margin-bottom: 8px; background: none; font-size: 14px; font-weight: 600; }
.inquiry-dialog .form-control { padding: 0; }
.inquiry-dialog .form-footer { justify-content: flex-end; padding-top: 24px; }
.inquiry-dialog .completion-notice { margin: 20px 0; font-size: 13px; }
.inquiry-dialog .completion-notice p { margin: 4px 0 0; }
.inquiry-dialog .inquiry-dialog__actions { clear: both; }
@media (max-width: 600px) {
.inquiry-dialog input, .inquiry-dialog select, .inquiry-dialog textarea { font-size: 16px; }
.inquiry-coming-soon { display: block; margin-left: 0; font-size: 9px; }
.inquiry-tabs button:disabled .inquiry-tab-description { flex-direction: column; gap: 2px; }
.inquiry-dialog .comment-meta { gap: 8px; flex-wrap: wrap; }
.inquiry-dialog .completion-notice { flex-direction: column; align-items: stretch; }
.inquiry-dialog .inquiry-dialog__actions { flex-wrap: wrap; }
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 602 150">
<defs>
<style>
.cls-1 {
fill: #000;
}
.cls-1, .cls-2 {
stroke-width: 0px;
}
.cls-2 {
fill: #ffde00;
}
</style>
</defs>
<g id="letter">
<path id="obj1" class="cls-1" d="M295.8,73.5c0,40.6-33.1,73.5-73.9,73.5s-73.9-32.9-73.9-73.5S181,0,221.9,0h73.9l-43.5,43.2h-30.5c-16.8,0-30.5,13.6-30.5,30.3s13.6,30.3,30.5,30.3h30.5l43.5-43.2M497.7,0h-51.9l-99.6,118.5V0h-43.3v149.2h68.9L497.7,0ZM563.7,107.2l-22.8-35.5h-8.5L521.5,0h-14.6l-36.6,43.5h15.7l4.3,28.1h-43.6l-29.9,35.5h78.9l6.4,42h42.2l-6.4-42h25.9,0ZM180.8,0H34L0,43.5h58.4v105.6h43V43.5h41.7C150.3,25,163.7,9.6,180.8,0Z"/>
</g>
<g id="obj">
<path id="obj2" class="cls-2" d="M295.8,38.6l-43.5,43.2v8.4l43.5-43.2M295.8,15.6l-43.5,43.2v8.4l43.5-43.2M553.7,71.7h-7.3l22.8,35.5h7.3l-22.8-35.5ZM578.3,71.7h-7.3l22.8,35.5h7.3l-22.8-35.5ZM565.8,71.7h-7.2l22.8,35.5h7.3l-22.8-35.5h0Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+257
View File
@@ -0,0 +1,257 @@
# S/W 문의 UI 개편 작업 목록
- 작성일: 2026-11-02
- 대상: EG-BIM Q&A 목록·문의 작성·문의 상세
- 디자인 기준: `SW문의_소스패키지`와 사용자가 제공한 목록·상세 모달·작성 모달 스크린샷 3장
- 현재 단계: 프론트 UI 구현 및 모의 API 회귀 검증 완료. 원격 push·스테이징 실서버 확인·운영 배포는 대기 중.
## 1. 사용자 선택으로 확정한 범위
| 항목 | 확정 내용 |
| --- | --- |
| 작성·상세 표시 | 목록 위에 뜨는 페이지 내부 모달로 전환. 원본의 `<dialog>` 방식 기준 |
| 기존 주소 | 기존 작성·상세 주소 연결 유지 |
| 소프트웨어 | EG-BIM 우선 적용. TOVA·GAIA 탭은 준비중 표시 |
| 디자인 적용 범위 | 문의 화면·작성·상세 영역. 홈페이지 헤더·전체 메뉴·푸터는 제외 |
| 입력 중 닫기 | 입력 변경이 있으면 폐기 여부를 확인한 뒤 닫기 |
| 원격지원 | `https://939.co.kr/saman/` 활성화. 원본처럼 새 창으로 열기 |
이번 문서는 현행 한국어 Q&A를 기준으로 한다. 소스 패키지의 영문 페이지는 별도 확대 범위다. 위 선택 항목은 확정됐으며 다시 선택받을 필요가 없다.
## 2. 소스 확인 결과
| 확인 파일 | 확인한 내용 | 적용 방향 |
| --- | --- | --- |
| `SW문의_소스패키지/ko/inquiry.html` | 목록과 작성·상세 `<dialog>`가 한 문서에 있음 | 화면 구조·배치 기준 |
| `SW문의_소스패키지/assets/css/inquiry.css` | 이미지 히어로, 곡선 탭, 검색·표·버튼, 반응형, 모달 스타일 | 문의 영역 스타일을 추려 적용 |
| `SW문의_소스패키지/assets/js/inquiry.js` | 예시 게시물·공지 생성, 모달 열기/닫기, 미리보기 등록 안내 | 모달 UX를 참고하고 실제 데이터 처리는 기존 Q&A 로직으로 연결 |
| `SW문의_소스패키지/assets/js/inquiry-copy.js` | 예시 데이터·상태명·KO/EN 미리보기 문구 | 실제 서비스 문구와 상태명으로 정리 |
| `SW문의_소스패키지/assets/img/inquiry-hero.webp` | 스크린샷의 상단 배경 이미지 | 해당 자산 사용 |
| `SW문의_소스패키지/ko/{egbim,tova,gaia}/img/` | 제품 로고와 모바일용 EG-BIM 심볼 | 활성·준비중 탭에 사용 |
| `SW문의_소스패키지/assets/js/common.js`, `baron-sso-*.js`, `_include/` | 홈페이지 메뉴·헤더·푸터와 별도 SSO 흐름에 의존 | Q&A의 기존 Worker SSO 유지. 필요한 디자인 자산만 반영 |
| `egbim/app.js`, `src/index.js` | 실제 API·SSO·첨부·댓글·검색·작성자 버튼 처리 | 기능 이전의 기준 |
원본의 등록은 저장 API를 호출하지 않고 미리보기 문구만 표시한다. 상세에도 실제 첨부·댓글·수정·삭제·완료 확인 UI가 없다. 따라서 HTML/CSS 전체 복사만으로 기존 기능이 유지되지 않으며, 현재 기능을 새 화면 구조에 연결해야 한다.
`docs/관리페이지 md 파일/detail-popup-window-tasks.md`는 관리페이지의 별도 브라우저 창 작업 기록이다. 이번에 확정한 Q&A 모달 전환과는 다른 화면의 기록이다.
## 3. 목표 화면
### 목록
- 상단 `S/W 문의` 이미지 배경과 설명 문구를 스크린샷에 맞춘다.
- 반투명 배경의 제품 탭과 선택된 EG-BIM 탭의 곡선·로고·색상을 반영한다.
- 홈페이지 헤더를 제외하므로 원본의 헤더 높이 보정용 상단 여백을 함께 조정한다.
- 문의 내역 제목, 원격지원 버튼, 둥근 분류 필터·검색창, 표·페이지 이동·문의등록 버튼을 맞춘다.
- TOVA·GAIA는 준비중임을 표시하고 실제 목록 조회·등록 대상으로 선택되지 않도록 한다.
- 실제 문의 데이터와 현행 검색·상태 표시를 사용한다. 예시 공지·예시 고객 데이터·미리보기 문구는 배포 화면에 넣지 않는다.
### 작성 모달
- 원본의 중앙 모달, 제목과 구분선, 닫기 버튼, 데스크톱 2열·모바일 1열 폼 구성을 반영한다.
- 서비스는 EG-BIM으로 고정한다. TOVA·GAIA로 잘못 등록할 수 없어야 한다.
- 회사·부서·작성자·이메일 칸은 현재 SSO 정보로 자동 표시하며 읽기 전용으로 구성한다.
- 문의 구분·제목·내용·첨부파일에 기존 비밀글 체크 UI를 더한다.
- 작성자 정보의 저장 원본은 기존 Worker의 세션·조직 조회 결과를 유지한다.
- 등록 중 상태, 필수값 오류, 첨부 오류와 API 오류를 모달 안에서 표시한다.
### 상세·수정 모달
- 원본처럼 제목·닫기 버튼·구분선·작성 정보·본문·하단 버튼 순서로 구성한다.
- 본문 아래에 첨부 목록·다운로드, 완료 안내·확인, 댓글·이미지, 댓글 입력 영역을 추가한다.
- 작성자에게만 게시글 수정·삭제 버튼을 표시한다. 댓글 버튼은 각 댓글 작성자에게만 표시한다.
- 수정은 상세 모달의 편집 모드로 연결하고, 저장·취소 후 상세 보기로 복귀한다.
- 댓글과 첨부로 내용이 길어져도 모달 내부에서 스크롤하고 목록의 위치는 유지한다.
기본 모달 크기는 원본의 최대 폭 780px·최대 높이 88dvh·둥근 모서리·어두운 배경을 출발점으로 한다. 상세에 추가되는 기능은 이 구조 안에서 배치하고, 작은 화면의 실제 사용성을 검증한다.
## 4. 반드시 유지할 기능
| 기능 | 개편 후 유지 기준 |
| --- | --- |
| SSO 로그인 | 기존 `/auth/*`, Q&A 세션 쿠키, 로그인 후 복귀 흐름 유지 |
| 회사·부서 | Worker의 `org-context` 매핑 유지. 폼의 수동 입력으로 대체하지 않음 |
| 목록 검색 | fuzzy 검색·초성·띄어쓰기·오타 허용, 정확한 제목 우선 정렬 유지 |
| 목록 수집·필터 | 100건 이후 페이지 수집, 분류·내 글 필터, 로딩·부분 실패 표시 유지 |
| 문의 작성 | 기존 구분·제목·내용 검증과 실제 저장 흐름 유지 |
| 문의 첨부 | 다중 첨부·파일당 30MB, 기존 multipart 업로드와 상세 다운로드 유지 |
| 비밀글 | 작성·수정 체크, 목록의 잠금 표시와 기존 조회 정책 유지 |
| 문의 수정·삭제 | 기존 API 연결과 작성자 전용 버튼 표시 유지 |
| 댓글 | 실제 댓글 조회·등록·수정·삭제와 작성자별 버튼 표시 유지 |
| 댓글 이미지 | 다중 이미지·파일당 30MB, 이미지 형식 검증·미리보기·썸네일 유지 |
| 처리 상태 | 현행 신규·접수·진행중·완료·보류 의미 유지 |
| 완료 확인 | 작성자에게 표시하는 처리 완료 안내·완료 확인·확인 후 상태 갱신 유지 |
| 기존 데이터 | 저장된 작성자·회사·부서·첨부·댓글을 그대로 사용. UI 작업으로 과거 데이터 재매핑하지 않음 |
원본의 상태 예시는 `문의접수/답변완료/검토중/패치예정`이고 현행 상태와 다르다. 배지의 디자인을 적용하면서 현행 상태 의미를 임의로 치환하거나 보류 상태를 누락하지 않는다.
## 5. 화면 전환·주소 연결안
내부 상태 URL은 아래 형태를 구현 기준안으로 사용한다. 기존 주소를 통해 들어오는 사용자도 같은 모달을 볼 수 있도록 연결한다.
| 진입 | 연결할 화면 |
| --- | --- |
| `/egbim/`, `/egbim/index.html` | 새 목록 |
| 목록의 문의등록 | 목록 위 작성 모달. 예: `/egbim/?view=write` |
| 목록 제목 클릭 | 해당 글의 상세 모달. 예: `/egbim/?view=detail&id=<글 UUID>` |
| `/egbim/write.html` | 목록과 작성 모달로 연결 |
| `/egbim/detail.html?id=<글 UUID>` | 목록과 해당 글의 상세 모달로 연결 |
```mermaid
flowchart LR
A[문의 목록] -->|문의등록| B[작성 모달]
A -->|글 제목| C[상세 모달]
B -->|저장 성공| C
C -->|작성자 수정| D[상세 편집 모드]
D -->|저장 또는 취소| C
B -->|닫기| A
C -->|닫기 또는 삭제 완료| A
```
- 모달 URL로 직접 접속·새로고침해도 해당 화면을 복원한다.
- 브라우저 뒤로·앞으로 이동과 모달 열기·닫기를 일치시킨다.
- 모달을 닫을 때 목록의 검색어·분류·내 글 조건·페이지·스크롤을 보존한다.
- 등록 성공 시 작성 모달에서 해당 글의 상세 모달로 전환하고 목록도 갱신한다.
- 수정·댓글 변경·완료 확인 후 상세와 목록의 관련 상태를 동기화한다.
- 삭제 성공 시 목록으로 복귀하고, 마지막 페이지가 비면 유효한 페이지로 보정한다.
- 세션 만료 시 기존 로그인 흐름을 사용하고 로그인 후 원래 작성·상세 진입 주소를 복원한다.
## 6. 실제 API 연결 유지
아래는 브라우저가 사용하는 현재 Worker 경로다. 원본 목업의 폼 이름을 그대로 전송하지 않고 기존 요청 형식을 연결한다.
| 동작 | 기존 경로·방식 | 유지할 처리 |
| --- | --- | --- |
| 로그인 사용자 확인 | `GET /auth/session` | 인증된 사용자·조직 정보 표시 |
| 목록 | `GET /api/feedbacks?limit=100&page=...` | 페이지 수집·검색·필터 |
| 작성 | `POST /api/feedbacks` | 텍스트는 JSON, 첨부는 multipart의 `images` 필드 |
| 상세 | `GET /api/feedbacks/{id}` | 실제 본문·작성자·첨부·상태 |
| 수정·삭제 | `PATCH/DELETE /api/feedbacks/{id}` | 기존 수정 가능 필드와 저장 형식 |
| 댓글 조회·등록 | `GET/POST /api/feedbacks/{id}/comments` | 텍스트 또는 multipart의 `attachments` 이미지 필드 |
| 댓글 수정·삭제 | `PATCH/DELETE /api/feedbacks/{id}/comments/{commentId}` | 실제 댓글 ID와 작성자 연결 |
| 완료 확인 | `POST /api/feedbacks/{id}/completion-confirmation` | 기존 완료 확인 처리 |
이번 범위에서 새 DB나 별도 업로드 API를 도입할 필요는 없다. TOVA·GAIA를 준비중으로 두므로 제품별 API 키·프로젝트·채널 설정 확대도 이번 작업의 선행 조건이 아니다.
## 7. 단계별 작업 목록
### P0. 분석·범위 확정 — 완료
- [x] 원본 HTML·CSS·JS·이미지 목록 확인.
- [x] 원본이 실제 저장 기능이 없는 디자인 목업임을 확인.
- [x] 현행 목록·작성·상세 및 Worker API의 기능 대조.
- [x] 사용자 제공 스크린샷 3장을 디자인 기준으로 반영.
- [x] 모달 전환·기존 주소 유지·EG-BIM 우선·헤더/푸터 제외 결정.
- [x] 입력 변경 확인 후 닫기·원본 원격지원 주소 활성화 결정.
### P1. 스타일·자산 적용
- [x] `UI-01` 문의 전용 스타일 범위를 정하고 기존 공통 버튼·폼·레이아웃과의 충돌을 정리한다.
- [x] `UI-02` 히어로 이미지·제품 로고·필요한 폰트만 배포 자산으로 정리하고 상대 경로를 맞춘다.
- [x] `UI-03` 목록의 히어로·곡선 탭·필터·검색창·표·페이지 이동·등록 버튼을 원본 기준으로 구성한다.
- [x] `UI-04` TOVA·GAIA 준비중 표기와 접근 가능한 비활성 탭 처리를 추가한다.
- [x] `UI-05` 원격지원 링크를 확정 주소로 연결하고 새 창 안내와 `noopener noreferrer`를 유지한다.
- [x] `UI-06` 실제 데이터가 없는 예시 공지·예시 댓글 수·미리보기 문구를 제거한다. 댓글 수 배지가 필요하면 실제 API 제공값을 확인하고 연결하며, 행마다 댓글 API를 추가 호출하는 방식은 피한다.
### P2. 모달·주소·상태 구성
- [x] `UI-07` 목록·작성·상세의 데이터 처리와 화면 표시를 분리하고, 모달을 다시 열어도 이벤트가 중복 등록되지 않도록 구성한다.
- [x] `UI-08` 작성·상세 모달의 열기·닫기·전환과 목록 상태 보존을 구현한다.
- [x] `UI-09` URL 직접 진입·새로고침·뒤로/앞으로 이동을 연결한다.
- [x] `UI-10` 기존 `write.html`, `detail.html?id=...` 주소를 새 모달 진입으로 연결하고 로그인 복귀도 확인한다.
- [x] `UI-11` 입력 변경 확인을 닫기 X·취소·ESC·배경 클릭·뒤로 이동에 동일하게 적용한다. 확인을 취소하면 입력과 선택한 파일을 보존한다.
- [x] `UI-12` 상세 안의 미등록 댓글·편집 중 댓글도 닫기 시 입력 변경 확인 대상에 포함한다.
- [x] `UI-13` 모달을 닫거나 다른 글을 열었을 때 이전 글의 늦은 응답이 새 화면을 덮지 않도록 처리한다.
- [x] `UI-14` 포커스 진입·모달 밖 이동 제한·닫은 뒤 포커스 복귀·배경 스크롤 잠금을 구현한다. 삭제로 원래 행이 사라지면 목록의 적절한 위치로 포커스를 돌린다.
### P3. 실제 작성·상세 기능 이전
- [x] `UI-15` 현재 SSO 확인과 회사·부서 매핑을 작성 모달의 읽기 전용 정보에 연결한다.
- [x] `UI-16` 문의 구분·제목·내용·비밀글·첨부파일과 기존 검증·등록 중 상태를 연결한다.
- [x] `UI-17` 등록 성공 시 전체 페이지 이동 대신 상세 모달을 열고 목록을 갱신한다. 실패 시 입력과 파일 선택 상태를 유지한다.
- [x] `UI-18` 상세 본문·작성 정보·한국 시간 등록일·비밀글 표시·첨부 목록 및 다운로드를 연결한다.
- [x] `UI-19` 작성자 UUID가 일치할 때만 게시글 수정·삭제 버튼을 표시하고 수정 폼·저장·취소·삭제 완료 흐름을 연결한다.
- [x] `UI-20` 댓글 조회·등록·수정·삭제, 댓글 이미지 첨부·미리보기·썸네일을 연결한다.
- [x] `UI-21` 게시글 작성자와 댓글 작성자를 각각 판단한다. 다른 사람의 게시글 안에 있는 본인 댓글에는 댓글 버튼을 표시한다.
- [x] `UI-22` 완료 안내·완료 확인 버튼·확인 완료 표시와 목록 상태 갱신을 연결한다.
- [x] `UI-23` 모달 안의 성공·오류 안내를 표시하고, 반복 열기·닫기 때 오류·수정 상태·이전 글의 댓글·이미지가 남지 않도록 초기화한다.
- [x] `UI-24` 실제로 폐기된 첨부·댓글 이미지 미리보기의 임시 URL을 정리한다.
### P4. 반응형·회귀 검증
- [x] `UI-25` 데스크톱 목록·작성·상세 화면을 제공 스크린샷과 비교한다.
- [x] `UI-26` 모바일에서 제품 탭·가로 스크롤 표·1열 작성 폼·모달 내부 스크롤·하단 버튼을 확인한다.
- [x] `UI-27` 작성자·타인·동명이인·작성자 ID 없음·세션 확인 중/만료/실패 조건에서 게시글과 댓글 버튼의 실제 가시성을 검사한다. 새 `.inquiry-button`에도 `hidden` 속성이 적용되도록 한다.
- [x] `UI-28` 오타·초성·띄어쓰기 검색, 100건 이후 데이터, 분류·내 글·페이지 이동 회귀를 검사한다.
- [x] `UI-29` JSON 작성·다중 첨부 작성·비밀글·첨부 다운로드·댓글 이미지·수정/삭제·완료 확인을 모의 API와 브라우저에서 검사한다.
- [x] `UI-30` 반복 모달 열기·등록/저장 중 상태·닫기 확인 취소·오류 후 재시도·다른 글의 늦은 응답을 검사한다.
- [x] `UI-31` 기존 작성자·댓글·검색·Worker 테스트를 새 화면 진입에 맞춰 유지하고 `npm run check`, `npm test`를 통과시킨다.
### P5. 자산 배포·문서 정리
- [x] `UI-32` `scripts/upload-r2.sh`가 새 CSS·JS·이미지·로고를 업로드하도록 경로를 확인하고 WebP의 MIME을 `image/webp`로 지정한다.
- [x] `UI-33` 기존 주소 연결 파일도 배포 목록에 유지하고 CSS·JS 버전을 갱신한다.
- [x] `UI-34` 현행 3개 페이지 전이 설명을 목록·모달 흐름과 실제 multipart API 기준으로 갱신한다. 구형 presign·로컬 저장 전용 설명을 구현 기준으로 사용하지 않는다.
- [x] `UI-35` 변경 커밋과 검증 결과를 정리한다. 원격 push는 기존 요청대로 사용자가 진행한다.
- [ ] `UI-36` 스테이징 자동 배포 성공 후 실제 로그인·조직 정보·작성·첨부·댓글·비밀글·기존 주소를 확인한다.
- [ ] `UI-37` 운영 적용 시 기존 수동 운영 배포 절차를 따른다. UI 개편을 이유로 운영 API 설정이나 SSO client/callback을 임의로 교체하지 않는다.
## 8. 예상 변경 파일
| 파일·영역 | 작업 |
| --- | --- |
| `egbim/index.html` | 새 목록과 작성·상세 모달의 화면 구조 |
| `egbim/write.html`, `egbim/detail.html` | 기존 주소의 진입 연결 |
| `egbim/app.js` 및 필요 시 문의 전용 JS | 기존 기능 연결·모달 수명주기·URL 상태 처리 |
| `assets/styles.css` 및 문의 전용 CSS | 원본 디자인·기능 확장 영역·반응형·숨김 처리 |
| `assets/` 아래 문의용 자산 | 히어로·로고·필요한 폰트 |
| `src/index.js` | 변경 없음. 기존 로그인 보호·API·SSO·조직 처리 유지 |
| `scripts/upload-r2.sh` | 새 자산 경로·MIME 확인 |
| `tests/page-*.test.js`, 필요 시 Worker 라우팅 검사 | 목록·모달·기능·기존 주소 회귀 |
| `README.md`, `docs/qa-login-to-save-transition.md` | 화면 구조·진입 주소·현재 API 흐름 설명 |
원본 패키지는 비교 기준으로 보존한다. 필요한 자산을 선별해 서비스 경로에 반영하며 패키지 전체와 홈페이지 공통 인증 스크립트를 그대로 배포하지 않는다.
## 9. 완료 기준
- [x] 제공한 세 화면의 배경·탭·목록·폼·모달 디자인을 반영했다.
- [x] 작성·상세를 목록 위 모달로 열며 기존 URL로도 같은 글·작성 화면에 진입한다.
- [x] 회사·부서가 SSO 조직 정보로 표시되고 기존 작성자 식별자를 유지한다.
- [x] 첨부·댓글·비밀글·수정/삭제·완료 확인이 실제 기존 API로 동작한다.
- [x] 게시글·댓글 버튼이 실제 작성자에게만 보이며 로그인 확인 전에도 잘못 노출되지 않는다.
- [x] fuzzy 검색과 전체 페이지 수집·필터·페이지 이동을 유지한다.
- [x] 입력 변경 후 닫기는 확인을 거치며, 닫기 취소 시 입력과 파일을 보존한다.
- [x] 모달을 닫아도 목록 조건·위치가 유지되고 새 글·수정·삭제 결과가 반영된다.
- [x] TOVA·GAIA 준비중 표시와 확정된 원격지원 링크가 동작한다.
- [x] 예시 데이터·미리보기 전용 문구가 남지 않고 모바일·키보드 사용과 회귀 검증을 통과했다.
## 10. 구현·검증 결과
변경은 정적 HTML/CSS/JS와 자산 업로드 MIME·검증·문서에 한정했다. Worker·API 계약·DB·SSO 설정·기존 배포 트리거는 변경하지 않았다. 기존 주소는 프론트의 `assets/inquiry/entry.js`로 연결하며, 새 화면은 기존 API를 그대로 호출한다. 원본 패키지 전체를 서비스 자산으로 복사하지 않았다.
- `npm run check`, `npm test`: 기존 94개와 구형 주소 연결 4개, 총 98개 테스트 통과.
- R2 업로드 검사: 새 하위 경로의 진입 JS와 WebP 이미지가 기존 배포 경로에 포함되며 MIME은 각각 `application/javascript`, `image/webp`다.
- Chromium 데스크톱 1900×1000, 모바일 390×844에서 제공 스크린샷과 비교했다. 모바일 표는 기존 열을 유지한 가로 스크롤, 작성 폼은 1열, 모달은 내부 스크롤이다.
- 모의 API 브라우저 검사: fuzzy 검색·페이지 이동·목록 조건 보존·JSON/다중 첨부 작성·비밀글·첨부 링크·게시글 수정/삭제·댓글 수정/삭제/이미지 등록·완료 확인 통과.
- 모달 검사: 기존 URL·직접 진입·새로고침·뒤로/앞으로·X/ESC/배경 닫기·입력 폐기 확인·파일 유지·등록 실패 재시도·등록 중 닫기 방지·반복 열기·이전 글의 늦은 응답·키보드 모달 밖 이동 제한 통과.
- 권한 검사: 작성자·타인·동명이인/ID 없음·만료/오래된 저장값·세션 조회 실패·세션 응답 대기의 6조건에서 게시글/댓글 버튼 실제 가시성을 검사했다.
- 100건 이후 페이지 수집·분류·내 글·초성·오타·띄어쓰기와 기존 Worker/API 회귀는 기본 테스트에서 확인했다.
위 체크는 로컬 및 모의 API 기준이다. 실제 SSO 로그인·관리페이지 DB 저장·실제 파일 다운로드/업로드 확인은 `UI-36`에 남겨 두었으며, `UI-37` 운영 배포도 아직 수행하지 않았다. 사용자가 push한 뒤 스테이징 배포 결과를 확인한다.
### 브라우저 회귀 검증
`tests/browser/inquiry-ui.mjs`는 Playwright와 Chromium이 있는 별도 검증 환경에서 실행한다. 앱 의존성에는 브라우저 패키지를 추가하지 않았다. 모든 네트워크 요청을 모의 API 또는 로컬 정적 파일로 처리하고 외부 요청은 차단한다.
```bash
# Playwright와 해당 Chromium이 이미 설치된 환경
npm run test:ui
# 검증 도구가 저장소 밖에 설치되어 있는 경우
PLAYWRIGHT_MODULE=/absolute/path/to/playwright-core \
CHROMIUM_EXECUTABLE_PATH=/absolute/path/to/chromium \
npm run test:ui
```
기본 스크린샷 출력 위치는 임시 디렉토리의 `qa-inquiry-ui/`다. `UI_SCREENSHOT_DIR`로 바꿀 수 있으며, 데스크톱·모바일 목록/작성/상세 6장을 생성한다. 실제 사용자 세션·API Key는 필요하지 않다.
+64 -146
View File
@@ -1,165 +1,83 @@
# EG-BIM Q&A 로그인부터 글 저장까지 전이맵
# EG-BIM Q&A 로그인·화면·저장 흐름
기준일: 2026-09-21
대상: `qa-test.baroncs.co.kr` / EG-BIM Q&A
구성: 목록(`index.html`) · 작성(`write.html`) · 상세(`detail.html`)
기준일: 2026-11-02. 스테이징 `qa-test.baroncs.co.kr`, 운영 `qa.baroncs.co.kr`의 현재 구조를 설명한다. 이번 UI 개편은 프론트에 한정하며 Worker API·DB·SSO 설정을 변경하지 않는다.
## 1. 전체 흐름
## 로그인과 화면 진입
홈페이지 로그인 정보를 Q&A 사이트로 직접 전달하지 않는다. 홈페이지의 Q&A 링크는 별도 호스트로 이동하고, Q&A는 같은 BRSW OIDC SSO에 직접 인증을 요청한다. 이미 중앙 SSO 세션이 있으면 사용자는 로그인 화면을 보지 않고 Q&A 세션을 발급받는다.
Q&A는 기존 Worker의 OIDC Authorization Code + PKCE와 HttpOnly 세션을 사용한다. 홈페이지에서 토큰을 전달하지 않는다. 보호된 페이지에 세션 없이 접근하면 Worker가 `/auth/login?return_url=...`로 보내며, 인증 후 원래 경로와 쿼리로 돌아온다. 작성자와 회사·부서는 `GET /auth/session`의 검증된 정보로 표시한다.
| 주소·사용자 동작 | 화면 |
| --- | --- |
| `/egbim/`, `/egbim/index.html` | 문의 목록 |
| 문의등록 / `?view=write` | 목록 위 작성 모달 |
| 글 제목 / `?view=detail&id=<UUID>` | 목록 위 해당 글 상세 모달 |
| `/egbim/write.html` | 프론트에서 `/egbim/?view=write`로 replace 이동 |
| `/egbim/detail.html?id=<UUID>` | 프론트에서 `/egbim/?view=detail&id=<UUID>`로 replace 이동 |
구형 주소도 Worker의 기존 로그인 보호를 거친다. 새로고침과 직접 링크는 URL에 지정한 모달을 복원한다. 모달의 열기·닫기는 브라우저 뒤로·앞으로 이동과 연결한다. 목록 위에서 열었을 때 검색어·분류·내 글·페이지·스크롤을 유지한다. 작성·수정·댓글 초안이나 파일 선택 변경이 있으면 닫기 전에 폐기를 확인한다.
```mermaid
flowchart TD
A[바론 홈페이지<br/>EG-BIM Q&A 링크] --> B[Q&A 목록 진입<br/>https://qa-test.baroncs.co.kr/egbim/]
B --> C{Q&A 세션 쿠키<br/>baron_qa_session_v2 유효?}
C -- 예 --> G[정적 목록 페이지 표시]
C -- 아니오 --> D[302 /auth/login<br/>return_url 보관]
D --> E[Worker가 PKCE 생성<br/>state · verifier · nonce]
E --> F[BARON-SSO OIDC<br/>sso.hmac.kr/oidc]
F --> F1{중앙 SSO 세션 존재?}
F1 -- 아니오 --> F2[SSO 로그인 화면]
F2 --> F3[최초 동의 화면<br/>필요 시 1회]
F1 -- 예 --> H[승인 코드 발급]
F3 --> H
H --> I[/auth/callback?code&state]
I --> J[Worker가 state 검증<br/>PKCE code_verifier 검증]
J --> K[Token Endpoint 호출<br/>Public Client · client_secret 없음]
K --> L[UserInfo 조회 및 claim 정규화]
L --> M[baron_qa_session_v2 발급<br/>HttpOnly · Secure · SameSite=Lax]
M --> G
G --> N[문의 등록 버튼]
N --> O[작성 페이지<br/>write.html]
O --> P[GET /auth/session<br/>작성자·tenant 표시]
P --> Q{userUuid와 tenantId 존재?}
Q -- 아니오 --> R[제출 차단<br/>다시 로그인 안내]
Q -- 예 --> S[카테고리·제목·내용·비밀글 입력]
S --> T[등록 버튼 클릭]
T --> U[브라우저 UUID 생성<br/>feedbackId]
U --> V{첨부파일 존재?}
V -- 아니오 --> Y[첨부 메타데이터 빈 배열]
V -- 예 --> W[POST presign 요청]
W --> X[qa_cdn presigned URL로<br/>파일 PUT 업로드]
X --> Y[첨부파일 메타데이터 확정]
Y --> Z[feedback envelope 생성]
Z --> AA{apiBaseUrl 설정 여부}
AA -- 비어 있음<br/>현재 테스트 모드 --> AB[localStorage에 임시 저장]
AA -- 설정됨<br/>실서비스 모드 --> AC[POST /v1/qa/feedbacks<br/>feedback.hmac.kr API]
AB --> AD[등록 완료 toast<br/>상세 페이지 이동]
AC --> AE{API 성공?}
AE -- 예 --> AD
AE -- 아니오 --> AF[오류 표시<br/>재시도 가능]
A[Q&A 주소 진입] --> B{Q&A 세션 유효?}
B -->|아니오| C[기존 OIDC SSO 로그인]
C --> D[callback 검증·세션 발급]
D --> A
B -->|예| E[목록 + URL에 지정한 모달]
E --> F[GET /auth/session]
F --> G[작성자·회사·부서 표시]
G --> H[작성 모달 입력·검증]
H --> I{첨부파일 있음?}
I -->|아니오| J[POST /api/feedbacks JSON]
I -->|예| K[POST /api/feedbacks multipart images]
J --> L[기존 Worker: 세션·조직 확인 후 ABC 전달]
K --> L
L --> M{저장 성공?}
M -->|예| N[목록 갱신·해당 글 상세 모달]
M -->|아니오| O[모달 오류 표시·입력과 파일 유지]
```
## 2. 상태 전이표
## 작성·첨부 저장
| 상태 | 화면/주소 | 진입 조건 | 주요 처리 | 다음 상태 |
|---|---|---|---|---|
| `S0` | 홈페이지 | Q&A 메뉴 클릭 | 외부 Q&A URL로 이동 | `S1` |
| `S1` | `/egbim/` | Q&A 세션 유효 | R2의 목록 HTML 제공 | `S5` |
| `S2` | `/auth/login` | 세션 없음 | PKCE 상태 생성, SSO로 redirect | `S3` |
| `S3` | `sso.hmac.kr/oidc` | SSO 세션 없음 | 최초 로그인 및 동의 | `S4` |
| `S4` | `/auth/callback` | `code`, `state` 수신 | state/PKCE 검증, token/userinfo 조회 | `S5` |
| `S5` | 목록 또는 작성 페이지 | `baron_qa_session_v2` 유효 | `/auth/session`에서 사용자 확인 | `S6` |
| `S6` | `write.html` | `userUuid`, `tenantId` 확인 | 작성 폼 입력 대기 | `S7` |
| `S7` | 작성 페이지 | 등록 클릭 | `feedbackId` UUID 생성 및 입력 검증 | `S8` |
| `S8` | 작성 페이지 | 첨부 있음 | presign → `qa_cdn` 업로드 | `S9` |
| `S9` | 작성 페이지 | payload 준비 | feedback/support ticket envelope 생성 | `S10` |
| `S10` | API 또는 localStorage | `apiBaseUrl` 상태에 따라 분기 | 실제 DB 저장 또는 테스트 저장 | `S11` |
| `S11` | `detail.html?id=...` | 저장 성공 | 등록 완료 및 상세 이동 | 종료 |
브라우저는 기존 UUID v7 생성 방식으로 `feedbackId`를 만들고 아래 필드를 보낸다. 회사·부서·작성자·이메일 칸은 읽기 전용이며, 서버가 기존 세션·조직 조회 결과에서 작성자 정보를 결정한다.
## 3. 화면별 사용자 경험
### A. 홈페이지
- EG-BIM 메뉴의 Q&A 링크만 제공한다.
- 로그인 정보, 쿠키, token, `SESSION_SECRET`을 Q&A로 전달하지 않는다.
- 링크 대상:
```text
https://qa-test.baroncs.co.kr/egbim/
```json
{
"feedbackId": "<UUID v7>",
"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 검증만으로 실서버 검증 완료로 표시하지 않는다.
+301 -80
View File
@@ -20,6 +20,7 @@
};
let authReady;
let resolvedAuthUser = null;
let inquiryUI = null;
function qs(selector, root) { return (root || document).querySelector(selector); }
function qsa(selector, root) { return Array.prototype.slice.call((root || document).querySelectorAll(selector)); }
@@ -590,6 +591,14 @@
let loadFailed = false;
let receivedRemotePage = false;
const searchStatus = qs('#searchStatus');
const changes = new Map();
const deleted = new Set();
function mergeChanges(remotePosts) {
const result = remotePosts.filter(function (post) { return !deleted.has(post.id); }).map(function (post) { return changes.get(post.id) || post; });
for (const post of changes.values()) if (!deleted.has(post.id) && !result.some(function (item) { return item.id === post.id; })) result.unshift(post);
return result;
}
function getPostCompany(post, user) { return getPostCompanyLabel(post, user); }
@@ -621,15 +630,19 @@
const isNotice = post.category === "공지사항";
const number = isNotice ? "공지" : filtered.length - ((page - 1) * pageSize + index);
const status = normalizeStatus(post.status);
const statusClass = status === "답변완료" ? "done" : (status === "문의검토" || status === "정밀검토" ? "review" : "");
const statusClass = status === '완료' ? 'done' : status === '신규' ? 'new' : status === '보류' ? 'hold' : status === '진행중' ? 'patch' : 'review';
const company = getPostCompany(post, user);
return "<tr class=\"" + (isNotice ? "notice-row" : "") + "\" data-id=\"" + escapeHtml(post.id) + "\">" +
"<td>" + number + "</td><td class=\"category-cell\">" + escapeHtml(post.category.replace("문의", "")) + "</td>" +
"<td>" + escapeHtml(company) + "</td><td>" + escapeHtml(post.department) + "</td><td>" + escapeHtml(post.author) + "</td>" +
"<td class=\"subject\"><span class=\"subject-link\">" + (post.secret ? "<span class=\"lock\">🔒</span>" : "") + escapeHtml(post.title) + "</span>" + (!isNotice && post.date === "2026-09-17" ? "<span class=\"new-mark\">N</span>" : "") + "</td>" +
"<td>" + escapeHtml(post.dateTime || post.date) + "</td><td>" + (status ? "<span class=\"status " + statusClass + "\">" + escapeHtml(status) + "</span>" : "-") + "</td></tr>";
'<td title="' + escapeHtml(company) + '">' + escapeHtml(company) + '</td><td title="' + escapeHtml(post.department) + '">' + escapeHtml(post.department) + '</td><td>' + escapeHtml(post.author) + '</td>' +
'<td class="inquiry-subject"><a class="subject-link" title="' + escapeHtml(post.title) + '" href="detail.html?id=' + encodeURIComponent(post.id) + '">' + (post.secret ? '<span class="lock" aria-label="비밀글">🔒</span>' : '') + '<span>' + escapeHtml(post.title) + '</span></a></td>' +
'<td title="' + escapeHtml(post.dateTime || post.date) + '">' + escapeHtml(post.dateTime || post.date) + '</td><td>' + (status ? '<span class="inquiry-status inquiry-status--' + statusClass + '">' + escapeHtml(status) + '</span>' : '-') + '</td></tr>';
}).join("");
qsa("#qaRows tr").forEach(function (row) { row.addEventListener("click", function () { window.location.href = "detail.html?id=" + encodeURIComponent(row.dataset.id); }); });
qsa("#qaRows tr").forEach(function (row) { row.addEventListener("click", function (event) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (inquiryUI) { event.preventDefault(); inquiryUI.open('detail', row.dataset.id, row.querySelector('a')); }
else window.location.href = 'detail.html?id=' + encodeURIComponent(row.dataset.id);
}); });
const visiblePages = Array.from(new Set([1, totalPages, page - 2, page - 1, page, page + 1, page + 2])).filter(function (value) { return value >= 1 && value <= totalPages; }).sort(function (a, b) { return a - b; });
pagination.innerHTML = '<button type="button" data-page="' + Math.max(1, page - 1) + '" aria-label="이전 페이지"' + (page === 1 ? ' disabled' : '') + '>‹</button>' + visiblePages.map(function (value, index) {
const gap = index > 0 && value - visiblePages[index - 1] > 1 ? '<span aria-hidden="true">…</span>' : '';
@@ -642,8 +655,8 @@
qsa("input[name=category], #onlyMine").forEach(function (input) { input.addEventListener("change", function () { page = 1; render(); }); });
render();
if (authReady) authReady.then(render);
loadRemotePosts(function (result) { receivedRemotePage = true; posts = result.posts; render(); }).then(function (result) {
posts = result.posts;
loadRemotePosts(function (result) { receivedRemotePage = true; posts = mergeChanges(result.posts); render(); }).then(function (result) {
posts = mergeChanges(result.posts);
remoteLoaded = true;
render();
}).catch(function (error) {
@@ -653,16 +666,26 @@
render();
if (!receivedRemotePage) refreshRemoteStatuses(posts).then(function (changed) { if (changed) render(); });
});
return {
upsert: function (post) { changes.set(post.id, post); posts = mergeChanges(posts); render(); },
remove: function (id) { changes.delete(id); deleted.add(id); posts = posts.filter(function (post) { return post.id !== id; }); render(); }
};
}
function initWrite() {
const form = qs('#qaForm');
function initWrite(root) {
const find = function (selector, container) { return qs(selector, container || root); };
const form = find('#qaForm');
if (!form) return;
const user = getAuthUser();
const errorBox = qs('#formError');
if (!user) {
qs('#writeFields').setAttribute('aria-disabled', 'true');
}
const errorBox = find('#formError');
Promise.resolve(authReady || user).then(function (profile) {
for (const [selector, value] of [['#writeCompany', profile && profile.company], ['#writeDepartment', profile && profile.department], ['#writeAuthor', profile && (profile.name || profile.loginId)], ['#writeEmail', profile && profile.email]]) {
const field = find(selector);
if (field) field.value = value || '-';
}
const notice = find('[data-auth-state]');
if (notice) notice.textContent = profile ? '로그인한 사용자 정보로 문의가 등록됩니다.' : '로그인 상태를 확인하지 못했습니다. 새로고침 후 다시 시도해주세요.';
});
form.addEventListener('submit', async function (event) {
event.preventDefault();
console.info('[QA] feedback submit started');
@@ -671,17 +694,17 @@
console.info('[QA] SSO user resolved:', Boolean(currentUser), currentUser ? { hasRequesterId: Boolean(currentUser.requesterId), hasTenantId: Boolean(currentUser.requesterTenantId), hasPhone: Boolean(currentUser.phone) } : null);
if (!currentUser) { errorBox.textContent = '로그인 후 문의를 등록할 수 있습니다.'; errorBox.style.display = 'block'; return; }
if (!currentUser.requesterId || !currentUser.requesterTenantId) { errorBox.textContent = 'SSO에서 작성자 UUID와 테넌트 정보를 확인하지 못했습니다. 다시 로그인한 뒤 시도해주세요.'; errorBox.style.display = 'block'; return; }
const title = qs('#title').value.trim();
const content = qs('#content').value.trim();
if (!qs('#category').value || !title || !content) { errorBox.textContent = '구분, 제목, 내용을 모두 입력해주세요.'; errorBox.style.display = 'block'; return; }
const title = find('#title').value.trim();
const content = find('#content').value.trim();
if (!find('#category').value || !title || !content) { errorBox.textContent = '구분, 제목, 내용을 모두 입력해주세요.'; errorBox.style.display = 'block'; return; }
const feedbackId = makeUuid();
const createdAt = new Date().toISOString();
const post = { id: feedbackId, feedbackId: feedbackId, requesterId: currentUser.requesterId, requesterTenantId: currentUser.requesterTenantId, category: qs('#category').value, company: currentUser.company || currentUser.tenantName || currentUser.familyCompany || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: formatKoreanDateTime(createdAt, false), dateTime: formatKoreanDateTime(createdAt, true), createdAt: createdAt, status: '신규', secret: qs('#secret').checked, content: content };
const submitButton = qs('#submitButton');
const post = { id: feedbackId, feedbackId: feedbackId, requesterId: currentUser.requesterId, requesterTenantId: currentUser.requesterTenantId, category: find('#category').value, company: currentUser.company || currentUser.tenantName || currentUser.familyCompany || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: formatKoreanDateTime(createdAt, false), dateTime: formatKoreanDateTime(createdAt, true), createdAt: createdAt, status: '신규', secret: find('#secret').checked, content: content };
const submitButton = find('#submitButton');
submitButton.disabled = true;
submitButton.textContent = '등록 중...';
try {
const submission = await buildFeedbackSubmission(post, feedbackId, qs('#attachment').files);
const submission = await buildFeedbackSubmission(post, feedbackId, find('#attachment').files);
console.info('[QA] sending feedback to Worker:', config.createFeedbackPath);
const result = await postToApi(config.createFeedbackPath, submission.body);
console.info('[QA] Worker feedback response:', result);
@@ -689,7 +712,8 @@
post.attachments = submission.attachments;
savePost(post);
showToast(result.local ? '테스트 글로 저장했습니다. UUID가 생성되었습니다.' : '문의가 등록되었습니다.');
window.setTimeout(function () { window.location.href = 'detail.html?id=' + encodeURIComponent(post.id); }, 500);
if (inquiryUI) inquiryUI.saved(post);
else window.setTimeout(function () { window.location.href = 'detail.html?id=' + encodeURIComponent(post.id); }, 500);
} catch (error) {
console.error('[QA] feedback submit failed:', error);
errorBox.textContent = error.message || '등록 중 오류가 발생했습니다.';
@@ -700,14 +724,15 @@
});
}
async function initDetail() {
const detail = qs('#detail');
async function initDetail(root, requestedId) {
const find = function (selector, container) { return qs(selector, container || root); };
const detail = find('#detail');
if (!detail) return;
const detailListLink = qs('.detail-actions > a', detail);
const detailListLink = find('.detail-actions > a', detail);
if (detailListLink) detailListLink.textContent = '목록으로';
const bottomBackButton = qs('#backButton', detail);
const bottomBackButton = find('#backButton', detail);
if (bottomBackButton && bottomBackButton.parentElement) bottomBackButton.parentElement.remove();
const id = new URLSearchParams(window.location.search).get("id") || "EG-1048";
const id = requestedId || new URLSearchParams(window.location.search).get("id") || "EG-1048";
const storedPost = getPosts().find(function (item) { return item.id === id; });
let post = storedPost;
try {
@@ -718,42 +743,45 @@
console.warn("[QA] feedback detail load failed:", error.message);
}
if (!post) {
qs('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
qs('[data-detail-content]').textContent = '목록에 등록된 문의가 없습니다.';
find('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
find('[data-detail-content]').textContent = '목록에 등록된 문의가 없습니다.';
const commentBox = find('.comment-box');
if (commentBox) commentBox.hidden = true;
return;
}
qs('[data-detail-category]').textContent = post.category;
qs('[data-detail-title]').textContent = post.title;
find('[data-detail-category]').textContent = post.category;
find('[data-detail-title]').textContent = post.title;
const renderDetailStatus = function () {
const statusElement = qs("[data-detail-status]");
const statusElement = find("[data-detail-status]");
const status = normalizeStatus(post.status) || "공지";
if (!statusElement) return;
statusElement.textContent = status;
statusElement.classList.toggle("done", status === "완료");
statusElement.classList.toggle("review", status !== "완료");
if (statusElement.setAttribute) statusElement.setAttribute('data-status', status);
};
renderDetailStatus();
qs('[data-detail-date]').textContent = post.dateTime || post.date;
qs('[data-detail-author]').textContent = post.author;
find('[data-detail-date]').textContent = post.dateTime || post.date;
find('[data-detail-author]').textContent = post.author;
const detailUser = await (authReady || Promise.resolve(getAuthUser()));
qs('[data-detail-company]').textContent = getPostCompanyLabel(post, detailUser);
qs('[data-detail-department]').textContent = post.department;
qs('[data-detail-content]').textContent = post.content;
qs('[data-detail-secret]').hidden = !post.secret;
const attachmentBox = qs('[data-detail-attachments]');
find('[data-detail-company]').textContent = getPostCompanyLabel(post, detailUser);
find('[data-detail-department]').textContent = post.department;
find('[data-detail-content]').textContent = post.content;
find('[data-detail-secret]').hidden = !post.secret;
const attachmentBox = find('[data-detail-attachments]');
const attachments = Array.isArray(post.attachments) ? post.attachments : [];
if (attachmentBox) attachmentBox.innerHTML = attachments.length ? attachments.map(function (attachment) {
const file = normalizeFeedbackAttachment(attachment);
if (!file.url) return '<div class="attachment-item">📎 ' + escapeHtml(file.name) + '</div>';
return '<div class="attachment-item"><a href="' + escapeHtml(file.url) + '" target="_blank" rel="noopener noreferrer">📎 ' + escapeHtml(file.name) + '</a></div>';
}).join('') : '<span class="help-text">첨부된 파일이 없습니다.</span>';
const editButton = qs('#editButton');
const deleteButton = qs('#deleteButton');
const editForm = qs('#editForm');
const editError = qs('#editError');
const completionNotice = qs("#completionNotice");
const completionDoneNotice = qs("#completionDoneNotice");
const completionButton = qs("#completionButton");
const editButton = find('#editButton');
const deleteButton = find('#deleteButton');
const editForm = find('#editForm');
const editError = find('#editError');
const completionNotice = find("#completionNotice");
const completionDoneNotice = find("#completionDoneNotice");
const completionButton = find("#completionButton");
const renderCompletionNotice = function () {
const owner = isPostOwner(post, detailUser);
const completed = normalizeStatus(post.status) === "완료" || post.completionConfirmed;
@@ -778,6 +806,7 @@
post.completionConfirmed = true;
post.status = "완료";
updateStoredPost(post);
if (inquiryUI) inquiryUI.changed(post);
renderDetailStatus();
renderCompletionNotice();
showToast("문의가 완료 처리되었습니다.");
@@ -789,49 +818,55 @@
}
});
if (editButton && editForm) editButton.addEventListener('click', function () {
qs('#editCategory').value = post.category || '일반문의';
qs('#editTitle').value = post.title || '';
qs('#editContent').value = post.content || '';
qs('#editSecret').checked = Boolean(post.secret);
find('#editCategory').value = post.category || '일반문의';
find('#editTitle').value = post.title || '';
find('#editContent').value = post.content || '';
find('#editSecret').checked = Boolean(post.secret);
editError.hidden = true;
editError.style.display = 'none';
editForm.hidden = false;
detail.classList.add('is-editing');
editForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (inquiryUI) inquiryUI.markClean(editForm);
});
const editCancel = find('#editCancel');
if (editCancel && editForm) editCancel.addEventListener('click', function () {
if (inquiryUI && !inquiryUI.discardForm(editForm)) return;
editForm.reset(); editForm.hidden = true; detail.classList.remove('is-editing');
if (inquiryUI) inquiryUI.markClean(editForm);
});
const editCancel = qs('#editCancel');
if (editCancel && editForm) editCancel.addEventListener('click', function () { editForm.hidden = true; detail.classList.remove('is-editing'); });
if (editForm) editForm.addEventListener('submit', async function (event) {
event.preventDefault();
editError.hidden = true;
editError.style.display = 'none';
const title = qs('#editTitle').value.trim();
const content = qs('#editContent').value.trim();
const category = qs('#editCategory').value;
const title = find('#editTitle').value.trim();
const content = find('#editContent').value.trim();
const category = find('#editCategory').value;
if (!title || !content || !category) {
editError.textContent = '구분, 제목, 내용을 모두 입력해주세요.';
editError.hidden = false;
editError.style.display = 'block';
return;
}
const saveButton = qs('button[type="submit"]', editForm);
const saveButton = find('button[type="submit"]', editForm);
saveButton.disabled = true;
saveButton.textContent = '저장 중...';
try {
await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title, contents: content, Category: CATEGORY_CODES[category], is_secret: qs('#editSecret').checked ? 'true' : 'false' })
body: JSON.stringify({ title: title, contents: content, Category: CATEGORY_CODES[category], is_secret: find('#editSecret').checked ? 'true' : 'false' })
});
post.category = category;
post.title = title;
post.content = content;
post.secret = qs('#editSecret').checked;
post.secret = find('#editSecret').checked;
updateStoredPost(post);
qs('[data-detail-category]').textContent = post.category;
qs('[data-detail-title]').textContent = post.title;
qs('[data-detail-content]').textContent = post.content;
qs('[data-detail-secret]').hidden = !post.secret;
if (inquiryUI) { inquiryUI.changed(post); inquiryUI.markClean(editForm); }
find('[data-detail-category]').textContent = post.category;
find('[data-detail-title]').textContent = post.title;
find('[data-detail-content]').textContent = post.content;
find('[data-detail-secret]').hidden = !post.secret;
editForm.hidden = true;
detail.classList.remove('is-editing');
showToast('문의가 수정되었습니다.');
@@ -850,7 +885,8 @@
try {
await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), { method: 'DELETE' });
removeStoredPost(post.id);
window.location.href = 'index.html';
if (inquiryUI) inquiryUI.deleted(post.id);
else window.location.href = 'index.html';
} catch (error) {
deleteButton.disabled = false;
showToast(error.message || '문의 삭제에 실패했습니다.');
@@ -896,15 +932,34 @@
}
let editingCommentId = null;
const objectUrls = new Set();
const previewUrls = new Set();
function imageObjectUrl(file, preview) {
const url = URL.createObjectURL(file);
objectUrls.add(url);
if (preview) previewUrls.add(url);
return url;
}
function clearImagePreview() {
previewUrls.forEach(function (url) { URL.revokeObjectURL(url); objectUrls.delete(url); });
previewUrls.clear();
}
if (inquiryUI) inquiryUI.onCleanup(root, function () { objectUrls.forEach(function (url) { URL.revokeObjectURL(url); }); });
function isCommentOwner(comment, user) {
return matchesAuthorId(comment.authorId, user);
}
function renderComments(comments) {
comments = (comments || []).map(normalizeComment).filter(function (comment) { return comment.content; });
qs('#commentCount').textContent = comments.length;
const usedUrls = new Set(comments.flatMap(function (comment) {
return comment.attachments.flatMap(function (attachment) { return [attachment.url, attachment.thumbnailUrl]; });
}));
objectUrls.forEach(function (url) {
if (!usedUrls.has(url) && !previewUrls.has(url)) { URL.revokeObjectURL(url); objectUrls.delete(url); }
});
find('#commentCount').textContent = comments.length;
const user = detailUser;
qs('#comments').innerHTML = comments.length ? comments.map(function (comment) {
find('#comments').innerHTML = comments.length ? comments.map(function (comment) {
const owner = Boolean(comment.id) && isCommentOwner(comment, user);
const editing = Boolean(editingCommentId) && editingCommentId === comment.id && owner;
const actions = owner ? '<div class="comment-actions"><button class="button button-outline" type="button" data-comment-edit="' + escapeHtml(comment.id) + '">수정</button><button class="button button-danger" type="button" data-comment-delete="' + escapeHtml(comment.id) + '">삭제</button></div>' : '';
@@ -923,7 +978,7 @@
}).catch(function (error) {
console.warn('[QA] comments load failed:', error.message);
});
qs('#comments').addEventListener('click', async function (event) {
find('#comments').addEventListener('click', async function (event) {
const editButton = event.target.closest('[data-comment-edit]');
const cancelButton = event.target.closest('[data-comment-cancel]');
const saveButton = event.target.closest('[data-comment-save]');
@@ -931,15 +986,19 @@
if (editButton) {
editingCommentId = editButton.getAttribute('data-comment-edit');
comments = renderComments(comments);
const input = qs('[data-comment-edit-input]');
const input = find('[data-comment-edit-input]');
if (input) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }
return;
}
if (cancelButton) { editingCommentId = null; comments = renderComments(comments); return; }
if (cancelButton) {
const input = find('[data-comment-edit-input]');
if (inquiryUI && input && !inquiryUI.discardEditor(input)) return;
editingCommentId = null; comments = renderComments(comments); return;
}
if (saveButton) {
const commentId = saveButton.getAttribute('data-comment-save');
const comment = comments.find(function (item) { return item.id === commentId; });
const input = qs('[data-comment-edit-input]');
const input = find('[data-comment-edit-input]');
const content = input ? input.value.trim() : '';
if (!content) { showToast('댓글 내용을 입력해주세요.'); return; }
saveButton.disabled = true;
@@ -965,13 +1024,13 @@
} catch (error) { showToast(error.message || '댓글 삭제에 실패했습니다.'); deleteButton.disabled = false; }
}
});
const commentForm = qs('#commentForm');
const commentForm = find('#commentForm');
if (commentForm) commentForm.addEventListener('submit', async function (event) {
event.preventDefault();
const contentInput = qs('#commentContent');
const errorBox = qs('#commentError');
const submitButton = qs('#commentSubmit');
const imageInput = qs('#commentImages');
const contentInput = find('#commentContent');
const errorBox = find('#commentError');
const submitButton = find('#commentSubmit');
const imageInput = find('#commentImages');
const files = imageInput ? Array.prototype.slice.call(imageInput.files || []) : [];
const content = contentInput.value.trim();
errorBox.hidden = true;
@@ -988,7 +1047,7 @@
files.forEach(function (file) { payload.append('attachments', file, file.name); });
}
const result = await postToApi(commentsPath, payload);
const localAttachments = files.map(function (file) { const url = URL.createObjectURL(file); return { name: file.name, url: url, thumbnailUrl: url }; });
const localAttachments = files.map(function (file) { const url = imageObjectUrl(file); return { name: file.name, url: url, thumbnailUrl: url }; });
const savedComment = normalizeComment(Object.assign({
content: content,
author: (getAuthUser() || {}).name || '작성자',
@@ -1002,8 +1061,10 @@
renderComments(comments);
contentInput.value = '';
if (imageInput) imageInput.value = '';
const imagePreview = qs('#commentImagePreview');
const imagePreview = find('#commentImagePreview');
if (imagePreview) imagePreview.innerHTML = '';
clearImagePreview();
if (inquiryUI) inquiryUI.markClean(commentForm);
} catch (error) {
errorBox.textContent = error.message || '댓글 등록에 실패했습니다.';
errorBox.hidden = false;
@@ -1012,21 +1073,181 @@
submitButton.textContent = '댓글 등록';
}
});
const commentImages = qs('#commentImages');
const commentImagePreview = qs('#commentImagePreview');
const commentImageLabel = qs('label[for="commentImages"]');
const commentImages = find('#commentImages');
const commentImagePreview = find('#commentImagePreview');
const commentImageLabel = find('label[for="commentImages"]');
if (commentImageLabel) commentImageLabel.textContent = '댓글 이미지 첨부';
if (commentImages && commentImagePreview) commentImages.addEventListener('change', function () {
clearImagePreview();
const files = Array.prototype.slice.call(commentImages.files || []);
commentImagePreview.innerHTML = files.filter(function (file) { return /^image\//i.test(file.type); }).map(function (file) {
const url = URL.createObjectURL(file);
const url = imageObjectUrl(file, true);
return '<div class="comment-image-preview-item"><img src="' + escapeHtml(url) + '" alt="' + escapeHtml(file.name) + '"><span>' + escapeHtml(file.name) + '</span></div>';
}).join('');
});
refreshRemoteStatus(post).then(function (changed) {
if (changed) renderDetailStatus();
if (changed && (!inquiryUI || inquiryUI.isCurrent(root))) {
renderDetailStatus();
if (inquiryUI) inquiryUI.changed(post);
}
});
}
document.addEventListener('DOMContentLoaded', function () { normalizePageHeading(); bindGlobal(); initList(); initWrite(); initDetail(); });
function initInquiryUI(list) {
const dialogs = { write: qs('#composeModal'), detail: qs('#detailModal') };
if (!dialogs.write || !dialogs.detail) return;
const baselines = new WeakMap();
let active = null;
let restoringHistory = false;
let depth = 0;
function snapshot(form) {
return JSON.stringify(Array.from(form.elements).filter(function (input) {
return !input.readOnly && !input.disabled && !['submit', 'button'].includes(input.type);
}).map(function (input) {
return [input.id, input.type === 'file' ? Array.from(input.files).map(function (file) { return [file.name, file.size, file.lastModified]; }) : input.type === 'checkbox' ? input.checked : input.value];
}));
}
function dirty(form) { return !form.hidden && snapshot(form) !== baselines.get(form); }
function dirtyEditor(input) { return input.value !== input.defaultValue; }
function busy() { return active && Boolean(active.root.querySelector('button:disabled')); }
function hasChanges() {
return active && (qsa('form', active.root).some(dirty) || qsa('[data-comment-edit-input]', active.root).some(dirtyEditor));
}
function confirmDiscard() { return window.confirm('작성 중인 내용이 있습니다. 닫으면 입력한 내용이 사라집니다. 닫으시겠습니까?'); }
function canLeave(force) {
if (force || !active) return true;
if (busy()) { showToast('처리가 끝난 후 닫을 수 있습니다.'); return false; }
return !hasChanges() || confirmDiscard();
}
function clear(restoreFocus) {
if (!active) return;
const previous = active;
active = null;
previous.dialog.close();
previous.cleanups.forEach(function (cleanup) { cleanup(); });
const toast = qs('#toast');
if (toast) document.body.append(toast);
previous.dialog.querySelector('[data-modal-content]').replaceChildren();
document.documentElement.classList.remove('inquiry-modal-open');
if (restoreFocus) {
window.scrollTo({ top: previous.scroll, behavior: 'instant' });
const opener = previous.opener && previous.opener.isConnected ? previous.opener : qs('#writeButton') || qs('#boardHeading');
if (opener) opener.focus({ preventScroll: true });
}
}
function routeURL(view, id) {
const url = new URL(window.location.href);
url.searchParams.delete('view'); url.searchParams.delete('id');
if (view) url.searchParams.set('view', view);
if (view === 'detail' && id) url.searchParams.set('id', id);
return url;
}
async function mount(view, id, opener) {
const scroll = active ? active.scroll : window.scrollY;
const previousOpener = active && active.opener;
clear(false);
const dialog = dialogs[view];
if (!dialog) return;
const root = document.createElement('div');
root.setAttribute('aria-busy', view === 'detail' ? 'true' : 'false');
root.append(qs(view === 'write' ? '#writeTemplate' : '#detailTemplate').content.cloneNode(true));
dialog.querySelector('[data-modal-content]').replaceChildren(root);
const toast = qs('#toast');
if (toast) dialog.append(toast);
active = { view: view, id: id, root: root, dialog: dialog, opener: opener || previousOpener, scroll: scroll, cleanups: [] };
qsa('form', root).forEach(function (form) { baselines.set(form, snapshot(form)); });
document.documentElement.classList.add('inquiry-modal-open');
dialog.showModal();
const heading = root.querySelector('h2');
if (heading) heading.focus({ preventScroll: true });
try {
if (view === 'write') initWrite(root);
else if (id) await initDetail(root, id);
else {
root.querySelector('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
root.querySelector('.comment-box').hidden = true;
}
} catch (error) {
if (active && active.root === root) showToast('화면을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.');
console.warn('[QA] dialog initialization failed:', error.message);
} finally {
root.setAttribute('aria-busy', 'false');
}
}
function open(view, id, opener, options) {
options = options || {};
if (!canLeave(options.force)) return;
const url = routeURL(view, id);
if (active || options.replace) history.replaceState({ qaModal: true }, '', url);
else history.pushState({ qaModal: true }, '', url);
depth = 1;
mount(view, id, opener);
}
function close(force) {
if (!canLeave(force)) return;
clear(true);
if (depth === 1) history.back();
else history.replaceState({ qaModal: false }, '', routeURL());
}
inquiryUI = {
open: open,
isCurrent: function (root) { return Boolean(active && active.root === root); },
markClean: function (form) { baselines.set(form, snapshot(form)); },
discardForm: function (form) { return !dirty(form) || confirmDiscard(); },
discardEditor: function (input) { return !dirtyEditor(input) || confirmDiscard(); },
onCleanup: function (root, callback) { if (active && active.root === root) active.cleanups.push(callback); else callback(); },
changed: function (post) { if (list) list.upsert(post); },
saved: function (post) { if (list) list.upsert(post); open('detail', post.id, null, { force: true, replace: true }); },
deleted: function (id) { if (list) list.remove(id); close(true); }
};
qsa('[data-open-write]').forEach(function (button) {
button.addEventListener('click', function (event) { event.preventDefault(); open('write', null, button); });
});
Object.values(dialogs).forEach(function (dialog) {
dialog.addEventListener('cancel', function (event) { event.preventDefault(); close(false); });
dialog.addEventListener('click', function (event) {
if (event.target.closest('[data-modal-close]')) { close(false); return; }
const rect = dialog.getBoundingClientRect();
if (event.target === dialog && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) close(false);
});
});
window.addEventListener('beforeunload', function (event) {
if (hasChanges() || busy()) { event.preventDefault(); event.returnValue = ''; }
});
window.addEventListener('popstate', function (event) {
if (restoringHistory) { restoringHistory = false; return; }
const nextDepth = event.state && event.state.qaModal ? 1 : 0;
if (!canLeave(false)) {
restoringHistory = true;
history.go(depth > nextDepth ? 1 : -1);
return;
}
depth = nextDepth;
const url = new URL(window.location.href);
const view = url.searchParams.get('view');
if (view === 'write' || view === 'detail') mount(view, url.searchParams.get('id'));
else clear(true);
});
const initial = new URL(window.location.href);
const view = initial.searchParams.get('view');
history.replaceState({ qaModal: false }, '', routeURL());
if (view === 'write' || view === 'detail') open(view, initial.searchParams.get('id'));
}
document.addEventListener('DOMContentLoaded', function () {
normalizePageHeading(); bindGlobal();
const list = initList();
if (qs('#composeModal')) initInquiryUI(list);
else { initWrite(); initDetail(); }
});
})();
+1 -8
View File
@@ -1,8 +1 @@
<!doctype html>
<html lang="ko">
<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=20261030-owner-1"></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 id="completionNotice" class="completion-notice" hidden><div><strong>처리 완료 안내</strong><p>담당자가 처리를 완료했습니다. 내용을 확인하셨다면 완료 확인을 눌러주세요.</p></div><button class="button button-primary" id="completionButton" type="button">완료 확인</button></div><div id="completionDoneNotice" class="completion-done-notice" hidden>완료 처리 된 요청 건입니다.</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-primary 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=20261030-owner-1"></script>
</body></html>
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>S/W 문의 | 바론컨설턴트</title><script src="../assets/inquiry/entry.js?v=20261102-ui-1" defer></script></head><body><p>문의 화면으로 이동합니다. <a href="./">문의 목록</a></p></body></html>
+207 -28
View File
@@ -1,29 +1,208 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="EG-BIM Q&A 문의 게시판">
<title>EG-BIM Q&A | 바론컨설턴트</title>
<link rel="stylesheet" href="../assets/styles.css?v=20261030-owner-1">
</head>
<body>
<main class="qa-list-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">
<div class="qa-list-heading"><h1>문의하기(Q&amp;A)</h1></div>
<form class="filter-panel" id="searchForm">
<div class="filter-row"><div class="chip-group"><label class="chip"><input type="radio" name="category" value="전체" checked>전체</label><label class="chip"><input type="radio" name="category" value="일반문의">일반</label><label class="chip"><input type="radio" name="category" value="개선문의">개선</label><label class="chip"><input type="radio" name="category" value="오류문의">오류</label></div><label class="check"><input type="checkbox" id="onlyMine">내가 작성한 글</label><span class="filter-spacer"></span><div class="search-form"><input id="query" type="search" maxlength="120" placeholder="검색어 입력 (오타·초성 지원)" aria-label="제목·내용·회사·부서·작성자 검색 (오타·초성 지원)"><button class="button search-button" type="submit"><span aria-hidden="true">⌕</span> 검색</button></div></div>
</form>
<p id="searchStatus" class="help-text" role="status" aria-live="polite" hidden></p>
<div class="table-wrap"><table class="qa-table"><caption class="sr-only">EG-BIM 문의 목록</caption><colgroup><col style="width:6%"><col style="width:8%"><col style="width:13%"><col style="width:11%"><col style="width:7%"><col style="width:38%"><col style="width:11%"><col style="width:6%"></colgroup><thead><tr><th>번호</th><th>구분</th><th>회사</th><th>부서</th><th>작성자</th><th>제목</th><th>등록일</th><th>상태</th></tr></thead><tbody id="qaRows"></tbody></table><div class="table-empty" id="tableEmpty">검색 조건에 맞는 문의가 없습니다.</div></div>
<div class="list-footer"><div class="pagination" id="pagination" aria-label="페이지 이동"></div><a class="button button-primary" href="write.html">문의등록</a></div>
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-owner-1"></script>
</body>
</html>
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="EG-BIM 소프트웨어 문의"><title>S/W 문의 | 바론컨설턴트</title><link rel="stylesheet" href="../assets/styles.css?v=20261102-ui-1"><link rel="stylesheet" href="../assets/inquiry/inquiry.css?v=20261102-ui-1"></head><body>
<main class="inquiry-page">
<section class="inquiry-hero" aria-labelledby="inquiry-title">
<div class="inquiry-hero__copy">
<h1 id="inquiry-title">S/W 문의</h1>
<p>소프트웨어 관련 문의하기</p>
</div>
</section>
<div class="inquiry-workspace">
<div class="inquiry-tabs-shell">
<div class="inquiry-tabs" role="tablist" aria-label="문의할 소프트웨어">
<button type="button" role="tab" id="tab-egbim" aria-controls="inquiry-panel" aria-selected="true" data-service="egbim" aria-label="EG-BIM 문의">
<span class="inquiry-tab-description">BIM 모델</span>
<picture class="inquiry-tab-logo"><source media="(max-width: 600px)" srcset="../assets/inquiry/egbim-symbol.svg" width="49" height="25" /><img src="../assets/inquiry/egbim.svg" alt="EG-BIM" width="150" height="26" /></picture>
</button>
<button type="button" role="tab" id="tab-tova" aria-controls="inquiry-panel" aria-selected="false" aria-disabled="true" disabled tabindex="-1" data-service="tova" aria-label="TOVA 문의">
<span class="inquiry-tab-description">통합 교통 수요분석 <span class="inquiry-coming-soon">준비중</span></span>
<span class="inquiry-tab-logo"><img src="../assets/inquiry/tova.svg" alt="TOVA" width="104" height="26" /></span>
</button>
<button type="button" role="tab" id="tab-gaia" aria-controls="inquiry-panel" aria-selected="false" aria-disabled="true" disabled tabindex="-1" data-service="gaia" aria-label="GAIA 문의">
<span class="inquiry-tab-description">지반조사 <span class="inquiry-coming-soon">준비중</span></span>
<span class="inquiry-tab-logo"><img src="../assets/inquiry/gaia.svg" alt="GAIA" width="82" height="33" /></span>
</button>
</div>
</div>
<section class="inquiry-board" id="inquiry-panel" role="tabpanel" aria-labelledby="tab-egbim">
<div class="inquiry-board__heading">
<h2 id="boardHeading" tabindex="-1">문의 내역</h2>
<a class="inquiry-remote" href="https://939.co.kr/saman/" target="_blank" rel="noopener noreferrer" aria-label="원격지원 (새 창)">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="13" rx="2" /><path d="M8 21h8M12 17v4m-1-13 4 3-4 3" /></svg>
<span>원격지원</span>
</a>
</div>
<form class="inquiry-controls" role="search" id="searchForm">
<div class="inquiry-filters">
<fieldset class="inquiry-categories">
<legend class="inquiry-sr-only">문의 구분</legend>
<label><input type="radio" name="category" value="전체" checked /><span>전체</span></label>
<label><input type="radio" name="category" value="일반문의" /><span>일반</span></label>
<label><input type="radio" name="category" value="개선문의" /><span>개선</span></label>
<label><input type="radio" name="category" value="오류문의" /><span>오류</span></label>
</fieldset>
<label class="inquiry-mine"><input type="checkbox" name="mine" id="onlyMine" /> 내가 작성한 글</label>
</div>
<div class="inquiry-search">
<label class="inquiry-sr-only" for="query">제목 및 내용 검색</label>
<input type="search" id="query" name="keyword" placeholder="제목 또는 내용 검색" autocomplete="off" maxlength="120" aria-label="제목·내용·회사·부서·작성자 검색 (오타·초성 지원)" />
<button type="submit" class="inquiry-search-button" aria-label="검색"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="10" cy="10" r="6.5" /><path d="m15 15 6 6" /></svg></button>
</div>
</form>
<p class="inquiry-scroll-hint">좌우로 밀어 문의 목록을 확인하세요.</p>
<div class="inquiry-table-scroll" tabindex="0" role="region" aria-label="문의 목록. 작은 화면에서는 좌우로 스크롤할 수 있습니다.">
<table class="inquiry-table">
<caption class="inquiry-sr-only"><span data-service-name>EG-BIM</span> 문의 목록</caption>
<colgroup><col class="col-number" /><col class="col-category" /><col class="col-company" /><col class="col-team" /><col class="col-author" /><col /><col class="col-date" /><col class="col-status" /></colgroup>
<thead><tr><th scope="col">번호</th><th scope="col">구분</th><th scope="col">회사</th><th scope="col">부서</th><th scope="col">작성자</th><th scope="col">제목</th><th scope="col">등록일</th><th scope="col">상태</th></tr></thead>
<tbody id="qaRows"></tbody>
</table><div class="table-empty" id="tableEmpty">문의 목록을 불러오는 중입니다.</div>
</div>
<p class="help-text" id="searchStatus" role="status" aria-live="polite" hidden></p>
<nav class="inquiry-pagination" aria-label="문의 목록 페이지" id="pagination"></nav>
<div class="inquiry-board__bottom">
<span id="resultCount" class="help-text" aria-live="polite"></span>
<button type="button" class="inquiry-button inquiry-write" id="writeButton" data-open-write><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m4 15 12-12 5 5L9 20l-6 1 1-6Zm10-10 5 5M4 15l5 5" /></svg>문의등록</button>
</div>
</section>
</div>
</main>
<dialog class="inquiry-dialog" id="composeModal" aria-labelledby="composeHeading"><div data-modal-content></div></dialog>
<dialog class="inquiry-dialog" id="detailModal" aria-labelledby="detailHeading"><div data-modal-content></div></dialog>
<template id="writeTemplate">
<div class="inquiry-dialog__heading"><h2 id="composeHeading" tabindex="-1">문의등록</h2><button type="button" class="inquiry-close" data-modal-close aria-label="문의등록 닫기">×</button></div>
<p class="help-text" data-auth-state>로그인 상태를 확인하고 있습니다.</p><div class="form-error" id="formError" role="alert"></div>
<form id="qaForm" novalidate><div id="writeFields">
<div class="inquiry-form-grid">
<label>서비스<select disabled aria-label="서비스"><option>EG-BIM</option></select></label>
<label for="category">문의 구분<select id="category" required><option value="">선택해주세요</option><option>일반문의</option><option>개선문의</option><option>오류문의</option></select></label>
<label>회사<input id="writeCompany" type="text" readonly placeholder="확인 중"></label>
<label>부서<input id="writeDepartment" type="text" readonly placeholder="확인 중"></label>
<label>작성자<input id="writeAuthor" type="text" readonly placeholder="확인 중"></label>
<label>이메일<input id="writeEmail" type="email" readonly placeholder="확인 중"></label>
</div>
<label class="inquiry-form-wide" for="title">제목<input id="title" type="text" maxlength="100" placeholder="문의 제목을 입력하세요" required></label>
<label class="inquiry-secret"><input id="secret" type="checkbox">비밀글로 등록</label>
<label class="inquiry-form-wide" for="content">내용<textarea id="content" rows="6" placeholder="문의 내용을 입력하세요" required></textarea></label>
<label class="inquiry-form-wide" for="attachment">첨부파일<input id="attachment" type="file" multiple></label>
<p class="help-text">파일당 최대 30MB. 개인정보, 비밀번호, 인증번호 등 민감한 정보는 입력하지 마세요.</p>
</div><div class="inquiry-dialog__actions"><button type="button" class="inquiry-button inquiry-button--light" data-modal-close>취소</button><button type="submit" class="inquiry-button" id="submitButton">등록</button></div></form>
</template>
<template id="detailTemplate">
<section id="detail">
<div class="inquiry-dialog__heading">
<h2 id="detailHeading" data-detail-title tabindex="-1">문의 내용을 불러오는 중입니다.</h2>
<button type="button" class="inquiry-close" data-modal-close aria-label="문의 내용 닫기">×</button>
</div>
<article class="detail-card">
<div class="detail-head">
<div class="inquiry-detail__summary">
<span class="category" data-detail-category>
</span>
<span class="status review" data-detail-status>
</span>
</div>
<div class="detail-meta">
<span data-detail-date>
</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 id="completionNotice" class="completion-notice" hidden>
<div>
<strong>처리 완료 안내</strong>
<p>담당자가 처리를 완료했습니다. 내용을 확인하셨다면 완료 확인을 눌러주세요.</p>
</div>
<button class="button button-primary" id="completionButton" type="button">완료 확인</button>
</div>
<div id="completionDoneNotice" class="completion-done-notice" hidden>완료 처리 된 요청 건입니다.</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>
</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-primary 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="inquiry-dialog__actions">
<button class="inquiry-button" type="button" data-modal-close>목록으로</button>
</div>
</section>
</template>
<div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261102-ui-1"></script>
</body></html>
+1 -8
View File
@@ -1,8 +1 @@
<!doctype html>
<html lang="ko">
<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=20261030-owner-1"></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"><div class="section-heading"><div><h2>문의 등록</h2><p>로그인한 사용자 정보로 문의가 등록됩니다.</p></div></div><div class="form-notice" data-auth-state>로그인 상태를 확인하고 있습니다.</div><div class="form-error" id="formError" role="alert"></div><form id="qaForm" novalidate><div class="form-card" id="writeFields"><div class="form-row"><label class="form-label" for="category">구분 <span class="required">*</span></label><div class="form-control"><select class="select" id="category" required><option value="">선택해주세요</option><option>오류문의</option><option>개선문의</option><option>일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="title">제목 <span class="required">*</span></label><div class="form-control"><input class="input" id="title" type="text" maxlength="100" placeholder="문의 제목을 입력해주세요" required></div></div><div class="form-row"><span class="form-label">공개 설정</span><div class="form-control"><label class="check"><input id="secret" type="checkbox"> 비밀글로 등록</label></div></div><div class="form-row"><label class="form-label" for="content">내용 <span class="required">*</span></label><div class="form-control column"><textarea class="textarea" id="content" placeholder="문의 내용을 작성해주세요" required></textarea><span class="help-text">개인정보, 비밀번호, 인증번호 등 민감한 정보는 입력하지 마세요.</span></div></div><div class="form-row"><label class="form-label" for="attachment">첨부파일</label><div class="form-control column"><input class="file-input" id="attachment" type="file" multiple><span class="help-text">파일은 문의와 함께 안전하게 업로드됩니다. 파일당 최대 30MB</span></div></div></div><div class="form-footer"><a class="button button-outline" href="index.html">취소</a><button class="button button-primary" id="submitButton" type="submit">문의 등록</button></div></form></section>
</main><div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-owner-1"></script>
</body></html>
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>S/W 문의 | 바론컨설턴트</title><script src="../assets/inquiry/entry.js?v=20261102-ui-1" defer></script></head><body><p>문의 화면으로 이동합니다. <a href="./">문의 목록</a></p></body></html>
+2 -1
View File
@@ -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"
},
+1
View File
@@ -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
+185
View File
@@ -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();}
+22
View File
@@ -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]);
});
}
+8 -1
View File
@@ -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'));