From d593075ea558d9fb27c2d90fa6a7bc694ca21316 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 22:05:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(481-1=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99):=20B05~B09=20=EC=A2=8C=EC=B8=A1=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=20[=3F]=20[<]=20[>]=20=E2=80=94=20=EC=9D=B4=EC=9B=83?= =?UTF-8?q?=20=EC=A7=84=ED=96=89=20=EB=8B=A8=EA=B3=84=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=20=EB=88=84=EB=A6=84=20=C2=B7=20B05=20=C2=B7=20B06=20[?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=EB=B3=B4=EA=B8=B0]=20=C2=B7=20[=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EB=B3=B4=EA=B8=B0]=20=EC=A7=80=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui_template_help: [?] 뒤 [<] [>] · 다섯 주소 목록만(b09-print · B03 · B04 · M01 · M02 뺌) · B09 [>] 흐림 · ui-step-nav 이벤트 - ui_template_workflow_layout: ui-step-nav 듣개 — 켜진 칸 ±1 단계 단추 click(onStepClick 한 길) · 꺼진 단계 = 그 단추 title 알림 - CSS: [<] [>] = [?] 같은 정사각 · 늘어남 규칙에서 뺌(overlay · B09 도크 한 줄) - design_dock · design_shared · locale_b2: switchView 단추 · 글 지움 - 시험: test_280_2_step_nav(새) · test_design_view_parity(단추 셋) Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_dock.ts | 27 +- A00_Common/design/design_shared.ts | 8 +- B09_Estimation/B09_Estimation_UI_Page.css | 2 +- resources/tester/helper_280_2_step_nav.cjs | 246 ++++++++++++++++++ .../tester/helper_design_view_parity.cjs | 3 - resources/tester/test_280_2_step_nav.py | 75 ++++++ resources/tester/test_design_view_parity.py | 18 +- ui_template/ui_template_help.css | 6 +- ui_template/ui_template_help.ts | 36 ++- ui_template/ui_template_locale_b2.ts | 2 - ui_template/ui_template_overlay.css | 6 +- ui_template/ui_template_workflow_layout.ts | 9 + 12 files changed, 386 insertions(+), 52 deletions(-) create mode 100644 resources/tester/helper_280_2_step_nav.cjs create mode 100644 resources/tester/test_280_2_step_nav.py diff --git a/A00_Common/design/design_dock.ts b/A00_Common/design/design_dock.ts index 8f29d14e0..9b2c1a00a 100644 --- a/A00_Common/design/design_dock.ts +++ b/A00_Common/design/design_dock.ts @@ -2,9 +2,8 @@ * design_dock.ts * B05 · B06 한 페이지 두 보기의 **좌측 패널 맨 아래 고정 영역**(계획서 24-1 · 24-2b). * - * 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 넷은 두 보기가 **같다**: - * [초기화] [저장] [확정] [보기 전환](종단 보기에서는 「횡단 보기」 · 횡단 보기에서는 - * 「종단 보기」). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은 + * 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 셋은 두 보기가 **같다**: + * [초기화] [저장] [확정](보기 오가기는 공용 [<] [>] · 요구 280-2). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은 * 단추를 만들고 켜고 끄기만 한다(저장 흐름은 `design_save.ts` 몫). * ========================================================================== */ @@ -26,15 +25,13 @@ export interface DesignDockActions { reset: DesignDockAction; save: DesignDockAction; confirm: DesignDockAction; - /** [보기 전환] — 지금은 주소 이동이다(2단계에서 같은 페이지 안 전환으로 바뀜). */ - switchView: DesignDockAction; } export interface DesignDock { root: HTMLElement; - /** 단추 넷의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */ + /** 단추 셋의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */ setActions: (actions: Partial) => void; - /** 보기가 바뀌면 [보기 전환] 글자가 따라 바뀐다. */ + /** 보기가 바뀌면 단추 줄 class 가 따라 바뀐다. */ setView: (view: DesignView) => void; /** 「계산 중」 점 — 묶음 차례가 떠 있는 동안 켬(386-2). */ setBusy: (busy: boolean) => void; @@ -44,11 +41,6 @@ function label(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/** 보기 전환 단추 글자 — 가려는 **쪽** 이름이다. */ -export function switchViewLabel(view: DesignView): string { - return label(view === "profile" ? "Design_Btn_ToCross" : "Design_Btn_ToProfile"); -} - /** * 아래 고정 영역을 만든다. `above` 는 단추 행 위에 놓을 것(구조물 목록) — 목록을 「구조물 배치」 * 본문에 두면 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시). @@ -93,20 +85,13 @@ export function createDesignDock( variant: "filled", onClick: press("confirm"), }); - const switchButton = createButton({ - label: switchViewLabel(view), - variant: "ghost", - onClick: press("switchView"), - }); resetButton.dataset.dockAction = "reset"; saveButton.dataset.dockAction = "save"; confirmButton.dataset.dockAction = "confirm"; - switchButton.dataset.dockAction = "switchView"; const buttons = { reset: resetButton, save: saveButton, confirm: confirmButton, - switchView: switchButton, }; const sync = (): void => { @@ -116,12 +101,10 @@ export function createDesignDock( if (action.title) buttons[name].title = action.title; else buttons[name].removeAttribute("title"); }); - (switchButton.querySelector(".ui-btn__label") ?? switchButton).textContent = - switchViewLabel(current); row.className = current === "profile" ? "b05-route__actions" : "b06-profile__actions"; }; - row.append(resetButton, saveButton, confirmButton, switchButton); + row.append(resetButton, saveButton, confirmButton); root.append(...above, divider, row, busy); sync(); diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 4a7c4fe4d..cef8daf8e 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -11,13 +11,12 @@ * [확정]은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`). * ========================================================================== */ -import { ROUTES } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { splitSidebarActions } from "@ui/ui_template_overlay"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav"; import { readState, writeState } from "../b_page_state"; -import { currentRoute, navigateTo, remountRoute } from "../router"; +import { currentRoute, remountRoute } from "../router"; import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch"; import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Context"; import { createStructuresSection, type StructuresSection } from "../b_structures_section"; @@ -330,7 +329,6 @@ function routeIdOf(shared: DesignPageShared): number | null { /** 좌측 패널 한 벌 — 부품은 두 보기가 `shared.parts` 에 넣고, 칸은 보기가 처음 보일 때 선다. */ export function sharedPanel(shared: DesignPageShared): DesignPanel { if (shared.panel) return shared.panel; - const other = (): DesignView => (shared.view() === "profile" ? "cross" : "profile"); const panel = createDesignPanel( { projectId: shared.projectId, @@ -345,10 +343,6 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel { // 자료가 서기 전에는 잠근다 — 보기가 `designReady` 로 연다. save: { run: () => saveSharedDesign(shared, false), enabled: false }, confirm: { run: () => saveSharedDesign(shared, true), enabled: false }, - // [보기 전환] = 주소만 바꾼다 — 같은 페이지라 라우터가 보기만 바꾼다(다시 안 만듦). - switchView: { - run: () => navigateTo(other() === "profile" ? ROUTES.B05_PROFILE : ROUTES.B06_SECTION), - }, }, }, shared.view(), diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 2a2041307..c0323f839 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -268,7 +268,7 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child { gap: var(--btn-group-gap); } -.b09-dock .b09-dock__row > .ui-btn:not(.ui-help-btn) { +.b09-dock .b09-dock__row > .ui-btn:not(.ui-help-btn, .ui-step-nav-btn) { flex: 1 1 0; min-width: auto; padding-inline: var(--spacing-4); diff --git a/resources/tester/helper_280_2_step_nav.cjs b/resources/tester/helper_280_2_step_nav.cjs new file mode 100644 index 000000000..b3b98a334 --- /dev/null +++ b/resources/tester/helper_280_2_step_nav.cjs @@ -0,0 +1,246 @@ +/* 요구 280-2 — 좌측 아래 [?] [<] [>] 헬퍼. `ui_template_help.ts` · `ui_template_workflow_layout.ts` 를 + * 트랜스파일해 가짜 DOM 위에서 ① 주소마다 줄 차례 ② [<] [>] = 이웃 단계 단추 누름(꺼진 단계 = 알림만)을 + * 돌려 JSON 한 벌로 냄. 확장자 `.cjs` 까닭은 `helper_design_view_parity.cjs` 머리와 같음. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +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.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: "" }; +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; + +function loadTs(file) { + const src = fs.readFileSync(file, "utf8").replace(/import\.meta\.env\.DEV/g, "globalThis.__DEV"); + const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (request.endsWith(".css")) return {}; + if (STUBS[request]) return STUBS[request]; + throw new Error(`대역 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +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 } = {}) { + globalThis.__DEV = dev; + location.hash = `#/${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: STEP_ROUTES.indexOf(route), + onStepClick: (index, to) => calls.push(`${index}:${to}`), + }); + const press = (step) => { + toasts.length = 0; + const button = handle.root + .querySelectorAll(".ui-step-nav-btn") + .find((b) => b.dataset.stepNav === String(step)); + button.click(); + return { calls: calls.splice(0), toasts: [...toasts] }; + }; + return { handle, press }; +} + +(async () => { + const nav = {}; + const b05 = page("b05-profile"); + nav.b05Prev = b05.press(-1); + nav.b05Next = b05.press(1); + // B05 → B06 = 같은 페이지 — 켜진 칸만 갈아 끼움(design_page 의 setActiveStep) + location.hash = "#/b06-section"; + b05.handle.setActiveStep(3); + nav.b06Prev = b05.press(-1); + nav.b06Next = b05.press(1); + 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); + // 일반 사용자 — B05 [<](B04) = 관리자 전용 알림(역할은 모듈이 한 번 물어 기억 → 새로 읽음) + role = "USER"; + const fresh = loadTs(path.join(ROOT, "ui_template", "ui_template_workflow_layout.ts")); + const user = page("b05-profile", { dev: true, module: fresh }); + await new Promise((r) => setTimeout(r, 0)); + nav.userPrev = user.press(-1); + process.stdout.write(JSON.stringify({ rows, nav })); +})(); diff --git a/resources/tester/helper_design_view_parity.cjs b/resources/tester/helper_design_view_parity.cjs index c00f77d53..3f05a78a4 100644 --- a/resources/tester/helper_design_view_parity.cjs +++ b/resources/tester/helper_design_view_parity.cjs @@ -124,8 +124,6 @@ const LOCALES = { Common_Btn_Reset: ["초기화", "Reset"], B06_Profile_Btn_Save: ["저장", "Save"], B06_Profile_Btn_Confirm: ["확정", "Confirm"], - Design_Btn_ToCross: ["횡단 보기", "Cross view"], - Design_Btn_ToProfile: ["노선 보기", "Route view"], Design_Busy: ["계산 중", "Calculating"], // 「계산 중」 점(386-2) }; const STUBS = { @@ -206,7 +204,6 @@ const built = panel.createDesignPanel( reset: { run: run("reset") }, save: { run: run("save") }, confirm: { run: run("confirm") }, - switchView: { run: run("switch") }, }, }, "profile", diff --git a/resources/tester/test_280_2_step_nav.py b/resources/tester/test_280_2_step_nav.py new file mode 100644 index 000000000..5d95793fe --- /dev/null +++ b/resources/tester/test_280_2_step_nav.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""요구 280-2 · PLAN 481-1 — 좌측 아래 [?] [<] [>] 페이지 이동. + +① 줄 차례 = [?] [<] [>] + 그 페이지 단추 · 다섯 주소만(b03 · b04 · b09-print · m01 · m02 = [?] 만) · B09 [>] 흐림 +② [<] [>] = 이웃 단계 단추 누름(`onStepClick` 한 길) · 막힌 단계 = 누르지 않고 그 단추 title 알림 +③ B05 ↔ B06 = 같은 페이지 주소만(다시 안 실음) · B05 · B06 도크 = [초기화] [저장] [확정] 셋 +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] +FIVE = ("b05-profile", "b06-section", "b07-design-detail", "b08-quantity", "b09-estimation") +OTHERS = ("b03-file-input", "b04-preprocess", "b09-print", "m01-master-data", "m02-master-template") + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_280_2_step_nav.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.mark.parametrize("shape", ["helpRow", "plain"]) +def test_다섯_주소만_물음_앞뒤_차례(results, shape): + for route in FIVE: + assert results["rows"][route][shape]["labels"] == ["?", "<", ">", "저장"], route + for route in OTHERS: + assert results["rows"][route][shape]["labels"] == ["?", "저장"], route + + +def test_B09_다음만_흐림(results): + for route in FIVE: + expected = [False, False, route == "b09-estimation", False] + assert results["rows"][route]["helpRow"]["disabled"] == expected, route + + +def test_앞뒤는_이웃_단계_누름(results): + nav = results["nav"] + assert nav["b05Prev"] == {"calls": ["1:b04-preprocess"], "toasts": []} + assert nav["b05Next"] == {"calls": ["3:b06-section"], "toasts": []} + # B05 → B06 은 같은 페이지 — setActiveStep 으로 켜진 칸이 바뀐 뒤 이웃 + assert nav["b06Prev"] == {"calls": ["2:b05-profile"], "toasts": []} + assert nav["b06Next"] == {"calls": ["4:b07-design-detail"], "toasts": []} + assert nav["b09Prev"] == {"calls": ["5:b08-quantity"], "toasts": []} + assert nav["b09Next"] == {"calls": [], "toasts": []} + + +def test_막힌_단계는_알림만(results): + nav = results["nav"] + assert nav["blockedNext"] == {"calls": [], "toasts": ["WF_State_NotStarted"]} + assert nav["userPrev"] == {"calls": [], "toasts": ["시스템 관리자 전용 단계입니다."]} + + +def test_B05_B06_사이는_주소만_다시_안_실음(): + page = (ROOT / "A00_Common" / "design" / "design_page.ts").read_text(encoding="utf-8") + assert "if (designViewOf(route)) navigateTo(route);" in page + dock = (ROOT / "A00_Common" / "design" / "design_dock.ts").read_text(encoding="utf-8") + shared = (ROOT / "A00_Common" / "design" / "design_shared.ts").read_text(encoding="utf-8") + for text in (dock, shared): + assert "switchView" not in text + assert "row.append(resetButton, saveButton, confirmButton);" in dock + locale = (ROOT / "ui_template" / "ui_template_locale_b2.ts").read_text(encoding="utf-8") + assert "Design_Btn_ToCross" not in locale and "Design_Btn_ToProfile" not in locale diff --git a/resources/tester/test_design_view_parity.py b/resources/tester/test_design_view_parity.py index b2bcb5af3..f1518bd4f 100644 --- a/resources/tester/test_design_view_parity.py +++ b/resources/tester/test_design_view_parity.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- """B05 · B06 한 페이지 두 보기 — **좌측 패널 한 벌 지킴**(계획서 24-2f · 24-3b). -① 좌측 패널을 **하나** 세우고 보기를 오가도 뿌리 · 구조물 배치 · 아래 고정 영역 · 단추 넷이 같은 노드인지, - 보기마다 제 칸만 보이는지, [보기 전환] 글자가 따라가는지 본다(Node 헬퍼 `helper_design_view_parity.cjs` +① 좌측 패널을 **하나** 세우고 보기를 오가도 뿌리 · 구조물 배치 · 아래 고정 영역 · 단추 셋이 같은 노드인지, + 보기마다 제 칸만 보이는지 본다(Node 헬퍼 `helper_design_view_parity.cjs` 가 등록부를 트랜스파일해 가짜 DOM 위에서 돌린다). ② 보기 파일(`design_view_profile.ts` · `design_view_cross.ts`)이 좌측 칸 · 아래 단추 · 저장 단추를 직접 만들면 빨강 — 칸 · 단추 · 저장은 한 벌(`design_shared`)만 만든다. @@ -87,20 +87,18 @@ def test_보기마다_제_칸만_보인다(results): assert results["sectionIds"]["cross"] == ["structures", "cross-view", "standard-cross"] -def test_아래_단추는_네_개_보기_전환_글자만_따라감(results): +def test_아래_단추는_세_개_보기_전환_없음(results): + """[노선 보기] · [횡단 보기] 지움 — 보기 오가기는 공용 [<] [>](요구 280-2)""" views = results["views"] for name in ("profile", "cross", "back"): - assert views[name]["dockActions"] == ["reset", "save", "confirm", "switchView"] - assert views[name]["dockLabels"][:3] == ["초기화", "저장", "확정"] + assert views[name]["dockActions"] == ["reset", "save", "confirm"] + assert views[name]["dockLabels"] == ["초기화", "저장", "확정"] # [확정]은 종단 보기에서도 켜진다(한 벌 저장이 횡단 자료를 같은 페이지에서 얻음). - assert views[name]["dockDisabled"] == [False, False, False, False] - assert views["profile"]["dockLabels"][3] == "횡단 보기" - assert views["cross"]["dockLabels"][3] == "노선 보기" - assert views["back"]["dockLabels"][3] == "횡단 보기" + assert views[name]["dockDisabled"] == [False, False, False] def test_단추는_한_벌_동작을_부름(results): - names = ["reset", "save", "confirm", "switch"] + names = ["reset", "save", "confirm"] assert results["calls"] == [ *(f"profile:{n}" for n in names), *(f"cross:{n}" for n in names), diff --git a/ui_template/ui_template_help.css b/ui_template/ui_template_help.css index 3585bea06..14bb7c9b4 100644 --- a/ui_template/ui_template_help.css +++ b/ui_template/ui_template_help.css @@ -1,6 +1,8 @@ -/* [?] 설명서(54-2) — 단추는 글자 폭만 · 모달은 왼쪽 차례 · 오른쪽 글 */ +/* [?] 설명서(54-2) — 단추는 글자 폭만 · 모달은 왼쪽 차례 · 오른쪽 글 · [<] [>](280-2)도 같은 정사각 */ .ui-sidebar-actions .ui-help-btn, -.ui-help-btn { +.ui-help-btn, +.ui-sidebar-actions .ui-step-nav-btn, +.ui-step-nav-btn { flex: none; /* 정사각 — 너비 = 높이(글 한 줄 · 위아래 여백 · 테두리) */ width: calc(1em + 2 * var(--spacing-8) + 2px); diff --git a/ui_template/ui_template_help.ts b/ui_template/ui_template_help.ts index e9c2773cf..c7ca4d3f6 100644 --- a/ui_template/ui_template_help.ts +++ b/ui_template/ui_template_help.ts @@ -5,10 +5,12 @@ * 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막 * (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서). * 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞. + * [<] [>](요구 280-2) = [?] 바로 뒤 · B05~B09 다섯 주소만 · 누르면 `ui-step-nav` 를 올려 보냄 → + * 진행 단계 틀(`createWorkflowLayout`)이 이웃 단계 단추를 누름(저장 · 막힘은 각 페이지 `onStepClick`). * ========================================================================== */ import "./ui_template_help.css"; -import { API_BASE_URL } from "@config/config_frontend"; +import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import { createButton, el } from "./ui_template_elements"; import { openModal } from "./ui_template_modal"; @@ -19,6 +21,16 @@ interface HelpItem { } const TEXT = { button: "?", tip: "설명서", title: "설명서", close: "닫기", empty: "설명 글 없음" }; +const STEP_TEXT = { prev: "<", next: ">", prevTip: "이전 단계", nextTip: "다음 단계" }; + +/** [<] [>] 를 붙이는 주소 — 목록으로 거름(b09-print · B03 · B04 · M01 · M02 뺌) */ +const STEP_NAV_ROUTES = [ + ROUTES.B05_PROFILE, + ROUTES.B06_SECTION, + ROUTES.B07_DESIGN_DETAIL, + ROUTES.B08_QUANTITY, + ROUTES.B09_ESTIMATION, +] as const; function currentPage(): string { const n = /^#\/b0(\d)/i.exec(location.hash)?.[1]; @@ -161,8 +173,28 @@ export function createHelpButton(): HTMLButtonElement { return button; } +function createStepNavButton(step: -1 | 1, disabled: boolean): HTMLButtonElement { + const button = createButton({ + label: step < 0 ? STEP_TEXT.prev : STEP_TEXT.next, + variant: "ghost", + onClick: () => + button.dispatchEvent(new CustomEvent("ui-step-nav", { bubbles: true, detail: step })), + }); + button.classList.add("ui-step-nav-btn"); + button.dataset.stepNav = String(step); + button.title = button.ariaLabel = step < 0 ? STEP_TEXT.prevTip : STEP_TEXT.nextTip; + button.disabled = disabled; + return button; +} + /** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */ export function placeHelpButton(actions: HTMLElement): void { if (actions.querySelector(".ui-help-btn")) return; - (actions.querySelector("[data-help-row]") ?? actions).prepend(createHelpButton()); + const row = actions.querySelector("[data-help-row]") ?? actions; + const route = location.hash.replace(/^#\/?/, "").split("?")[0]; + const index = (STEP_NAV_ROUTES as readonly string[]).indexOf(route); + if (index < 0) return void row.prepend(createHelpButton()); + // B09 = 마지막 단계 — [>] 흐림 + const last = index === STEP_NAV_ROUTES.length - 1; + row.prepend(createHelpButton(), createStepNavButton(-1, false), createStepNavButton(1, last)); } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index e33a35d5d..fbeddc50c 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -145,8 +145,6 @@ export const ui_locales_b2 = { B05_Route_Btn_TempSave: ["저장", "Save"], B05_Route_Btn_GoCross: ["횡단 이동", "Go to Cross"], /* 좌측 패널 아래 단추 [보기 전환] — 가려는 쪽 이름(계획서 24-2b). */ - Design_Btn_ToCross: ["횡단 보기", "Cross view"], - Design_Btn_ToProfile: ["노선 보기", "Route view"], B05_Route_TempSave_Success: ["변경 내용을 저장했습니다.", "Changes saved."], B05_Route_TempSave_Failed: ["저장에 실패했습니다.", "Failed to save."], B05_Route_Reset_Confirm: [ diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index 85c909391..1159e358e 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -376,8 +376,8 @@ gap: var(--btn-group-gap); } -.ui-sidebar-actions > .ui-btn:not(.ui-help-btn), -.ui-sidebar-actions [data-help-row] > .ui-btn:not(.ui-help-btn) { +.ui-sidebar-actions > .ui-btn:not(.ui-help-btn, .ui-step-nav-btn), +.ui-sidebar-actions [data-help-row] > .ui-btn:not(.ui-help-btn, .ui-step-nav-btn) { flex: 1 1 0; min-width: auto; padding-inline: var(--spacing-4); @@ -469,7 +469,7 @@ } /* 단추 크기 = 「구조물 배치」 단추(`b05-route__irregular-btn`) · 색은 단추 갈래(filled · ghost) 그대로 */ -.ui-panel-form .ui-btn:not(.ui-help-btn) { +.ui-panel-form .ui-btn:not(.ui-help-btn, .ui-step-nav-btn) { padding: var(--spacing-8); border-radius: var(--radius-inputs); font-size: var(--text-caption); diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index bcee03cd3..a8bab71f7 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -2,6 +2,7 @@ import "./ui_template_workflow_layout.css"; import { ROUTES } from "@config/config_frontend"; import { t } from "./ui_template_locale"; import { createWorkflowOverlays } from "./ui_template_overlay"; +import { showToast } from "./ui_template_elements"; import { navigateTo } from "../A00_Common/router"; import { setDrawerSteps } from "../A00_Common/app_shell"; import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; @@ -206,6 +207,14 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa ".ui-workflow-layout__step:not(.ui-workflow-layout__step--home)", ), ]; + // 좌측 아래 [<] [>](요구 280-2) = 이웃 단계 단추 누름 — 막힘 · 저장 · 이동은 그 단추의 `onStepClick` 한 길 + root.addEventListener("ui-step-nav", (event) => { + const active = steps.findIndex((step) => step.classList.contains("is-active")); + const target = active < 0 ? undefined : steps[active + (event as CustomEvent).detail]; + if (!(target instanceof HTMLButtonElement)) return; + if (target.disabled) showToast(target.title || t("WF_State_NotStarted")); + else target.click(); + }); return { root, setOptionsOpen: overlays.setTitleOpen,