/* 요구 280-2 — 좌측 아래 [?] [<] [>] 헬퍼. `ui_template_help.ts` · `ui_template_workflow_layout.ts` 를 * 트랜스파일해 가짜 DOM 위에서 ① 주소마다 줄 차례 ② [<] [>] = 이웃 단계 단추 누름(꺼진 단계 = 알림만)을 * 돌려 JSON 한 벌로 냄. 확장자 `.cjs` 까닭은 `helper_design_view_parity.cjs` 머리와 같음. */ const path = require("path"); const { makeLoader } = require("./helper_ts_stub_loader.cjs"); const ROOT = path.join(__dirname, "..", ".."); class FakeNode { constructor(tag) { this.tagName = tag; this.children = []; this.parent = null; this.dataset = {}; this.listeners = {}; this.disabled = false; this.isConnected = true; this.title = ""; this._text = ""; this._classes = new Set(); const self = this; this.classList = { add: (...n) => n.forEach((x) => self._classes.add(x)), remove: (...n) => n.forEach((x) => self._classes.delete(x)), toggle: (n, f) => (f === undefined ? !self._classes.has(n) : !!f) ? self._classes.add(n) : self._classes.delete(n), contains: (n) => self._classes.has(n), }; } set className(v) { this._classes = new Set(String(v).split(/\s+/).filter(Boolean)); } get className() { return [...this._classes].join(" "); } set textContent(v) { this._text = String(v); this.children = []; } get textContent() { return this._text + this.children.map((c) => c.textContent).join(""); } _adopt(kid) { if (kid.parent) kid.parent.children = kid.parent.children.filter((c) => c !== kid); kid.parent = this; return kid; } append(...kids) { kids.forEach((k) => this.children.push(this._adopt(k))); } prepend(...kids) { this.children.unshift(...kids.map((k) => this._adopt(k))); } setAttribute() {} addEventListener(type, handler) { (this.listeners[type] ||= []).push(handler); } dispatchEvent(event) { for (let node = this; node; node = event.bubbles ? node.parent : null) (node.listeners[event.type] || []).forEach((h) => h(event)); } click() { if (!this.disabled) this.dispatchEvent({ type: "click", bubbles: true, target: this }); } _matches(selector) { if (selector === "[data-help-row]") return "helpRow" in this.dataset; const [, cls, not] = /^\.([\w-]+)(?::not\(\.([\w-]+)\))?$/.exec(selector); return this._classes.has(cls) && !(not && this._classes.has(not)); } querySelector(selector) { return this.querySelectorAll(selector)[0] ?? null; } querySelectorAll(selector) { const out = []; const walk = (n) => n.children.forEach((k) => (k._matches(selector) && out.push(k), walk(k))); walk(this); return out; } } class CustomEvent { constructor(type, init) { Object.assign(this, { type, bubbles: !!init?.bubbles, detail: init?.detail }); } } globalThis.document = { createElement: (tag) => new FakeNode(tag) }; globalThis.location = { hash: "" }; // 주소 바뀜 듣개 — B05 ↔ B06 은 페이지를 다시 안 세우고 [<] [>] 켬 · 끔만 다시 맞춤(496-1) const hashListeners = new Set(); globalThis.window = { addEventListener: (type, fn) => type === "hashchange" && hashListeners.add(fn), removeEventListener: (type, fn) => type === "hashchange" && hashListeners.delete(fn), }; const goHash = (route) => { location.hash = `#/${route}`; [...hashListeners].forEach((fn) => fn()); }; globalThis.CustomEvent = CustomEvent; globalThis.HTMLButtonElement = FakeNode; // ── 바깥 모듈 대역 ───────────────────────────────────────────────────────────── const ROUTES = { B01_ACCOUNT: "b01-account", B03_FILE_INPUT: "b03-file-input", B04_PREPROCESS: "b04-preprocess", B05_PROFILE: "b05-profile", B06_SECTION: "b06-section", B07_DESIGN_DETAIL: "b07-design-detail", B08_QUANTITY: "b08-quantity", B09_ESTIMATION: "b09-estimation", }; const toasts = []; let role = "SYSTEM_ADMIN"; let help; const STUBS = { "@config/config_frontend": { ROUTES, API_BASE_URL: "" }, "./ui_template_locale": { t: (key) => key }, "./ui_template_modal": { openModal: () => undefined }, "./ui_template_elements": { showToast: (message) => toasts.push(message), el: (tag) => new FakeNode(tag), createButton: ({ label, onClick }) => { const button = new FakeNode("button"); button.className = "ui-btn"; button.textContent = label; button.addEventListener("click", () => onClick && onClick()); return button; }, }, // 진짜 겹판은 `splitSidebarActions` → `placeHelpButton` — 같은 차례로 흉내 "./ui_template_overlay": { createWorkflowOverlays: ({ optionsContent, progressContent }) => { const root = new FakeNode("div"); root.append(optionsContent, progressContent); const actions = optionsContent._classes.has("ui-sidebar-actions") ? optionsContent : optionsContent.querySelector(".ui-sidebar-actions"); help.placeHelpButton(actions); return { root, setTitleOpen() {}, setProgressOpen() {} }; }, }, "../A00_Common/router": { navigateTo: () => undefined }, "../A00_Common/app_shell": { setDrawerSteps: () => undefined }, "../B01_Dashboard/B01_Dashboard_Api_Fetch": { fetchDashboardMe: async () => ({ role }) }, }; globalThis.__DEV = false; const { loadTs } = makeLoader({ root: ROOT, cache: false, rewrite: (js) => js.replace(/import\.meta\.env\.DEV/g, "globalThis.__DEV"), requireFor: () => (request) => { if (request.endsWith(".css")) return {}; if (STUBS[request]) return STUBS[request]; throw new Error(`대역 없음: ${request}`); }, }); help = loadTs(path.join(ROOT, "ui_template", "ui_template_help.ts")); const layout = loadTs(path.join(ROOT, "ui_template", "ui_template_workflow_layout.ts")); // ① 주소마다 줄 차례 — [data-help-row] 있는 줄 · 없는 줄(B08 · B09 꼴) const rowOf = (route, withHelpRow) => { location.hash = `#/${route}`; const actions = new FakeNode("div"); actions.className = "ui-sidebar-actions"; const row = new FakeNode("div"); if (withHelpRow) { row.dataset.helpRow = ""; actions.append(row); } const save = new FakeNode("button"); save.className = "ui-btn"; save.textContent = "저장"; (withHelpRow ? row : actions).append(save); help.placeHelpButton(actions); help.placeHelpButton(actions); // 두 번 불러도 한 벌 const kids = (withHelpRow ? row : actions).children; return { labels: kids.map((k) => k.textContent), disabled: kids.map((k) => k.disabled) }; }; const ALL = [ "b03-file-input", "b04-preprocess", "b05-profile", "b06-section", "b07-design-detail", "b08-quantity", "b09-estimation", "b09-print", "m01-master-data", "m02-master-template", ]; const rows = {}; for (const route of ALL) rows[route] = { helpRow: rowOf(route, true), plain: rowOf(route, false) }; // ② [<] [>] = 이웃 단계 누름 const STEP_ROUTES = ALL.slice(0, 7); function page(route, { stages, dev = false, module = layout, current } = {}) { globalThis.__DEV = dev; goHash(route); const calls = []; const handle = module.createWorkflowLayout({ title: route, steps: STEP_ROUTES, activeStep: STEP_ROUTES.indexOf(route), mainContent: new FakeNode("div"), routes: STEP_ROUTES, stages, currentStage: current ?? STEP_ROUTES.indexOf(route), onStepClick: (index, to) => calls.push(`${index}:${to}`), }); const navButton = (step) => handle.root .querySelectorAll(".ui-step-nav-btn") .find((b) => b.dataset.stepNav === String(step)); const press = (step) => { toasts.length = 0; navButton(step).click(); return { calls: calls.splice(0), toasts: [...toasts] }; }; const stepButton = (index) => handle.root.querySelectorAll(".ui-workflow-layout__step:not(.ui-workflow-layout__step--home)")[ index ]; return { handle, press, navButton, stepButton }; } (async () => { const nav = {}; const b05 = page("b05-profile"); nav.b05Prev = b05.press(-1); nav.b05Next = b05.press(1); nav.prevDisabled = { b05: b05.navButton(-1).disabled }; // B05 → B06 = 같은 페이지 — 켜진 칸만 갈아 끼움(design_page 의 setActiveStep) · [<] 는 주소 따라 켬 goHash("b06-section"); b05.handle.setActiveStep(3); nav.prevDisabled.b06 = b05.navButton(-1).disabled; nav.b06Prev = b05.press(-1); nav.b06Next = b05.press(1); goHash("b05-profile"); b05.handle.setActiveStep(2); nav.prevDisabled.back = b05.navButton(-1).disabled; const b09 = page("b09-estimation"); nav.b09Prev = b09.press(-1); nav.b09Next = b09.press(1); // 운영 — 막힌 단계(NOT_STARTED) = 누르지 않고 그 단추 title 알림 const stages = STEP_ROUTES.map((_, i) => ({ stage_no: i, stage_key: String(i), state: i >= 4 ? "NOT_STARTED" : "COMPLETE", })); const blocked = page("b06-section", { stages }); nav.blockedNext = blocked.press(1); // [저장] 뒤 서버 단계로 다시 칠함 — 시작함(IN_PROGRESS)이면 켬 blocked.handle.setStages( stages.map((s) => ({ ...s, state: s.stage_no === 4 ? "IN_PROGRESS" : s.state })), ); nav.repaintNext = blocked.press(1); nav.repaintClass = blocked.stepButton(4).className; // STALE = 색만 · 막지 않음(요구 300 B1) const stale = page("b06-section", { stages: stages.map((s) => ({ ...s, state: s.stage_no >= 4 ? "STALE" : s.state })), }); nav.staleNext = stale.press(1); nav.staleClass = stale.stepButton(4).className; // B05 · B06 화면 = 2 · 3 · 4 늘 켬(운영 · 체인 직후 NOT_STARTED 라도) · B08 은 막힘 const chain = page("b06-section", { stages, current: [2, 3, 4] }); nav.listNext = chain.press(1); nav.listB08 = chain.stepButton(5).disabled; // 일반 사용자 — B05 [<](B04) = 관리자 전용 알림(역할은 모듈이 한 번 물어 기억 → 새로 읽음) role = "USER"; const fresh = loadTs(path.join(ROOT, "ui_template", "ui_template_workflow_layout.ts")); const user = page("b06-section", { dev: true, module: fresh }); await new Promise((r) => setTimeout(r, 0)); // B06 [<] = B05(같은 페이지) · B04 단계 단추 = 관리자 전용 잠금(다시 칠해도 남음) nav.userPrev = user.press(-1); const lockOf = (b) => ({ disabled: b.disabled, title: b.title }); nav.userB04 = lockOf(user.stepButton(1)); user.handle.setStages(stages); nav.userB04Repaint = lockOf(user.stepButton(1)); process.stdout.write(JSON.stringify({ rows, nav })); })();