feat(496-1 B05·B06 단추 줄): [확정] 지움 · 차례 [?] [<] [>] [저장] [초기화] · B06 [>] = 확정 뒤 B07

- 새 단추 꼴 danger-ghost(테두리 · 글 빨강 · 올리면 채움) · [초기화]만
- B05 [<] 흐림 · 주소 바뀔 때마다 [<] [>] 다시 맞춤
- 단계 판 B07~B09 · B06 [>] = 확정 뒤 그 주소 · 실패 · 자료 서기 전 = 머묾
- 노선 바뀐 채 확정 = 저장 뒤 물음 [머묾] / [B07 로](새 노선으로 확정 뒤 이동)
- 막힘 = NOT_STARTED 만(STALE 은 색만) · B05·B06 화면 2 · 3 · 4 칸 늘 켬 · [저장] 뒤 단계 색 다시 읽음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 19:54:16 +09:00
co-authored by Claude Opus 5.5
parent 5392680ec1
commit 21e0f10df9
15 changed files with 290 additions and 119 deletions
+5 -13
View File
@@ -2,8 +2,8 @@
* design_dock.ts
* B05 · B06 한 페이지 두 보기의 **좌측 패널 맨 아래 고정 영역**(계획서 24-1 · 24-2b).
*
* 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 셋은 두 보기가 **같다**:
* [초기화] [저장] [확정](보기 오가기는 공용 [<] [>] · 요구 280-2). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은
* 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 둘은 두 보기가 **같다**:
* [저장] [초기화](앞은 공용 [?] [<] [>] · B06 [>] = 확정 뒤 B07 · 요구 280-2 · 298-2). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은
* 단추를 만들고 켜고 끄기만 한다(저장 흐름은 `design_save.ts` 몫).
* ========================================================================== */
@@ -24,12 +24,11 @@ export interface DesignDockAction {
export interface DesignDockActions {
reset: DesignDockAction;
save: DesignDockAction;
confirm: DesignDockAction;
}
export interface DesignDock {
root: HTMLElement;
/** 단추 셋의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */
/** 단추 둘의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */
setActions: (actions: Partial<DesignDockActions>) => void;
/** 보기가 바뀌면 단추 줄 class 가 따라 바뀐다. */
setView: (view: DesignView) => void;
@@ -72,7 +71,7 @@ export function createDesignDock(
};
const resetButton = createButton({
label: label("Common_Btn_Reset"),
variant: "danger",
variant: "danger-ghost",
onClick: press("reset"),
});
const saveButton = createButton({
@@ -80,18 +79,11 @@ export function createDesignDock(
variant: "ghost",
onClick: press("save"),
});
const confirmButton = createButton({
label: label("B06_Profile_Btn_Confirm"),
variant: "filled",
onClick: press("confirm"),
});
resetButton.dataset.dockAction = "reset";
saveButton.dataset.dockAction = "save";
confirmButton.dataset.dockAction = "confirm";
const buttons = {
reset: resetButton,
save: saveButton,
confirm: confirmButton,
};
const sync = (): void => {
@@ -104,7 +96,7 @@ export function createDesignDock(
row.className = current === "profile" ? "b05-route__actions" : "b06-profile__actions";
};
row.append(resetButton, saveButton, confirmButton);
row.append(saveButton, resetButton);
root.append(...above, divider, row, busy);
sync();
+12 -3
View File
@@ -21,6 +21,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createWorkflowLayout, type WorkflowLayoutHandle } from "@ui/ui_template_workflow_layout";
import { workflowSteps } from "../b_page_scaffold";
import {
clearWorkflowStateCache,
fetchWorkflowState,
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
@@ -28,7 +29,7 @@ import {
} from "../b_workflow_nav";
import { currentRoute, navigateTo, type MountedPage } from "../router";
import type { DesignView } from "./design_dock";
import { saveSharedDesign, type DesignPageShared } from "./design_shared";
import { confirmAndGo, saveSharedDesign, type DesignPageShared } from "./design_shared";
import { guardDesignLeave } from "./design_leave";
import { usePipeIds } from "./design_pipe_ids";
import { createFacilityLane } from "./design_facility_lane";
@@ -152,6 +153,12 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
lane: createFacilityLane(projectId, () => shared.profile),
revealLeft: () => layout?.setOptionsOpen(true),
ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready,
refreshStages: () => {
clearWorkflowStateCache();
void fetchWorkflowState(projectId)
.then((state) => layout?.setStages(state.stages))
.catch(() => undefined);
},
};
guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4)
let busy = false; // 「계산 중」 점 — 좌측 패널이 늦게 서도 지금 상태로(386-2)
@@ -177,12 +184,14 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
leftPanel: left,
mainContent: main,
stages: state?.stages,
currentStage: state?.current_stage,
// 종단 · 횡단 · 상세설계 칸은 늘 켬 — B07 로 가는 길이 곧 확정(496-1 · 운영 잠금 흠)
currentStage: [2, 3, 4],
routes: WORKFLOW_STEP_ROUTES,
// 종단설계 · 횡단설계 두 칸은 같은 페이지 — 주소만 바꿔 보기만 바꾼다.
// 종단설계 · 횡단설계 두 칸은 같은 페이지 — 주소만 바꿔 보기만 바꾼다. B07~B09 = 확정 뒤 이동(496-1).
onStepClick: (stepIndex) => {
const route = WORKFLOW_STEP_ROUTES[stepIndex];
if (designViewOf(route)) navigateTo(route);
else if (stepIndex >= 4) confirmAndGo(shared, route);
else goToWorkflowStage(projectId, route);
},
});
+8 -3
View File
@@ -65,6 +65,8 @@ export interface DesignSaveContext {
done?: (confirm: boolean) => Promise<void> | void;
/** 멈춘 저장을 알린다. */
failed: (error: unknown, confirm: boolean) => void;
/** 노선 모양 바뀐 채 확정 — 노선 · 고침은 저장됨 · 페이지 다시 세우기 앞에 물음(요구 300 B2). */
routeMoved?: () => Promise<void>;
}
export type PartOverride = (
@@ -222,9 +224,12 @@ export async function saveDesign(
}
const saved = (result.steps.route ?? {}) as unknown as RouteReplanResponse;
noticeRouteSaved(saved, Number(result.steps.carry?.outside ?? 0));
// 새 노선 — [확정]은 새 노선을 본 뒤 다시(단계 닫기는 새 횡단 본문이 있어야 함) · 페이지 다시 세움.
if (confirm) showToast("노선을 저장했습니다 — 새 노선을 확인한 뒤 다시 [확정]하세요.", "info");
else await ctx.done?.(false);
// 새 노선 — 확정은 새 횡단 본문이 있어야 함 → 물음(머묾 / 새 노선으로 확정 뒤 이동) · 페이지 다시 세움.
if (confirm) {
hideLoadingOverlay(); // 물음 동안 가림막을 걷음
await ctx.routeMoved?.();
showLoadingOverlay();
} else await ctx.done?.(false);
await rebuildAfterRouteSave(ctx.projectId);
// 옛 노선 칸 · 초안 전 hold = 서버가 새 노선에 넘겼음 — 쓰레기만 비움(넘김 규칙 그대로 · 세션 넘침 388-3)
if (saved.route_id !== routeId) clearRouteDrafts(ctx.projectId, routeId);
+46 -10
View File
@@ -7,14 +7,19 @@
* `bindDesignView` 로 건 동작으로 간다. 값이 바뀐 알림(관 목록 · 횡단 재계산)은 숨은 보기도
* 받는다 — 다시 보일 때 같은 값을 들고 있게.
* 시설 줄(알약 · 비정규 측점 · 관 id 장부)은 페이지 한 벌(`design_facility_lane`) — 관 알림 · 노선 따라감을 여기서 넘김.
* [저장] · [확정]은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로 보낸다. 종단 보기의
* [확정]은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`).
* [저장] · 확정(B06 [>] · 단계 판 B07~B09 · 496-1)은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로
* 보낸다. 종단 보기의 확정은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import type { RoutePath } from "@config/config_frontend";
import { showConfirmDialog, 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 {
clearWorkflowStateCache,
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
} from "../b_workflow_nav";
import { readState, writeState } from "../b_page_state";
import { currentRoute, remountRoute } from "../router";
import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch";
@@ -116,6 +121,10 @@ export interface DesignPageShared {
revealLeft: () => void;
/** 그 보기를 (숨긴 채라도) 세우고 자료가 설 때까지 기다린다. */
ensureView: (view: DesignView) => Promise<void>;
/** 자료가 섰다(`designReady`) — 그 전 확정 이동은 막는다. */
ready?: boolean;
/** [저장] 뒤 진행 단계 판 색을 서버 단계 상태로 다시 맞춘다(496-1). */
refreshStages?: () => void;
}
const bound = (shared: DesignPageShared): DesignViewBinding => shared.bindings[shared.view()] ?? {};
@@ -342,7 +351,6 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel {
},
// 자료가 서기 전에는 잠근다 — 보기가 `designReady` 로 연다.
save: { run: () => saveSharedDesign(shared, false), enabled: false },
confirm: { run: () => saveSharedDesign(shared, true), enabled: false },
},
},
shared.view(),
@@ -355,16 +363,29 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel {
return panel;
}
/** 자료가 섰다 — [저장] · [확정]을 연다(두 보기 어느 쪽이 먼저든 한 번이면 된다). */
/** 노선 바뀐 채 확정 → [B07 로] 고름 — 다시 세운 페이지가 자료를 세우면 새 노선으로 확정 뒤 그 주소(요구 300 B2). */
const confirmOnReady = new Map<string, RoutePath>();
/** 자료가 섰다 — [저장] · 확정 이동을 연다(두 보기 어느 쪽이 먼저든 한 번이면 된다). */
export function designReady(shared: DesignPageShared): void {
shared.ready = true;
shared.panel?.dock.setActions({
save: {
run: () => saveSharedDesign(shared, false),
enabled: true,
title: L("B06_Profile_Btn_Save_Tip"),
},
confirm: { run: () => saveSharedDesign(shared, true), enabled: true },
});
const target = confirmOnReady.get(shared.projectId);
if (!target) return;
confirmOnReady.delete(shared.projectId);
void saveSharedDesign(shared, true, target);
}
/** B06 [>] · 단계 판 B07~B09(496-1) — 확정 뒤 그 주소 · 실패 · 자료 서기 전 = 머묾. */
export function confirmAndGo(shared: DesignPageShared, route: RoutePath): void {
if (!shared.ready) return void showToast(L("Design_Next_NotReady"), "info");
void saveSharedDesign(shared, true, route);
}
/**
@@ -384,10 +405,14 @@ async function routeChanged(shared: DesignPageShared): Promise<boolean> {
}
/**
* [저장] · [확정] 한 번 — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가,
* [저장] · 확정 한 번(확정 = `target` 으로 감 · 기본 B07) — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가,
* 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳.
*/
export async function saveSharedDesign(shared: DesignPageShared, confirm: boolean): Promise<void> {
export async function saveSharedDesign(
shared: DesignPageShared,
confirm: boolean,
target: RoutePath = WORKFLOW_STEP_ROUTES[4],
): Promise<void> {
if (await routeChanged(shared)) return;
// [저장] · [확정] 본문 = 횡단 보기 한 벌(표준 횡단면 · 전 측점 면적 · 카드 고침) — 들어온 화면 무관 · 숨긴 채라도
// 세움(B05 만 연 캐시 패치 길 지움 · 386-6)
@@ -432,7 +457,7 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
// 서버가 정본을 다시 씀 — 다음 페이지(B07)가 브라우저 셈 · 옛 세션 사본 말고 정본을 받게(470 N1)
invalidateSectionDetail(shared.projectId);
showToast(L("B06_Profile_Confirm_Success"), "success");
goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]);
goToWorkflowStage(shared.projectId, target);
return;
}
// 서버가 정본(계획선 · 횡단 · 면적)을 다시 썼다 — 공유 캐시를 한 번 비우고 두 보기가 새 상세 한 객체를
@@ -441,6 +466,17 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
await profileSave?.done?.(false);
await crossSave?.done?.(false);
showToast(L("B06_Profile_Save_Success"), "success");
shared.refreshStages?.();
},
// 노선 모양 바뀐 채 확정 — 새 노선은 저장됨 · 머묾 / 새 노선으로 확정 뒤 이동을 고름(요구 300 B2)
routeMoved: async () => {
clearWorkflowStateCache();
const go = await showConfirmDialog(
L("Design_Route_Moved"),
L("Design_Route_Moved_Go"),
L("Design_Route_Moved_Stay"),
);
if (go) confirmOnReady.set(shared.projectId, target);
},
failed: (error, isConfirm) => {
const key = isConfirm ? "B06_Profile_Confirm_Failed" : "B06_Profile_Save_Failed";
+1 -1
View File
@@ -331,7 +331,7 @@ export function createStandardPanel(
pending.className = "b06-std__pending";
pending.textContent =
"고친 값이 측점에 아직 안 실렸습니다 — [전체 측점 반영]을 누르면 바로 따라오고, " +
"[저장]·[확정] 때도 전 측점이 이 값으로 다시 섭니다. 그전에 카드를 만지면 그 측점만 먼저 바뀝니다.";
"[저장]·[>](확정) 때도 전 측점이 이 값으로 다시 섭니다. 그전에 카드를 만지면 그 측점만 먼저 바뀝니다.";
const showPending = (): void => {
const now = JSON.stringify(state);
// 값이 실렸는지 눈으로도 캘 수 있게 남긴다 — 화면 실측에서 판정 근거가 된다.
+53 -11
View File
@@ -16,6 +16,7 @@ class FakeNode {
this.dataset = {};
this.listeners = {};
this.disabled = false;
this.isConnected = true;
this.title = "";
this._text = "";
this._classes = new Set();
@@ -87,6 +88,16 @@ class CustomEvent {
}
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;
@@ -190,9 +201,9 @@ for (const route of ALL) rows[route] = { helpRow: rowOf(route, true), plain: row
// ② [<] [>] = 이웃 단계 누름
const STEP_ROUTES = ALL.slice(0, 7);
function page(route, { stages, dev = false, module = layout } = {}) {
function page(route, { stages, dev = false, module = layout, current } = {}) {
globalThis.__DEV = dev;
location.hash = `#/${route}`;
goHash(route);
const calls = [];
const handle = module.createWorkflowLayout({
title: route,
@@ -201,18 +212,23 @@ function page(route, { stages, dev = false, module = layout } = {}) {
mainContent: new FakeNode("div"),
routes: STEP_ROUTES,
stages,
currentStage: STEP_ROUTES.indexOf(route),
currentStage: current ?? STEP_ROUTES.indexOf(route),
onStepClick: (index, to) => calls.push(`${index}:${to}`),
});
const press = (step) => {
toasts.length = 0;
const button = handle.root
const navButton = (step) =>
handle.root
.querySelectorAll(".ui-step-nav-btn")
.find((b) => b.dataset.stepNav === String(step));
button.click();
const press = (step) => {
toasts.length = 0;
navButton(step).click();
return { calls: calls.splice(0), toasts: [...toasts] };
};
return { handle, press };
const stepButton = (index) =>
handle.root.querySelectorAll(".ui-workflow-layout__step:not(.ui-workflow-layout__step--home)")[
index
];
return { handle, press, navButton, stepButton };
}
(async () => {
@@ -220,11 +236,16 @@ function page(route, { stages, dev = false, module = layout } = {}) {
const b05 = page("b05-profile");
nav.b05Prev = b05.press(-1);
nav.b05Next = b05.press(1);
// B05 → B06 = 같은 페이지 — 켜진 칸만 갈아 끼움(design_page 의 setActiveStep)
location.hash = "#/b06-section";
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);
@@ -236,11 +257,32 @@ function page(route, { stages, dev = false, module = layout } = {}) {
}));
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("b05-profile", { dev: true, module: fresh });
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 }));
})();
@@ -123,7 +123,6 @@ function html(node) {
const LOCALES = {
Common_Btn_Reset: ["초기화", "Reset"],
B06_Profile_Btn_Save: ["저장", "Save"],
B06_Profile_Btn_Confirm: ["확정", "Confirm"],
Design_Busy: ["계산 중", "Calculating"], // 「계산 중」 점(386-2)
};
const STUBS = {
@@ -203,7 +202,6 @@ const built = panel.createDesignPanel(
dock: {
reset: { run: run("reset") },
save: { run: run("save") },
confirm: { run: run("confirm") },
},
},
"profile",
@@ -232,6 +230,7 @@ function snapshot() {
dockIsLast: built.root.children[built.root.children.length - 1] === dock,
dockLabels: buttons.map((b) => b.textContent),
dockActions: buttons.map((b) => b.dataset.dockAction),
dockClasses: buttons.map((b) => b.className),
dockDisabled: buttons.map((b) => b.disabled),
};
}
+34 -8
View File
@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
"""요구 280-2 · PLAN 481-1 — 좌측 아래 [?] [<] [>] 페이지 이동.
① 줄 차례 = [?] [<] [>] + 그 페이지 단추 · 다섯 주소만(b03 · b04 · b09-print · m01 · m02 = [?] 만) · B09 [>] 흐림
② [<] [>] = 이웃 단계 단추 누름(`onStepClick` 한 길) · 막힌 단계 = 누르지 않고 그 단추 title 알림
③ B05 ↔ B06 = 같은 페이지 주소만(다시 안 실음) · B05 · B06 도크 = [초기화] [저장] [확정] 셋
① 줄 차례 = [?] [<] [>] + 그 페이지 단추 · 다섯 주소만(b03 · b04 · b09-print · m01 · m02 = [?] 만) · B05 [<] · B09 [>] 흐림
② [<] [>] = 이웃 단계 단추 누름(`onStepClick` 한 길) · 막힌 단계(NOT_STARTED 만) = 누르지 않고 그 단추 title 알림
③ B05 ↔ B06 = 같은 페이지 주소만(다시 안 실음) · 주소 바뀌면 [<] 다시 맞춤 · 도크 = [저장] [초기화] 둘(496-1)
"""
import json
@@ -40,15 +40,15 @@ def test_다섯_주소만_물음_앞뒤_차례(results, shape):
assert results["rows"][route][shape]["labels"] == ["?", "저장"], route
def test_B09_다음만_흐림(results):
def test_B05_앞_B09_다음만_흐림(results):
for route in FIVE:
expected = [False, False, route == "b09-estimation", False]
expected = [False, route == "b05-profile", 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["b05Prev"] == {"calls": [], "toasts": []} # 흐림 — 누름 안 감(496-1)
assert nav["b05Next"] == {"calls": ["3:b06-section"], "toasts": []}
# B05 → B06 은 같은 페이지 — setActiveStep 으로 켜진 칸이 바뀐 뒤 이웃
assert nav["b06Prev"] == {"calls": ["2:b05-profile"], "toasts": []}
@@ -57,10 +57,32 @@ def test_앞뒤는_이웃_단계_누름(results):
assert nav["b09Next"] == {"calls": [], "toasts": []}
def test_B05_앞_흐림은_주소_따라_다시_맞춤(results):
assert results["nav"]["prevDisabled"] == {"b05": True, "b06": False, "back": True}
def test_막힌_단계는_알림만(results):
nav = results["nav"]
assert nav["blockedNext"] == {"calls": [], "toasts": ["WF_State_NotStarted"]}
assert nav["userPrev"] == {"calls": [], "toasts": ["시스템 관리자 전용 단계입니다."]}
assert nav["userPrev"] == {"calls": ["2:b05-profile"], "toasts": []}
lock = {"disabled": True, "title": "시스템 관리자 전용 단계입니다."}
assert nav["userB04"] == lock and nav["userB04Repaint"] == lock
def test_STALE_은_색만_막지_않음(results):
nav = results["nav"]
assert nav["staleNext"] == {"calls": ["4:b07-design-detail"], "toasts": []}
assert "state-stale" in nav["staleClass"].split()
def test_다시_칠하기와_켤_칸_목록(results):
nav = results["nav"]
assert nav["repaintNext"] == {"calls": ["4:b07-design-detail"], "toasts": []}
assert "state-in_progress" in nav["repaintClass"].split()
assert "state-not_started" not in nav["repaintClass"].split()
# B05 · B06 화면 = 2 · 3 · 4 늘 켬 — 운영 · 체인 직후 B07 NOT_STARTED 라도 [>] 가 감 · B08 은 막힘
assert nav["listNext"] == {"calls": ["4:b07-design-detail"], "toasts": []}
assert nav["listB08"] is True
def test_B05_B06_사이는_주소만_다시_안_실음():
@@ -70,6 +92,10 @@ def test_B05_B06_사이는_주소만_다시_안_실음():
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
assert "row.append(saveButton, resetButton);" in dock
assert "confirmButton" not in dock and 'variant: "danger-ghost"' in dock
assert "currentStage: [2, 3, 4]" in page
assert "else if (stepIndex >= 4) confirmAndGo(shared, route);" in page
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
assert "B06_Profile_Btn_Confirm" not in locale
+3 -1
View File
@@ -93,7 +93,9 @@ def test_두_페이지가_저장_한_줄기를_탄다():
actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text(encoding="utf-8")
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert shared.count("await saveDesign(") == 1, "저장 한 줄기를 한 자리에서만 부름"
assert "saveSharedDesign(shared, true)" in shared, "[확정]이 한 줄기를 안 탐"
page = (ROOT / "A00_Common" / "design" / "design_page.ts").read_text(encoding="utf-8")
assert "saveSharedDesign(shared, true, route)" in shared, "확정(B06 [>])이 한 줄기를 안 탐"
assert "confirmAndGo(shared, route)" in page, "B06 [>] · 단계 판 B07~B09 가 확정을 안 탐(496-1)"
assert 'await shared.ensureView("cross")' in shared, "종단 보기 [확정]이 횡단 자료를 못 얻음"
for name, text in (("종단 보기", actions), ("횡단 보기", persist), ("한 벌", shared)):
for token in ("flushPendingPipes(", "flushPendingStructures(", "flushUphillOverrides("):
+8 -7
View File
@@ -87,18 +87,19 @@ def test_보기마다_제_칸만_보인다(results):
assert results["sectionIds"]["cross"] == ["structures", "cross-view", "standard-cross"]
def test_아래_단추는_세_개_보기_전환_없음(results):
"""[노선 보기] · [횡단 보기] 지움 — 보기 오가기는 공용 [<] [>](요구 280-2)"""
def test_아래_단추는_둘_보기_전환_없음(results):
"""[노선 보기] · [횡단 보기] 지움 — 보기 오가기는 공용 [<] [>](요구 280-2) · [확정] 지움 = B06 [>](496-1)"""
views = results["views"]
for name in ("profile", "cross", "back"):
assert views[name]["dockActions"] == ["reset", "save", "confirm"]
assert views[name]["dockLabels"] == ["초기화", "저장", "확정"]
# [확정]은 종단 보기에서도 켜진다(한 벌 저장이 횡단 자료를 같은 페이지에서 얻음).
assert views[name]["dockDisabled"] == [False, False, False]
assert views[name]["dockActions"] == ["save", "reset"]
assert views[name]["dockLabels"] == ["저장", "초기화"]
assert views[name]["dockDisabled"] == [False, False]
# [초기화] = 테두리 · 글만 빨강(danger-ghost) · [저장] = ghost
assert views[name]["dockClasses"] == ["ui-btn ui-btn--ghost", "ui-btn ui-btn--danger-ghost"]
def test_단추는_한_벌_동작을_부름(results):
names = ["reset", "save", "confirm"]
names = ["save", "reset"]
assert results["calls"] == [
*(f"profile:{n}" for n in names),
*(f"cross:{n}" for n in names),
+1 -1
View File
@@ -23,7 +23,7 @@ export { el } from "./ui_template_elements_base";
* 1. 버튼 (Button) — design.md: Filled Brand / Ghost Outlined / Pill Nav
* -------------------------------------------------------------------------- */
export type ButtonVariant = "filled" | "ghost" | "pill" | "danger" | "glass";
export type ButtonVariant = "filled" | "ghost" | "pill" | "danger" | "danger-ghost" | "glass";
export type ButtonSize = "md" | "sm";
export interface ButtonOptions {
@@ -67,6 +67,17 @@ const BASE_CSS = `
color: var(--color-canvas);
}
/* [초기화] 꼴 — 테두리 · 글만 빨강 · 올리면 채움 */
.ui-btn--danger-ghost {
background-color: transparent;
border-color: var(--color-danger);
color: var(--color-danger);
}
.ui-btn--danger-ghost:hover:not(:disabled) {
background-color: var(--color-danger);
color: var(--color-canvas);
}
/* 3D 뷰포트 오버레이용: 배경 위에 떠 있어 반투명 + 블러 필요 */
.ui-btn--glass {
border-color: color-mix(in srgb, var(--color-border) 65%, transparent);
+23 -8
View File
@@ -173,7 +173,7 @@ export function createHelpButton(): HTMLButtonElement {
return button;
}
function createStepNavButton(step: -1 | 1, disabled: boolean): HTMLButtonElement {
function createStepNavButton(step: -1 | 1): HTMLButtonElement {
const button = createButton({
label: step < 0 ? STEP_TEXT.prev : STEP_TEXT.next,
variant: "ghost",
@@ -183,18 +183,33 @@ function createStepNavButton(step: -1 | 1, disabled: boolean): HTMLButtonElement
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;
}
/** 지금 주소의 [<] [>] 차례 — 목록 밖이면 -1 */
function stepNavIndex(): number {
const route = location.hash.replace(/^#\/?/, "").split("?")[0];
return (STEP_NAV_ROUTES as readonly string[]).indexOf(route);
}
/** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */
export function placeHelpButton(actions: HTMLElement): void {
if (actions.querySelector(".ui-help-btn")) return;
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));
if (stepNavIndex() < 0) return void row.prepend(createHelpButton());
const prev = createStepNavButton(-1);
const next = createStepNavButton(1);
// 첫 단계(B05) [<] · 마지막 단계(B09) [>] 흐림 — B05 ↔ B06 은 페이지를 다시 안 세우므로 주소마다 다시 맞춤
const sync = (): void => {
const index = stepNavIndex();
prev.disabled = index <= 0;
next.disabled = index === STEP_NAV_ROUTES.length - 1;
};
const onHash = (): void => {
if (prev.isConnected) sync();
else window.removeEventListener("hashchange", onHash); // 화면에서 빠짐 — 풂
};
sync();
window.addEventListener("hashchange", onHash);
row.prepend(createHelpButton(), prev, next);
}
+15 -6
View File
@@ -191,14 +191,12 @@ export const ui_locales_b2 = {
B06_Profile_Field_Smooth: ["지표면 스무딩", "Smooth surface"],
B06_Profile_Smooth_On: ["사용", "On"],
B06_Profile_Smooth_Off: ["미사용", "Off"],
/* 2026-08-08 재정의 — 확정은 종+횡 통합(stage 2·3 함께 완료), 종단 이동은 저장 없이 B05로. */
B06_Profile_Btn_Confirm: ["확정", "Confirm"],
B06_Profile_Btn_GoProfile: ["종단 이동", "Go to Profile"],
/* 저장 — 확정과 같은 내용을 저장하되 경로 상태·워크플로 단계는 건드리지 않는다. */
B06_Profile_Btn_Save: ["저장", "Save"],
B06_Profile_Btn_Save_Tip: [
"지금까지 편집한 내용을 확정하지 않고 저장합니다. 페이지는 그대로 있습니다.",
"Saves the current edits without confirming. You stay on this page.",
"지금까지 편집한 내용을 저장합니다. 페이지는 그대로 있습니다 — 확정은 B06 [>] 로 B07 에 넘어갈 때.",
"Saves the current edits. You stay on this page — B06 [>] confirms and moves to B07.",
],
B06_Profile_Save_Success: ["편집 내용을 저장했습니다.", "Changes saved."],
B06_Profile_Save_Failed: ["저장에 실패했습니다.", "Failed to save."],
@@ -405,8 +403,8 @@ export const ui_locales_b2 = {
B06_Profile_View_ZoomOut: ["축소", "Zoom out"],
B06_Profile_View_ZoomFit: ["원래 크기", "Fit"],
B06_MassHaul_Balloon_Reset_Tip: [
"끌어 옮긴 유토곡선 도형을 자동 배치로 되돌립니다. 횡단을 확정하면 저장된 위치도 함께 지워집니다.",
"Restores dragged mass-haul labels to automatic placement. Confirming the sections also clears the stored positions.",
"끌어 옮긴 유토곡선 도형을 자동 배치로 되돌립니다. B06 [>] 로 B07 에 넘어가면(확정) 저장된 위치도 함께 지워집니다.",
"Restores dragged mass-haul labels to automatic placement. Moving to B07 with B06 [>] (confirm) also clears the stored positions.",
],
/* --- B06 측점 표준횡단 설계 지정 --- */
@@ -1056,4 +1054,15 @@ export const ui_locales_b2 = {
"Calculation unstable — inputs and results did not settle in three rounds",
],
Design_Busy: ["계산 중", "Calculating"],
/* B06 [>] = 확정 뒤 B07(496-1) */
Design_Next_NotReady: [
"자료를 불러오는 중입니다 — 다 뜬 뒤 다시 누르세요.",
"Still loading — press again when the page is ready.",
],
Design_Route_Moved: [
"노선이 바뀌었음 — 새 노선과 고친 값은 저장됨. 새 노선을 보고 머물거나, 새 노선으로 확정한 뒤 B07 로 넘어감.",
"The route changed — the new route and edits are saved. Stay to review it, or confirm with the new route and go to B07.",
],
Design_Route_Moved_Go: ["B07 로", "Go to B07"],
Design_Route_Moved_Stay: ["머묾", "Stay"],
} as const satisfies Record<string, LocaleEntry>;
+69 -45
View File
@@ -27,7 +27,8 @@ export interface WorkflowLayoutOptions {
leftPanel?: HTMLElement;
mainContent: HTMLElement;
stages?: WorkflowStage[];
currentStage?: number;
/** 막힌(NOT_STARTED) 단계라도 켤 칸 — 수 하나 또는 목록(B05 · B06 = 2 · 3 · 4 · 496-1) */
currentStage?: number | readonly number[];
routes?: readonly string[];
onStepClick?: (stepIndex: number, route: string) => void;
}
@@ -39,11 +40,13 @@ export interface WorkflowLayoutHandle {
/** 한 페이지 두 보기(B05 · B06) — 보기가 바뀌면 제목 · 켜진 단계만 갈아 끼운다. */
setTitle: (title: string) => void;
setActiveStep: (stepIndex: number) => void;
/** [저장] · [>] 뒤 서버 단계 상태로 색 · 켬을 다시 맞춘다(496-1). */
setStages: (stages: WorkflowStage[]) => void;
}
export interface StepBarOptions {
stages?: WorkflowStage[];
currentStage?: number;
currentStage?: number | readonly number[];
routes?: readonly string[];
compact?: boolean;
orientation?: "horizontal" | "vertical";
@@ -53,6 +56,8 @@ export interface StepBarOptions {
homeButton?: boolean;
}
type StepBarElement = HTMLElement & { repaintSteps?: (stages: WorkflowStage[]) => void };
export const WORKFLOW_STEP_ICONS = ["📁", "🗺️", "🛣️", "📐", "∑", "🏗️", "📄"] as const;
export function createStepBar(
@@ -62,6 +67,9 @@ export function createStepBar(
): HTMLElement {
const bar = document.createElement("div");
bar.className = "ui-workflow-layout__steps";
// 단계 단추 다시 칠하기(`setStages`) — 단추 차례대로
const painters: Array<(stages: WorkflowStage[]) => void> = [];
(bar as StepBarElement).repaintSteps = (stages) => painters.forEach((paint) => paint(stages));
if (options?.compact) bar.classList.add("is-compact");
if (options?.orientation === "vertical") bar.classList.add("is-vertical");
// 진행단계 오버레이(세로형) 최상단에 대시보드로 돌아가는 버튼을 둔다(2026-08-04 사용자
@@ -101,59 +109,74 @@ export function createStepBar(
button.textContent = step;
}
const stage =
options?.stages?.find((item) => item.stage_no === index) ?? options?.stages?.[index];
const hasStages = Boolean(options?.stages?.length);
const isBlockedState = stage?.state === "NOT_STARTED" || stage?.state === "STALE";
// ⚠ 개발 전용 — 막힌 단계도 눌러서 들어간다 (2026-09-09 사용자 지시).
// B08 의 「확정 없이 다음으로」 단추가 **B08 화면 안에** 있어서, B07 에서 B08 로 못
// 넘어가면 그 단추에 닿을 길이 없었다(닫힌 문 안에 열쇠가 있는 꼴).
// ⚠ **잠금을 푸는 것이 아니라 가기만 한다** — 값이 없어 「미확보」로 뜨는 것이 정상이고,
// 실제 우회는 B08 의 그 단추가 서버(`common_util_dev_unlock`)로 한다.
// ⚠ **서버는 그대로 막혀 있다** — 운영에서는 `ENVIRONMENT` 로 거절한다.
const devBypass = import.meta.env.DEV;
const isEnabled =
devBypass || !hasStages || !isBlockedState || stage?.stage_no === options?.currentStage;
button.classList.toggle("is-enabled", isEnabled);
button.disabled = !isEnabled;
const currents = ([] as number[]).concat(options?.currentStage ?? []);
let adminOnly = false; // B04 = 시스템 관리자만
const paint = (stages: WorkflowStage[] | undefined): void => {
const stage = stages?.find((item) => item.stage_no === index) ?? stages?.[index];
const hasStages = Boolean(stages?.length);
// 막힘 = 시작 전만 — STALE(앞 단계 바뀜)은 색만 「다시 확인」 · 막지 않음(요구 300 B1)
const isBlockedState = stage?.state === "NOT_STARTED";
// ⚠ 개발 전용 — 막힌 단계도 눌러서 들어간다 (2026-09-09 사용자 지시).
// B08 의 「확정 없이 다음으로」 단추가 **B08 화면 안에** 있어서, B07 에서 B08 로 못
// 넘어가면 그 단추에 닿을 길이 없었다(닫힌 문 안에 열쇠가 있는 꼴).
// ⚠ **잠금을 푸는 것이 아니라 가기만 한다** — 값이 없어 「미확보」로 뜨는 것이 정상이고,
// 실제 우회는 B08 의 그 단추가 서버(`common_util_dev_unlock`)로 한다.
// ⚠ **서버는 그대로 막혀 있다** — 운영에서는 `ENVIRONMENT` 로 거절한다.
const devBypass = import.meta.env.DEV;
const isEnabled =
!adminOnly &&
(devBypass || !hasStages || !isBlockedState || currents.includes(stage?.stage_no ?? -1));
button.classList.toggle("is-enabled", isEnabled);
button.disabled = !isEnabled;
button.title = "";
// state 기반 스타일링 (표시 전용)
button.className
.split(/\s+/)
.filter((name) => name.startsWith("state-"))
.forEach((name) => button.classList.remove(name));
button.classList.remove("is-dev-bypass");
if (stage) {
const state = stage.state;
button.classList.add(`state-${state.toLowerCase()}`);
if (state === "STALE") {
button.title = t("WF_State_Stale");
} else if (state === "FAILED") {
button.title = t("WF_State_Failed");
} else if (state === "COMPLETE") {
button.title = t("WF_State_Complete");
} else if (state === "IN_PROGRESS") {
button.title = t("WF_State_InProgress");
} else {
button.title = t("WF_State_NotStarted");
}
}
// 개발 우회로 열어 둔 단계는 **그렇다고 말해 준다** — 조용히 열면 다음 사람이
// 「왜 값이 없나」로 헤맨다(B08 의 안내 줄과 같은 뜻).
if (devBypass && isBlockedState && !adminOnly) {
button.classList.add("is-dev-bypass");
button.title = `${button.title} (개발: 확정 전이라 값이 비어 보일 수 있습니다)`;
}
if (adminOnly) button.title = "시스템 관리자 전용 단계입니다.";
};
let painted = options?.stages;
paint(painted);
painters.push((stages) => paint((painted = stages)));
if (options?.routes?.[index] === ROUTES.B04_PREPROCESS) {
void getDashboardRole()
.then((role) => {
if (role !== "SYSTEM_ADMIN") {
button.classList.remove("is-enabled");
button.disabled = true;
button.title = "시스템 관리자 전용 단계입니다.";
adminOnly = true;
paint(painted);
}
})
.catch(() => undefined);
}
// state 기반 스타일링 (표시 전용)
if (stage) {
const state = stage.state;
button.classList.add(`state-${state.toLowerCase()}`);
if (state === "STALE") {
button.title = t("WF_State_Stale");
} else if (state === "FAILED") {
button.title = t("WF_State_Failed");
} else if (state === "COMPLETE") {
button.title = t("WF_State_Complete");
} else if (state === "IN_PROGRESS") {
button.title = t("WF_State_InProgress");
} else {
button.title = t("WF_State_NotStarted");
}
}
// 개발 우회로 열어 둔 단계는 **그렇다고 말해 준다** — 조용히 열면 다음 사람이
// 「왜 값이 없나」로 헤맨다(B08 의 안내 줄과 같은 뜻).
if (devBypass && isBlockedState) {
button.classList.add("is-dev-bypass");
button.title = `${button.title} (개발: 확정 전이라 값이 비어 보일 수 있습니다)`;
}
if (isEnabled && options?.routes?.[index]) {
// 꺼진 단추는 누름이 안 감 — 켬 · 끔이 뒤에 바뀌어도 듣개 하나
if (options?.routes?.[index]) {
button.addEventListener("click", () => {
options.onStepClick?.(index, options.routes![index]);
});
@@ -225,5 +248,6 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa
},
setActiveStep: (stepIndex) =>
steps.forEach((step, index) => step.classList.toggle("is-active", index === stepIndex)),
setStages: (stages) => (progressContent as StepBarElement).repaintSteps?.(stages),
};
}