Files
Aislo/ui_template/ui_template_workflow_layout.ts
T
eomsangdonandClaude Opus 5.5 76f74037af feat(499-1 좌측 패널 하이라이트): 닫힌 패널 안 엶 · 카드 폄 + 열 때 고른 줄 · 우클릭 [추가] 포장도 바로 · B06 평면 겹침 기준 main 전체 — 요구 298-4 · 298-6 · 300 E2 · E3 · 304 E1
- onReveal = 사용자 고름이면 「구조물 배치」 카드 폄만 · revealLeft 지움(부르는 열네 길 그대로)
- 좌측 패널 열림 알림(workflow_layout onOptionsOpenChange) → 고른 목록 줄 · 강조 상자로 한 번 밂
- addsAtOnce = 등록부 기본값 뒤 필수 빈칸만 봄 · 포장 폼만 갈래 지움(물넘이 걸침 = 확인창 그대로)
- design_page.css 횡단 보기 가운데 칸 position: static · 평면 z 4 그대로
- 시험 398_1 뒤집음 · 116 · 126 포장 바로 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:25:44 +09:00

259 lines
11 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 { 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";
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[];
/** 막힌(NOT_STARTED) 단계라도 켤 칸 — 수 하나 또는 목록(B05 · B06 = 2 · 3 · 4 · 496-1) */
currentStage?: number | readonly number[];
routes?: readonly string[];
onStepClick?: (stepIndex: number, route: string) => void;
/** 좌측 패널이 열리고 닫힐 때(처음 앉힘 포함) — 닫힌 동안 바뀐 안쪽을 열 때 맞춤(499-1). */
onOptionsOpenChange?: (isOpen: boolean) => void;
}
export interface WorkflowLayoutHandle {
root: HTMLElement;
setOptionsOpen: (isOpen: boolean) => void;
setProgressOpen: (isOpen: boolean) => void;
/** 한 페이지 두 보기(B05 · B06) — 보기가 바뀌면 제목 · 켜진 단계만 갈아 끼운다. */
setTitle: (title: string) => void;
setActiveStep: (stepIndex: number) => void;
/** [저장] · [>] 뒤 서버 단계 상태로 색 · 켬을 다시 맞춘다(496-1). */
setStages: (stages: WorkflowStage[]) => void;
}
export interface StepBarOptions {
stages?: WorkflowStage[];
currentStage?: number | readonly number[];
routes?: readonly string[];
compact?: boolean;
orientation?: "horizontal" | "vertical";
icons?: readonly string[];
onStepClick?: (stepIndex: number, route: string) => void;
/** 목록 최상단에 대시보드 이동 버튼을 단다(진행단계 오버레이 전용). */
homeButton?: boolean;
}
type StepBarElement = HTMLElement & { repaintSteps?: (stages: WorkflowStage[]) => void };
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";
// 단계 단추 다시 칠하기(`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 사용자
// 지시). 워크플로 단계가 아니라 탈출구라 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 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") {
adminOnly = true;
paint(painted);
}
})
.catch(() => undefined);
}
// 꺼진 단추는 누름이 안 감 — 켬 · 끔이 뒤에 바뀌어도 듣개 하나
if (options?.routes?.[index]) {
button.addEventListener("click", () => {
options.onStepClick?.(index, options.routes![index]);
});
}
bar.append(button);
});
return bar;
}
function helpOnlyRow(): HTMLElement {
const row = document.createElement("div");
row.className = "ui-sidebar-actions";
return row;
}
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,
// 좌측 패널이 없는 화면(B08 · B09)도 [?] 줄은 둠(54-2)
optionsContent: options.leftPanel ?? helpOnlyRow(),
progressContent,
onOptionsOpenChange: (isOpen) => {
root.classList.toggle("is-options-open", isOpen);
options.onOptionsOpenChange?.(isOpen);
},
});
root.append(body, overlays.root);
// ☰ 서랍 진행 단계 묶음(6-2) — 같은 단계 목록을 서랍이 열릴 때 빌려 감(그리기 · 누름 한 벌)
setDrawerSteps(progressContent);
const steps = [
...progressContent.querySelectorAll<HTMLElement>(
".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,
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)),
setStages: (stages) => (progressContent as StepBarElement).repaintSteps?.(stages),
};
}