- 좌측 패널 · 아래 단추 넷 · 종단도 · 구조물 배치 폼을 페이지에 한 번만 만듦(design_shared) — 두 보기가 같은 노드 - 보기는 가운데 위 칸만: 종단 보기 = 3D(design_view_profile) · 횡단 보기 = 횡단 카드(design_view_cross · 처음 보일 때 그림) - 옛 B05_Profile_UI_Page · B06_Section_UI_Page 삭제 · 한 벌이 받은 조작은 지금 보기로, 관 목록 · 횡단 재계산 알림은 두 보기 모두로 - 진행 단계 종단설계 · 횡단설계 두 칸이 보기를 따라 켜짐 · 누르면 주소만 바꿈 · 제목도 따라감(setTitle · setActiveStep) - [저장] · [확정] 한 벌 — 두 보기 몫을 모아 saveDesign 한 번 · 종단 보기 [확정] 켬(횡단 보기를 숨긴 채 세워 횡단 자료를 얻음) - [초기화] · 계획노선 편집은 같은 페이지라도 다시 세움(remountRoute) - 덧붙임: 방향키는 초점 있는 쪽만(조정창 ↔ 종단도 계획고) · 횡단 보기 계획고 편집에도 [3D 업데이트] 대기 표시 · 종단 보기 폼 관 옵션을 횡단 캐시에도 얹음 · 종단 보기에서 횡단 조정 칸 가림 - 나눔: Cross_View 756→658(_Cross_View_Frame) · Page_Structures_Panel 700→547(_Structures_Spec) · 옛 B05 Page 786 → design_view_profile 638(_Page_Latest · _Page_Stations) - 시험: test_design_view_parity 한 벌 기준(한 패널 · 보기 오가도 같은 노드) · page_single · profile_single · save_channels · save_recompute 등 옮긴 자리로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
211 lines
7.8 KiB
TypeScript
211 lines
7.8 KiB
TypeScript
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 { navigateTo } from "../A00_Common/router";
|
|
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
|
|
|
let dashboardRolePromise: Promise<string> | undefined;
|
|
|
|
function getDashboardRole(): Promise<string> {
|
|
dashboardRolePromise ??= fetchDashboardMe().then((user) => user.role);
|
|
return dashboardRolePromise;
|
|
}
|
|
|
|
export interface WorkflowStage {
|
|
stage_no: number;
|
|
stage_key: string;
|
|
state: string;
|
|
}
|
|
|
|
export interface WorkflowLayoutOptions {
|
|
title: string;
|
|
steps: string[];
|
|
activeStep: number;
|
|
leftPanel?: HTMLElement;
|
|
mainContent: HTMLElement;
|
|
stages?: WorkflowStage[];
|
|
currentStage?: number;
|
|
routes?: readonly string[];
|
|
onStepClick?: (stepIndex: number, route: string) => void;
|
|
}
|
|
|
|
export interface WorkflowLayoutHandle {
|
|
root: HTMLElement;
|
|
setOptionsOpen: (isOpen: boolean) => void;
|
|
setProgressOpen: (isOpen: boolean) => void;
|
|
/** 한 페이지 두 보기(B05 · B06) — 보기가 바뀌면 제목 · 켜진 단계만 갈아 끼운다. */
|
|
setTitle: (title: string) => void;
|
|
setActiveStep: (stepIndex: number) => void;
|
|
}
|
|
|
|
export interface StepBarOptions {
|
|
stages?: WorkflowStage[];
|
|
currentStage?: number;
|
|
routes?: readonly string[];
|
|
compact?: boolean;
|
|
orientation?: "horizontal" | "vertical";
|
|
icons?: readonly string[];
|
|
onStepClick?: (stepIndex: number, route: string) => void;
|
|
/** 목록 최상단에 대시보드 이동 버튼을 단다(진행단계 오버레이 전용). */
|
|
homeButton?: boolean;
|
|
}
|
|
|
|
export const WORKFLOW_STEP_ICONS = ["📁", "🗺️", "🛣️", "📐", "∑", "🏗️", "📄"] as const;
|
|
|
|
export function createStepBar(
|
|
steps: readonly string[],
|
|
activeStep: number,
|
|
options?: StepBarOptions,
|
|
): HTMLElement {
|
|
const bar = document.createElement("div");
|
|
bar.className = "ui-workflow-layout__steps";
|
|
if (options?.compact) bar.classList.add("is-compact");
|
|
if (options?.orientation === "vertical") bar.classList.add("is-vertical");
|
|
// 진행단계 오버레이(세로형) 최상단에 대시보드로 돌아가는 버튼을 둔다(2026-08-04 사용자
|
|
// 지시). 워크플로 단계가 아니라 탈출구라 stage 배지 없이 항상 활성이다.
|
|
if (options?.homeButton) {
|
|
const home = document.createElement("button");
|
|
home.type = "button";
|
|
home.className = "ui-workflow-layout__step is-enabled ui-workflow-layout__step--home";
|
|
const icon = document.createElement("span");
|
|
icon.className = "ui-workflow-layout__step-icon";
|
|
icon.setAttribute("aria-hidden", "true");
|
|
icon.textContent = "🏠";
|
|
const label = document.createElement("span");
|
|
label.className = "ui-workflow-layout__step-label";
|
|
label.textContent = t("Workflow_Step_Dashboard");
|
|
home.append(icon, label);
|
|
home.title = t("Workflow_Step_Dashboard");
|
|
home.addEventListener("click", () => navigateTo(ROUTES.B01_ACCOUNT));
|
|
bar.append(home);
|
|
}
|
|
steps.forEach((step, index) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "ui-workflow-layout__step";
|
|
if (index === activeStep) button.classList.add("is-active");
|
|
const iconText = options?.icons?.[index];
|
|
if (iconText) {
|
|
const icon = document.createElement("span");
|
|
icon.className = "ui-workflow-layout__step-icon";
|
|
icon.setAttribute("aria-hidden", "true");
|
|
icon.textContent = iconText;
|
|
const label = document.createElement("span");
|
|
label.className = "ui-workflow-layout__step-label";
|
|
label.textContent = step;
|
|
button.append(icon, label);
|
|
} else {
|
|
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;
|
|
|
|
if (index === 1) {
|
|
void getDashboardRole()
|
|
.then((role) => {
|
|
if (role !== "SYSTEM_ADMIN") {
|
|
button.classList.remove("is-enabled");
|
|
button.disabled = true;
|
|
button.title = "시스템 관리자 전용 단계입니다.";
|
|
}
|
|
})
|
|
.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]) {
|
|
button.addEventListener("click", () => {
|
|
options.onStepClick?.(index, options.routes![index]);
|
|
});
|
|
}
|
|
|
|
bar.append(button);
|
|
});
|
|
return bar;
|
|
}
|
|
|
|
export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLayoutHandle {
|
|
const root = document.createElement("div");
|
|
root.className = "ui-workflow-layout";
|
|
|
|
const body = document.createElement("div");
|
|
body.className = "ui-workflow-layout__body";
|
|
|
|
const main = document.createElement("main");
|
|
main.className = "ui-workflow-layout__main";
|
|
main.append(options.mainContent);
|
|
body.append(main);
|
|
|
|
const progressContent = createStepBar(options.steps, options.activeStep, {
|
|
stages: options.stages,
|
|
currentStage: options.currentStage,
|
|
routes: options.routes,
|
|
orientation: "vertical",
|
|
icons: WORKFLOW_STEP_ICONS,
|
|
onStepClick: options.onStepClick,
|
|
homeButton: true,
|
|
});
|
|
const overlays = createWorkflowOverlays({
|
|
title: options.title,
|
|
optionsContent: options.leftPanel,
|
|
progressContent,
|
|
onOptionsOpenChange: (isOpen) => root.classList.toggle("is-options-open", isOpen),
|
|
});
|
|
|
|
root.append(body, overlays.root);
|
|
const steps = [
|
|
...progressContent.querySelectorAll<HTMLElement>(
|
|
".ui-workflow-layout__step:not(.ui-workflow-layout__step--home)",
|
|
),
|
|
];
|
|
return {
|
|
root,
|
|
setOptionsOpen: overlays.setTitleOpen,
|
|
setProgressOpen: overlays.setProgressOpen,
|
|
setTitle: (title) => {
|
|
const heading = overlays.root.querySelector(".ui-workflow-overlay__title");
|
|
if (heading) heading.textContent = title;
|
|
},
|
|
setActiveStep: (stepIndex) =>
|
|
steps.forEach((step, index) => step.classList.toggle("is-active", index === stepIndex)),
|
|
};
|
|
}
|