feat(481-1 페이지 이동): B05~B09 좌측 아래 [?] [<] [>] — 이웃 진행 단계 단추 누름 · B05 · B06 [노선 보기] · [횡단 보기] 지움
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<DesignDockActions>) => 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();
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }));
|
||||
})();
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HTMLElement>("[data-help-row]") ?? actions).prepend(createHelpButton());
|
||||
const row = actions.querySelector<HTMLElement>("[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));
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<number>).detail];
|
||||
if (!(target instanceof HTMLButtonElement)) return;
|
||||
if (target.disabled) showToast(target.title || t("WF_State_NotStarted"));
|
||||
else target.click();
|
||||
});
|
||||
return {
|
||||
root,
|
||||
setOptionsOpen: overlays.setTitleOpen,
|
||||
|
||||
Reference in New Issue
Block a user