Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-10-06 10:18:42 +09:00
49 changed files with 622 additions and 242 deletions
+1
View File
@@ -65,6 +65,7 @@ export function createDesignDock(
divider.className = "b05-structure__divider";
const row = document.createElement("div");
row.className = view === "profile" ? "b05-route__actions" : "b06-profile__actions";
row.dataset.helpRow = "";
const press = (name: keyof DesignDockActions) => (): void => {
void Promise.resolve(actions[name].run());
+3 -1
View File
@@ -20,6 +20,7 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createHelpButton } from "@ui/ui_template_help";
import { isBlank } from "@util/common_util_validate";
import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name";
@@ -299,7 +300,8 @@ export function renderB02ProjRegister(root: HTMLElement): void {
// 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다.
const actions = document.createElement("div");
actions.className = "ui-general-block__actions b02-proj__actions";
actions.append(cancelBtn, submitBtn);
// 좌측 패널이 없어 [?](54-2)는 아래 단추 줄 맨 앞
actions.append(createHelpButton(), cancelBtn, submitBtn);
const card = createCard({ body: [grid, actions], raised: true });
const layout = createGeneralLayout({
pageClass: "b02-proj",
+8 -67
View File
@@ -1,77 +1,18 @@
/* =============================================================================
* B03_FileInput_UI_Guide.ts
* 파일 입력 좌측 안내 패널 (2026-09-03 사용자 지시).
* 파일 입력 좌측 패널 (2026-09-03 사용자 지시).
*
* 종전에는 고르는 방법·필요한 파일 목록이 **선택 영역과 카드 안에** 길게 들어가 있어
* 카드 한 장이 220px씩 차지했다. 안내는 한 번 읽으면 되는 것이라 다른 단계(B04·B05)와
* 같은 자리 — 공용 오버레이의 좌측 패널(`createWorkflowOverlays.optionsContent`) — 로
* 옮기고, 본문은 고르는 자리만 남긴다.
*
* 패널 양식은 **B04~B07과 같은 공용 양식**을 그대로 쓴다(2026-09-03 사용자 지시):
* 패널 루트 `{page}__form`, 문단은 `ui-collapsible ui-sidebar-section` + 제목에
* `ui-collapsible__title`. 제목 행을 누르면 접히는 동작·캐럿·외곽선이 전부 공용 것이다.
* 안내 글(필요한 파일 · 고르는 방법 · 알아 둘 것)은 [?] 설명서 `resources/help/B03/*.md` 로
* 옮김(54-2). 패널에는 현황 배너와 아래 단추 줄([?] 자리)만 남김.
* ========================================================================== */
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 안내 문단 하나 — 공용 접기 컨테이너(B06 `buildGroup`과 같은 조립). */
function section(
titleKey: keyof typeof ui_locales,
itemKeys: (keyof typeof ui_locales)[],
): HTMLElement {
const group = document.createElement("section");
group.className = "b03-file__guide-group ui-collapsible ui-sidebar-section";
const title = document.createElement("h3");
title.className = "b03-file__guide-legend ui-collapsible__title";
title.textContent = L(titleKey);
const list = document.createElement("ul");
list.className = "b03-file__guide-list";
for (const key of itemKeys) {
const item = document.createElement("li");
item.textContent = L(key);
list.append(item);
}
group.append(title, list);
return group;
}
/**
* 좌측 패널 본문 — 필요한 파일 · 고르는 방법 · 알아 둘 것.
*
* `statusNote`(재접속 현황 배너)는 「고르는 방법」 끝에 붙는다 — 프로젝트가 이미 완료라
* 다시 올리면 교체된다는 안내라서, 고르기 전에 읽을 것들과 같은 자리에 둔다
* (2026-09-03 사용자 지시).
*/
/** 좌측 패널 본문 — `statusNote`(재접속 현황 배너) + 아래 단추 줄(공용 [?] 가 붙음). */
export function createInputGuide(statusNote?: HTMLElement): HTMLElement {
const guide = document.createElement("div");
guide.className = "b03-file__form";
const howTo = section("B03_Guide_How_Title", [
"B03_Guide_How_Drop",
"B03_Guide_How_Card",
"B03_Guide_How_Temp",
]);
if (statusNote) howTo.append(statusNote);
guide.append(
section("B03_Guide_Files_Title", [
"B03_Guide_Files_Route",
"B03_Guide_Files_Terrain",
"B03_Guide_Files_Optional",
]),
howTo,
section("B03_Guide_Notes_Title", [
"B03_Guide_Notes_Replace",
"B03_Guide_Notes_Crs",
"B03_Guide_Notes_LasFree",
]),
);
attachCollapsible(guide);
const actions = document.createElement("div");
actions.className = "ui-sidebar-actions";
if (statusNote) guide.append(statusNote);
guide.append(actions);
return guide;
}
+1 -34
View File
@@ -310,10 +310,7 @@
}
}
/* --- 좌측 안내 패널 (2026-09-03) — 본문에서 뺀 설명이 사는 자리.
양식은 B04~B07 좌측 패널과 **같은 공용 양식**이다: 루트 `__form`, 문단은
`ui-collapsible ui-sidebar-section`(외곽선·접힘·캐럿은 공용 CSS 몫), 제목은
`__group-legend` 크기. 여기서는 값만 맞추고 새로 만들지 않는다. --- */
/* --- 좌측 패널 — 현황 배너 + 아래 단추 줄(안내 글은 [?] 설명서로 옮김 · 54-2) --- */
.b03-file__form {
display: flex;
flex-direction: column;
@@ -321,36 +318,6 @@
padding: var(--spacing-16);
}
.b03-file__guide-group {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
margin: 0;
padding: var(--spacing-16);
border-radius: var(--radius-cards);
background-color: var(--color-surface-raised);
}
.b03-file__guide-legend {
margin: 0;
padding: 0 var(--spacing-8);
font-size: var(--text-caption);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
/* 글자 크기·색도 공용 양식 그대로 — B04 좌측 패널 본문(`b04-surface__check`)과 같은
`--text-body-sm` · `--color-text-body`(2026-09-03 사용자 지시: 양식 = 글자 크기 포함). */
.b03-file__guide-list {
margin: 0;
padding-left: var(--spacing-16);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
font-size: var(--text-body-sm);
color: var(--color-text-body);
}
.b03-file__group {
display: flex;
flex-direction: column;
@@ -373,16 +373,11 @@ export function createFacilityOptionsForm(
// 옛 월류 폭 · 높이 · 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
const paveForm = createSpecForm(specLinks, { onChange: () => emit() });
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
const saveNote = document.createElement("p");
saveNote.className = "b05-structure__owner-note";
saveNote.textContent =
"[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀";
// 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`.
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
saveNote,
pipeForm.root,
fordForm.root,
paveForm.root,
@@ -9,7 +9,6 @@
* ========================================================================== */
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json";
/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */
@@ -245,39 +244,18 @@ export interface RevetSideGroup {
slot: HTMLElement;
}
/** 독립 기슭막이 치수 근거 줄 — `revetment_sabang_*.json` 한 벌(B08 사유와 같은 파일 · 쪽·줄 번호).
* ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험(`test_b05_revetment_sabang_basis`)이 최신 판을 대조. */
function sabangBasisLines(): string[] {
const { items, scope } = revetmentSabang;
const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`;
return [
`높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`,
`계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`,
`둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`,
`뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`,
scope,
];
}
export function createRevetSideGroup(keys: RevetmentKeys): RevetSideGroup {
const box = group("기슭막이");
const kind = document.createElement("select");
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
// 높이는 기본값 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정).
const fields = createRevetmentFields(keys, { height: "" });
const basis = document.createElement("p");
basis.className = "b05-route__note";
basis.replaceChildren(
...sabangBasisLines().flatMap((line, index) =>
index ? [document.createElement("br"), line] : [line],
),
);
// 치수 근거 글(사방기술교본)은 [?] 설명서 `구조물_기슭막이.md` 로 옮김(54-2)
const slot = document.createElement("div");
slot.className = "b05-structure__adjust-slot";
box.body.append(
grid(labeled("구조", kind), fields.formField),
...fields.rows,
basis,
// 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과
// 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자).
slot,
@@ -9,23 +9,13 @@
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station";
export function field(
labelText: string,
input: HTMLElement,
basis?: string | null,
): HTMLLabelElement {
export function field(labelText: string, input: HTMLElement): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = labelText;
// 칸 밑 근거 글(`default_basis`)은 [?] 설명서 `구조물_<종류>.md` 로 옮김(54-2)
wrapper.append(caption, input);
// 칸 밑 근거 한 줄 — 등록부 `default_basis`(기본값의 뜻 · 10-A 2026-09-14 사용자 확정).
if (basis) {
const note = document.createElement("small");
note.className = "b05-structure__basis";
note.textContent = basis;
wrapper.append(note);
}
return wrapper;
}
@@ -35,8 +35,6 @@ export interface StructuresFormElements {
body: HTMLElement;
groupSelect: HTMLSelectElement;
typeSelect: HTMLSelectElement;
/** 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — `design_owner` 가 있을 때만 보인다. */
ownerNote: HTMLElement;
startFields: StationFields;
anchorFields: StationFields;
endFields: StationFields;
@@ -113,13 +111,6 @@ export function buildStructuresForm(options: {
typeRow.className = "b05-structure__grid";
typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect));
// 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — 목록 항목은 [측점][이름]만 적는 규칙이라
// (2026-08-18 사용자 지시) 표시는 이 폼에 둔다. 측구(옆도랑)가 그 경우다(2026-09-07 사용자:
// 「두되 표시만 해줘」). 문구는 레지스트리 `design_owner` 값으로 만든다.
const ownerNote = document.createElement("p");
ownerNote.className = "b05-structure__owner-note";
ownerNote.hidden = true;
// 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다.
const optionRow = document.createElement("div");
optionRow.className = "b05-structure__grid";
@@ -178,7 +169,6 @@ export function buildStructuresForm(options: {
body.append(
typeRow,
ownerNote,
positionRow,
summary.root, // 종류 단위 「구조물 조합」 잇기 — 기준 측점 바로 아래(PLAN 52-1 · 컨테이너 없는 종류)
positionDivider,
@@ -195,7 +185,6 @@ export function buildStructuresForm(options: {
body,
groupSelect,
typeSelect,
ownerNote,
startFields,
anchorFields,
endFields,
@@ -78,7 +78,6 @@ export function createStructuresSection(
body,
groupSelect,
typeSelect,
ownerNote,
startFields,
anchorFields,
endFields,
@@ -233,12 +232,8 @@ export function createStructuresSection(
startFields.wrap.hidden = !isInterval;
endFields.wrap.hidden = !isInterval;
primary.disabled = !type;
// 제원·수량 주인이 다른 화면인 타입은 그 사실을 폼에 적는다 — 목록에서 안 보이면
// 「측구가 왜 없지」로 헤매고, 그렇다고 수량에 넣으면 이중 계상이다(2026-09-07 사용자).
ownerNote.textContent = type?.design_owner
? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.`
: "";
ownerNote.hidden = !type?.design_owner;
// [?] 설명서가 먼저 여는 항목 = `구조물_<종류 이름>.md`(54-2 · 종류 안내 글은 그 파일로 옮김)
root.dataset.helpTopic = type ? `구조물_${type.name}` : "";
// 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임
anchorFields.wrap.hidden = isInterval && !showsAnchor(type!);
anchorFields.wrap.querySelector("span")!.textContent = isInterval
@@ -101,11 +101,7 @@ export function renderOptionFields(
// 정의 그룹 상자로 옮겨 갈 숫자 칸 = 명세 컨테이너와 같은 [-][숫자][+](52-18)
const grouped =
option.input === "number" && !!type.groups?.some((g) => g.options.includes(option.key));
const wrap = field(
label,
grouped ? stepper(input as HTMLInputElement, 0.1) : input,
option.default_basis,
);
const wrap = field(label, grouped ? stepper(input as HTMLInputElement, 0.1) : input);
if (grouped) wrap.htmlFor = input.id; // 라벨 대상 = 숫자 칸(첫 버튼으로 새지 않게)
wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3)
// 배치 격자(48-3) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로
@@ -215,13 +215,6 @@
line-height: 1.5;
}
/* 칸 밑 근거 한 줄 — 등록부 `default_basis`(10-A). 회색 작은 글씨. */
.b05-structure__basis {
color: var(--color-text-muted, #9aa1ad);
font-size: var(--text-caption, 12px);
line-height: 1.3;
}
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
* (2026-08-17 사용자 지시 2). */
.b05-structure__position-row {
@@ -118,10 +118,7 @@ export function buildStandardDiagram(): HTMLElement {
text(252, 196, L("B06_Std_Diagram_Fill"), "b06-diag__label-sm", "middle"),
);
// 모식도 설명 글은 [?] 설명서 `B06/표준_횡단면.md` 로 옮김(54-2)
wrap.append(svg);
const caption = document.createElement("p");
caption.className = "b06-std__diagram-caption";
caption.textContent = L("B06_Std_Diagram_Caption");
wrap.append(caption);
return wrap;
}
+1 -4
View File
@@ -276,6 +276,7 @@ export function createStandardPanel(
const root = document.createElement("div");
root.className = "b06-std";
root.dataset.helpTopic = "표준_횡단면"; // [?] 설명서 항목(54-2 · 이 상자 안내 글은 그 파일로 옮김)
// 변수 위치 안내 모식도(고정 도형): 각 설정값이 횡단면 어느 위치인지 표시(작업 C-5).
root.append(buildStandardDiagram());
@@ -367,9 +368,6 @@ export function createStandardPanel(
const legend = document.createElement("summary");
legend.className = "b06-std__legend";
legend.textContent = L("B06_Std_Detail_Title");
const note = document.createElement("p");
note.className = "b06-std__note";
note.textContent = L("B06_Std_LType_Note");
fieldset.append(
legend,
buildScope("common"),
@@ -377,7 +375,6 @@ export function createStandardPanel(
buildScope("rock"),
buildDivider("B06_Std_Section_PavedOnly"),
buildScope("paved"),
note,
);
return fieldset;
};
-12
View File
@@ -173,12 +173,6 @@
margin-top: var(--spacing-8);
}
.b06-std__diagram-caption {
margin: var(--spacing-8) 0 0;
font-size: var(--text-caption);
color: var(--color-text-muted);
}
/* ─── 다른 프로젝트에서 불러오기 (작업 C-6) ───────────────────────── */
.b06-std__loader {
display: flex;
@@ -307,12 +301,6 @@
color: var(--color-text-secondary);
}
.b06-std__note {
margin: 0;
font-size: var(--text-caption);
color: var(--color-text-muted);
}
.b06-std__actions {
display: flex;
gap: var(--spacing-8);
@@ -430,6 +430,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김).
const confirmButtonRow = document.createElement("div");
confirmButtonRow.className = "b07-drawing-actions__row";
confirmButtonRow.dataset.helpRow = "";
confirmButtonRow.append(frameEditor.button, confirmButton);
confirmActions.append(frameEditor.banner, confirmButtonRow);
+34
View File
@@ -0,0 +1,34 @@
"""[?] 설명서 글(54-2) — `resources/help/<페이지>/*.md` 를 부를 때마다 읽어 줌.
사용자가 메모장으로 파일을 고치면 화면 고침 없이 다음에 열 때 바뀐 글이 보임.
파일 이름 순서 = 모달 왼쪽 차례 순서 · 제목 = 첫 `# ` 줄(없으면 파일 이름).
"""
from __future__ import annotations
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/api/help", tags=["help"])
HELP_ROOT = Path(__file__).resolve().parent.parent / "resources" / "help"
PAGE_RE = re.compile(r"^B0[2-9]$")
def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]:
if not PAGE_RE.match(page):
raise HTTPException(status_code=404, detail="없는 페이지")
folder = root / page
items = []
for path in sorted(folder.glob("*.md")) if folder.is_dir() else []:
text = path.read_text(encoding="utf-8-sig")
head = next((line[2:].strip() for line in text.splitlines() if line.startswith("# ")), "")
items.append({"key": path.stem, "title": head or path.stem, "text": text})
return items
@router.get("/{page}")
async def get_help(page: str) -> dict[str, list[dict[str, str]]]:
return {"items": read_help(page)}
+3
View File
@@ -81,6 +81,7 @@ from common_util.common_util_auth import (
# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가
# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조).
from common_util.common_util_dev_unlock_router import router as dev_unlock_router
from common_util.common_util_help_router import router as help_router
from common_util.common_util_resource_monitor import sample_resources_loop
from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop
from config.config_db import close_db_pool, get_db_pool, init_db_pool
@@ -661,6 +662,8 @@ app.include_router(m02_project_items_router, dependencies=protected)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
# 그 위에 서버가 환경까지 한 번 더 본다.
app.include_router(dev_unlock_router, dependencies=protected_with_company)
# [?] 설명서 글(54-2) — 로그인만 · 파일을 부를 때마다 읽음.
app.include_router(help_router, dependencies=protected)
# ─────────────────────────────────────────────────────────────────────────
+9
View File
@@ -0,0 +1,9 @@
# 프로젝트 등록 설명서
## 이 화면에서 하는 일
## 작업 순서
## 단추
## 주의할 점
+9
View File
@@ -0,0 +1,9 @@
# 파일입력 설명서
## 이 화면에서 하는 일
## 작업 순서
## 단추
## 주의할 점
@@ -0,0 +1,5 @@
# 필요한 파일
- 계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.
- 지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW.
- 지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다.
@@ -0,0 +1,5 @@
# 고르는 방법
- 여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다.
- 카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다.
- [임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다.
+5
View File
@@ -0,0 +1,5 @@
# 알아 둘 것
- 이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다.
- 작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다.
- 라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다.
+9
View File
@@ -0,0 +1,9 @@
# 전처리 설명서
## 이 화면에서 하는 일
## 작업 순서
## 단추
## 주의할 점
+9
View File
@@ -0,0 +1,9 @@
# 종단설계 설명서
## 이 화면에서 하는 일
## 작업 순서
## 단추
## 주의할 점
@@ -0,0 +1,5 @@
# 구조물 · BOX암거
## 저장
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
@@ -0,0 +1,13 @@
# 구조물 · 기슭막이
## 저장
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
## 치수 근거
- 높이 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 설계자 입력 · 비우면 미확정(금액에 안 들어감)
- 계획비탈 1:0.3~0.5(사방기술교본 2-나:129 · 3-가:151) — 제안값은 품셈 표준경사
- 둑마루 두께 0.3~0.5m(사방기술교본 2-나:143 · 3-가:201 · 3-가:219(콘크리트기슭막이) · 2-나:135(콘크리트 0.3m)) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식
- 뒷채움 조약돌 두께 — 사방기술교본 2-나:133(「사방품셈 규정에 의하여」) → 품셈 13-4-4 [주]⑨ 원문 L7164~7171
- ⚠ 사방(계류) 교본 기준 — 임도 배수관 유입·유출부 적용은 확인 남음
@@ -0,0 +1,6 @@
# 구조물 · 돌쌓기(메)
## 칸 기본값의 뜻
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜
@@ -0,0 +1,6 @@
# 구조물 · 돌쌓기(찰)
## 칸 기본값의 뜻
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜
@@ -0,0 +1,5 @@
# 구조물 · 물넘이포장
## 저장
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
+5
View File
@@ -0,0 +1,5 @@
# 구조물 · 배관
## 저장
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
@@ -0,0 +1,5 @@
# 구조물 · 비탈면 녹화
## 칸 기본값의 뜻
- **공법**(횡단설계) — 「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)
@@ -0,0 +1,5 @@
# 구조물 · 세월교
## 저장
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
+6
View File
@@ -0,0 +1,6 @@
# 구조물 · 옹벽
## 칸 기본값의 뜻
- **높이** — 기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
@@ -0,0 +1,3 @@
# 구조물 · 측구(옆도랑)
- 횡단 설계에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.
@@ -0,0 +1,6 @@
# 구조물 · 큰돌쌓기
## 칸 기본값의 뜻
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜
@@ -0,0 +1,9 @@
# 구조물 · 흙막이
## 칸 기본값의 뜻
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
- **단 수(다단)** — 1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)
- **기준 올림(사면 위로)** — 0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)
- **기준 좌우 이동** — 0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)
- **조달**(횡단설계) — 「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음
+9
View File
@@ -0,0 +1,9 @@
# 횡단설계 설명서
## 이 화면에서 하는 일
## 작업 순서
## 단추
## 주의할 점
+9
View File
@@ -0,0 +1,9 @@
# 표준 횡단면 설정
## 변수 위치 안내
- 표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님).
## 표준횡단면 상세값
- L형 측구는 각 횡단면도에서 선택합니다.
+9
View File
@@ -0,0 +1,9 @@
# 상세설계 설명서
## 이 화면에서 하는 일
## 작업 순서
## 단추
## 주의할 점
+9
View File
@@ -0,0 +1,9 @@
# 수량산출 설명서
## 이 화면에서 하는 일
## 작업 순서
## 단추
## 주의할 점
+9
View File
@@ -0,0 +1,9 @@
# 원가계산 설명서
## 이 화면에서 하는 일
## 작업 순서
## 단추
## 주의할 점
+38
View File
@@ -0,0 +1,38 @@
"""54-2 [?] 설명서 — 서버가 resources/help/<페이지>/*.md 를 부를 때마다 읽음(고치면 바로 바뀜)."""
import tempfile
from pathlib import Path
import pytest
from fastapi import HTTPException
from common_util.common_util_help_router import HELP_ROOT, read_help
def test_every_page_has_manual_first():
for n in range(2, 10):
items = read_help(f"B0{n}")
assert items and items[0]["key"] == "00_설명서"
def test_title_order_and_live_edit():
tmp_path = Path(tempfile.mkdtemp())
page = tmp_path / "B05"
page.mkdir()
(page / "10_배관.md").write_text("# 배관\n\n본문", encoding="utf-8")
(page / "00_설명서.md").write_text("제목 없음", encoding="utf-8")
items = read_help("B05", tmp_path)
assert [i["key"] for i in items] == ["00_설명서", "10_배관"]
assert [i["title"] for i in items] == ["00_설명서", "배관"]
(page / "10_배관.md").write_text("# 배관\n\n고친 글", encoding="utf-8")
assert "고친 글" in read_help("B05", tmp_path)[1]["text"]
@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05"])
def test_bad_page_refused(page):
with pytest.raises(HTTPException):
read_help(page)
def test_root_is_repo_resources():
assert HELP_ROOT.parts[-2:] == ("resources", "help")
@@ -0,0 +1,114 @@
/* 54-2 — [?] 설명서: B02~B09 좌측 패널 아래 단추 묶음 맨 왼쪽 · 모달 = 왼쪽 차례 · 오른쪽 파일 글 ·
* 구조물 종류를 고르면 그 항목 먼저 · 패널 안 설명 글 0
* 시작 화면: 아무 B 화면(로그인 · 환경 배정 storage 프로젝트가 현재 프로젝트)
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) · 1분 넘으면 끊기므로
* `window.__help542 = ["b02-proj-register", …];` 를 앞에 붙여 네 화면씩 두 번
* 쓰는 것: 없음(주소 이동 · 누르기 · 모달 닫기 · 끝나면 처음 주소로)
* 파일 고침 반영: 셸이 `resources/help/B04/99_임시.md` 를 만들고(글 A) 부름 → 글 B 로 고치고 다시 부름 →
* `temp` 가 A → B 로 바뀌면 통과 · 뒤에 파일 지움(없으면 temp = null)
* 기대 수치:
* - pages 8 · 줄마다 btn = 1 · first = true(묶음 맨 왼쪽) · inLeft = true(B02 는 아래 단추 줄) ·
* modal = true · toc[0] = 「… 설명서」 · text = 그 설명서 제목 글
* - B03 toc = 설명서 + 필요한 파일 · 고르는 방법 · 알아 둘 것 · B06 toc 에 「표준 횡단면 설정」
* - pipeTopic = 「구조물 · 배관」(B05 에서 배관을 고른 채 [?]) · leftovers = 0(옮긴 글이 패널에 없음)
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 15000) => {
for (let t = 0; t < ms; t += 200) {
const v = fn();
if (v) return v;
await w(200);
}
return null;
};
const MOVED = [
"[저장]은 이 폼에 있는 칸만",
"에서 관리 — 여기서 넣어도",
"사방기술교본",
"표준 편절편성 단면 모식도",
"L형 측구는 각 횡단면도에서",
"끌어 놓으면 확장자로",
"shapefile 한 벌",
"작업 좌표계는 라이다 PRJ",
"칸이 생기기 전의 동작",
"소광리 도면 H=2.0",
];
const start = location.hash;
const vis = (n) => n && n.offsetParent !== null;
const leftText = () =>
document.querySelector(".ui-workflow-overlay__panel--title")?.innerText ?? "";
const openModal = async (btn) => {
btn.click();
const box = await until(() => document.querySelector(".ui-help .ui-help__text h2"));
const res = {
modal: !!box,
toc: [...document.querySelectorAll(".ui-help__link")].map((b) => b.innerText.trim()),
text: box?.innerText ?? null,
active: document.querySelector(".ui-help__text")?.dataset.helpKey ?? null,
};
const temp = [...document.querySelectorAll(".ui-help__link")].find((b) =>
b.dataset.helpKey?.startsWith("99_"),
);
if (temp) {
temp.click();
res.temp = document.querySelector(".ui-help__text p")?.innerText ?? null;
}
document.querySelector(".ui-help .ui-modal__top button")?.click();
await w(200);
res.closed = !document.querySelector(".ui-help");
return res;
};
const out = { pages: [] };
// orca eval 은 한 번에 1분쯤에서 끊김 — 앞에 `window.__help542 = ["b05-profile", …];` 를 붙이면 그 화면만
const routes = window.__help542 ?? [
"b02-proj-register",
"b03-file-input",
"b04-preprocess",
"b05-profile",
"b06-section",
"b07-design-detail",
"b08-quantity",
"b09-estimation",
];
let prev = null;
for (const route of routes) {
location.hash = `#/${route}`;
await w(3000); // 불러오는 동안의 임시 틀(B05 등)이 실제 화면으로 바뀔 때까지
// 앞 화면 단추가 빠지고 이 화면 단추가 설 때까지
const btn = await until(() =>
[...document.querySelectorAll(".ui-help-btn")].find((b) => vis(b) && b !== prev),
);
prev = btn;
await w(1500);
const row = btn?.parentElement;
const res = {
page: route.slice(0, 3).toUpperCase(),
btn: document.querySelectorAll(".ui-help-btn").length,
first: !!row && row.firstElementChild === btn,
inLeft: !!btn?.closest(".ui-sidebar-actions") || !!btn?.closest(".b02-proj__actions"),
leftovers: MOVED.filter((s) => leftText().includes(s)),
};
if (btn) Object.assign(res, await openModal(btn));
if (route === "b05-profile") {
// 배관 하나를 골라 [?] — 구조물 종류 항목이 먼저 열림(이미 골라져 있으면 그대로 · 끝나면 처음 상태로)
const picked = () => document.querySelector('[data-help-topic="구조물_배관"]');
const was = !!picked();
const item = await until(() =>
[...document.querySelectorAll(".b05-route__irregular-item")].find((li) =>
li.innerText.includes("배관"),
),
);
if (!was) item?.click();
await until(picked, 8000);
await w(800);
res.leftoversPipe = MOVED.filter((s) => leftText().includes(s));
const again = await openModal([...document.querySelectorAll(".ui-help-btn")].find(vis));
res.pipeTopic = again.text;
if (!was) item?.click(); // 고름 풂
}
out.pages.push(res);
}
location.hash = start;
return JSON.stringify(out);
})();
+84
View File
@@ -0,0 +1,84 @@
/* [?] 설명서(54-2) — 단추는 글자 폭만 · 모달은 왼쪽 차례 · 오른쪽 글 */
.ui-sidebar-actions .ui-help-btn,
.ui-help-btn {
flex: none;
width: auto;
min-width: 2.25rem;
padding-inline: var(--spacing-12);
}
.ui-help.ui-modal {
width: min(960px, 92vw);
height: var(--modal-max, 80vh);
}
.ui-help .ui-modal__body {
flex: 1 1 auto;
min-height: 0;
}
.ui-help__grid {
display: grid;
grid-template-columns: 200px 1fr;
gap: var(--spacing-16);
height: 100%;
min-height: 0;
}
.ui-help__toc {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding-right: var(--spacing-8);
border-right: 1px solid var(--color-border);
overflow-y: auto;
}
.ui-help__link {
padding: var(--spacing-4) var(--spacing-8);
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
font: inherit;
text-align: left;
cursor: pointer;
}
.ui-help__link:hover,
.ui-help__link.is-active {
background: color-mix(in srgb, var(--color-border) 60%, transparent);
color: var(--color-text-body);
}
.ui-help__text {
overflow-y: auto;
line-height: 1.6;
}
.ui-help__text h2 {
margin: 0 0 var(--spacing-12);
font-size: 1.4em;
}
.ui-help__text h3 {
margin: var(--spacing-16) 0 var(--spacing-4);
font-size: 1.1em;
}
.ui-help__text h4,
.ui-help__text h5 {
margin: var(--spacing-12) 0 var(--spacing-4);
font-size: 1em;
}
.ui-help__text p,
.ui-help__text ul,
.ui-help__text ol {
margin: 0 0 var(--spacing-8);
}
.ui-help__text ul,
.ui-help__text ol {
padding-left: 1.4em;
}
+146
View File
@@ -0,0 +1,146 @@
/* =============================================================================
* ui_template_help.ts
* [?] 설명서(54-2) — 좌측 패널 아래 단추 묶음 맨 왼쪽 단추 + 모달(왼쪽 차례 · 오른쪽 글). B02~B09 공용.
* 글 = `resources/help/<페이지>/*.md` — 서버가 열 때마다 읽음(파일만 고치면 다음에 열 때 바뀜).
* 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막
* (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서).
* 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞.
* ========================================================================== */
import "./ui_template_help.css";
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, el } from "./ui_template_elements";
import { openModal } from "./ui_template_modal";
interface HelpItem {
key: string;
title: string;
text: string;
}
const TEXT = { button: "?", tip: "설명서", title: "설명서", close: "닫기", empty: "설명 글 없음" };
function currentPage(): string {
const n = /^#\/b0(\d)/i.exec(location.hash)?.[1];
return n ? `B0${n}` : "";
}
function escapeHtml(text: string): string {
return text.replace(
/[&<>"]/g,
(c) => `&${{ "&": "amp", "<": "lt", ">": "gt", '"': "quot" }[c]};`,
);
}
function inline(text: string): string {
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/`(.+?)`/g, "<code>$1</code>");
}
/** 메모장 글에 쓸 만큼만 — 제목(#) · 목록(- · 1.) · 문단 · **굵게** · `코드` */
export function renderHelpMarkdown(text: string): string {
const out: string[] = [];
let list: "ul" | "ol" | null = null;
let para: string[] = [];
const flush = (): void => {
if (para.length) out.push(`<p>${para.map(inline).join("<br>")}</p>`);
para = [];
if (list) out.push(`</${list}>`);
list = null;
};
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
const head = /^(#{1,4})\s+(.*)$/.exec(line);
const item = /^(?:([-*])|\d+\.)\s+(.*)$/.exec(line);
if (!line) flush();
else if (head) {
flush();
out.push(`<h${head[1].length + 1}>${inline(head[2])}</h${head[1].length + 1}>`);
} else if (item) {
const kind = item[1] ? "ul" : "ol";
if (para.length || list !== kind) flush();
if (!list) out.push(`<${kind}>`);
list = kind;
out.push(`<li>${inline(item[2])}</li>`);
} else {
if (list) flush();
para.push(line);
}
}
flush();
return out.join("");
}
function visibleTopics(): string[] {
const panel = document.querySelector(".ui-workflow-overlay__panel--title") ?? document;
return [...panel.querySelectorAll<HTMLElement>("[data-help-topic]")]
.filter((node) => node.offsetParent !== null)
.map((node) => node.dataset.helpTopic ?? "");
}
async function fetchItems(page: string): Promise<HelpItem[]> {
const response = await fetch(`${API_BASE_URL}/help/${page}`, {
credentials: "include",
cache: "no-store",
});
return response.ok ? ((await response.json()) as { items: HelpItem[] }).items : [];
}
/** B05 · B06 = 한 페이지(구조물 폼 공유) — 제 폴더 뒤에 짝 폴더 항목(짝의 설명서는 뺌)도 보임 */
const PAIR: Record<string, string> = { B05: "B06", B06: "B05" };
async function openHelp(): Promise<void> {
const page = currentPage();
const pair = PAIR[page];
const [own, other] = await Promise.all([fetchItems(page), pair ? fetchItems(pair) : []]);
const items = [...own, ...other.filter((item) => !own.some((o) => o.key === item.key))];
const topics = visibleTopics();
const first = [...topics].reverse().find((key) => items.some((item) => item.key === key));
openModal({
title: `${TEXT.title} · ${page}`,
closeLabel: TEXT.close,
dialogClass: "ui-help",
mount: (body) => {
const article = el("article", { className: "ui-help__text" });
const toc = el("nav", { className: "ui-help__toc" });
const show = (key: string): void => {
const item = items.find((entry) => entry.key === key);
article.innerHTML = item ? renderHelpMarkdown(item.text) : `<p>${TEXT.empty}</p>`;
article.dataset.helpKey = key;
toc
.querySelectorAll("button")
.forEach((b) => b.classList.toggle("is-active", b.dataset.helpKey === key));
};
for (const item of items) {
const link = el("button", {
className: "ui-help__link",
text: item.title,
attrs: { type: "button" },
});
link.dataset.helpKey = item.key;
link.addEventListener("click", () => show(item.key));
toc.append(link);
}
body.append(el("div", { className: "ui-help__grid", children: [toc, article] }));
show(first ?? items[0]?.key ?? "");
},
});
}
export function createHelpButton(): HTMLButtonElement {
const button = createButton({
label: TEXT.button,
variant: "ghost",
onClick: () => void openHelp(),
});
button.classList.add("ui-help-btn");
button.title = button.ariaLabel = TEXT.tip;
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());
}
-40
View File
@@ -228,46 +228,6 @@ export const ui_locales_b1 = {
"Upload the required planned route, terrain, and point cloud files.",
],
B03_File_Select_Label: ["입력 파일 선택", "Select input files"],
/* --- 좌측 안내 패널 (2026-09-03) --- */
B03_Guide_Files_Title: ["필요한 파일", "Files you need"],
B03_Guide_Files_Route: [
"계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.",
"Planned route — the five shapefile parts (.shp, .shx, .dbf, .cpg, .prj).",
],
B03_Guide_Files_Terrain: [
"지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW.",
"Terrain — one LAS/LAZ point cloud plus the PRJ projection and TFW world file.",
],
B03_Guide_Files_Optional: [
"지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다.",
"The terrain TIF (DEM) is optional — surface analysis runs without it.",
],
B03_Guide_How_Title: ["고르는 방법", "How to pick"],
B03_Guide_How_Drop: [
"여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다.",
"Drop several files at once — each is assigned to a card by its extension.",
],
B03_Guide_How_Card: [
"카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다.",
"Use a card's [Select] to set one slot, or ✕ to clear it.",
],
B03_Guide_How_Temp: [
"[임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다.",
"[Temporary storage] pulls files you staged on the dashboard.",
],
B03_Guide_Notes_Title: ["알아 둘 것", "Before you upload"],
B03_Guide_Notes_Replace: [
"이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다.",
"Uploading again to a finished project replaces the existing analysis.",
],
B03_Guide_Notes_Crs: [
"작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다.",
"The LiDAR PRJ sets the working CRS — a mismatched route CRS blocks the upload.",
],
B03_Guide_Notes_LasFree: [
"라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다.",
"With no LiDAR, turn on [Design without LAS] and work from sheet contours.",
],
B03_File_Selected_Title: ["선택한 파일", "Selected files"],
B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."],
B03_File_Upload_Button: ["파일 업로드", "Upload files"],
-8
View File
@@ -528,10 +528,6 @@ export const ui_locales_b2 = {
B06_Std_Field_CrossSlopeMax: ["횡단경사 최대(%)", "Cross slope max (%)"],
B06_Std_Field_CutSlope: ["절토경사(1:n)", "Cut slope (1:n)"],
B06_Std_Field_FillSlope: ["성토경사(1:n)", "Fill slope (1:n)"],
B06_Std_LType_Note: [
"L형 측구는 각 횡단면도에서 선택합니다.",
"L-type ditch is chosen per cross-section drawing.",
],
B06_Std_Reset: ["기본값 복원", "Restore defaults"],
B06_Std_ApplyAll: ["전체 측점 반영", "Apply to all stations"],
B06_Std_ApplyAll_Success: [
@@ -563,10 +559,6 @@ export const ui_locales_b2 = {
B06_Std_Diagram_Fill: ["성토경사", "Fill slope"],
B06_Std_Diagram_CrossSlope: ["횡단경사", "Cross slope"],
B06_Std_Diagram_Center: ["중심선(계획고)", "Centerline (design elev.)"],
B06_Std_Diagram_Caption: [
"표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님).",
"Standard cut-fill section schematic — shows where each value applies (not to scale).",
],
/* --- B07_DesignDetail 상세 설계 --- */
B07_Design_Title: ["상세설계", "Detail Design"],
+2
View File
@@ -1,6 +1,7 @@
import "./ui_template_overlay.css";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { t } from "./ui_template_locale";
import { placeHelpButton } from "./ui_template_help";
import { makePanelDraggable } from "./ui_template_overlay_drag";
// 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을
// 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다.
@@ -126,6 +127,7 @@ export function splitSidebarActions(body: HTMLElement): void {
while (container.firstChild && container.firstChild !== actions)
scroll.append(container.firstChild);
container.prepend(scroll);
placeHelpButton(actions);
body.classList.add("ui-sidebar-split");
// body → 부모 사이 중간 요소들이 높이를 이어주도록 표시한다(플렉스 세로 확장).
for (let el = container; el && el !== body; el = el.parentElement as HTMLElement) {
+8 -1
View File
@@ -162,6 +162,12 @@ export function createStepBar(
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";
@@ -185,7 +191,8 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa
});
const overlays = createWorkflowOverlays({
title: options.title,
optionsContent: options.leftPanel,
// 좌측 패널이 없는 화면(B08 · B09)도 [?] 줄은 둠(54-2)
optionsContent: options.leftPanel ?? helpOnlyRow(),
progressContent,
onOptionsOpenChange: (isOpen) => root.classList.toggle("is-options-open", isOpen),
});