feat(B07): 횡단 장 배치 칸을 횡단도 묶음 맨 아래 공용 입력칸 · 작은 단추로 이동(153-1)

- 배치 열 · 행 = 공용 입력칸 · [이 장] [모든 장] = 작은 ghost 단추
- 장 아닌 도면 = 기본값 · [이 장] 꺼짐
- 정보 칸을 다시 그려도(장 확정 뒤) 칸이 남음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
This commit is contained in:
2026-10-09 19:02:54 +09:00
co-authored by Claude Opus 5.5
parent 09b3b50efc
commit 3eaa165cbc
5 changed files with 321 additions and 56 deletions
+25 -12
View File
@@ -137,6 +137,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// 표준도 제원 칸·[여기서 고치기]는 2026-09-13 B08 「구조물도」 탭으로 이관(PLAN 3장 ④).
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
void fillCrossLayout(drawing);
if (drawing.kind !== "cross") {
infoPanelHost.replaceChildren();
return;
@@ -144,28 +145,37 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// 장은 id 가 `cross_s…`(여러 측점 묶음), 측점 도면은 `cross_…m` 이다. `chainage_m` 은
// 장에도 첫 측점 값이 실려 오므로 그것으로는 갈리지 않는다.
const scope = isCrossSheet(drawing) ? "sheet" : "station";
const panel = buildDesignInfoPanel(drawing.label, response.design ?? null, scope);
infoPanelHost.replaceChildren(panel);
if (scope === "sheet") void appendCrossLayout(drawing, panel);
infoPanelHost.replaceChildren(
buildDesignInfoPanel(drawing.label, response.design ?? null, scope),
);
};
/** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. */
const appendCrossLayout = async (drawing: DesignDrawingItem, panel: HTMLElement) => {
/** 장 배치 칸 자리(153-1) — 횡단도 묶음 맨 아래 · 정보 칸을 다시 그려도 남음. */
const crossLayoutSlot = document.createElement("div");
crossLayoutSlot.className = "b07-cross-layout-slot";
let crossLayoutRequest: Promise<CrossLayout> | undefined;
/** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. 장 아니면 기본값 · [이 장] 꺼짐. */
const fillCrossLayout = async (drawing: DesignDrawingItem) => {
if (!projectId) return;
const number = drawings.filter(isCrossSheet).findIndex((item) => item.id === drawing.id) + 1;
let layout: CrossLayout;
try {
layout = await fetchCrossLayout(projectId);
layout = await (crossLayoutRequest ??= fetchCrossLayout(projectId));
} catch {
crossLayoutRequest = undefined;
return; // 배치 칸만 빠짐 — 도면 보기는 그대로
}
if (currentDrawing?.id !== drawing.id || !panel.isConnected) return;
if (currentDrawing?.id !== drawing.id) return;
const number = isCrossSheet(drawing)
? drawings.filter(isCrossSheet).findIndex((item) => item.id === drawing.id) + 1
: 0;
const save = async (next: CrossLayout) => {
if (!mayDiscardEdits()) return;
showLoadingOverlay();
try {
await saveCrossLayout(projectId, next);
reopenCrossSheet = number;
reopenCrossSheet = number || undefined;
reopenDrawingId = number ? undefined : currentDrawing?.id;
cad.destroy();
void renderB07DesignDetail(root);
} catch (error) {
@@ -174,10 +184,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
hideLoadingOverlay();
}
};
panel.append(
crossLayoutSlot.replaceChildren(
buildCrossLayoutControls(
layout.sheets[String(number)] ?? layout.default,
(grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }),
(number && layout.sheets[String(number)]) || layout.default,
number
? (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } })
: null,
(grid) => void save({ default: grid, sheets: {} }),
),
);
@@ -517,6 +529,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
},
}
: undefined,
crossLayoutSlot,
);
drawingListEl = drawingPanel;
const confirmActions = document.createElement("div");
+38 -37
View File
@@ -7,7 +7,7 @@
* ========================================================================== */
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { showToast } from "@ui/ui_template_elements";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds";
@@ -46,6 +46,8 @@ export function buildDrawingSidePanel(
errorMessage?: string,
devBypass = false,
plan?: PlanManageControls,
/** 횡단도 묶음 맨 아래에 붙일 자리(153-1 장 배치 칸) — 내용은 화면 조립이 도면을 열 때마다 채움. */
crossTools?: HTMLElement,
): HTMLDivElement {
const panel = document.createElement("div");
panel.className = "b07-drawing-list";
@@ -147,6 +149,7 @@ export function buildDrawingSidePanel(
// 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다.
section.append(drawingButton(drawing, drawing.label));
}
if (cross && crossTools) section.append(crossTools);
panel.append(section);
}
attachCollapsible(panel);
@@ -172,54 +175,52 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean {
return drawing.kind === "cross" && drawing.id.startsWith("cross_s");
}
/** 횡단 장 배치 칸(140-3) — 「배치 열 [4] × 행 [4] [적용]」 + [모든 장 기본]. */
/**
* 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」.
* `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔.
*/
export function buildCrossLayoutControls(
grid: [number, number],
onApply: (grid: [number, number]) => void,
onApply: ((grid: [number, number]) => void) | null,
onAllDefault: (grid: [number, number]) => void,
): HTMLElement {
const box = document.createElement("div");
box.className = "b07-info__layout";
const number = (value: number, label: string): HTMLInputElement => {
const input = document.createElement("input");
input.type = "number";
input.min = "1";
input.max = "10";
input.step = "1";
input.value = String(value);
input.ariaLabel = label;
return input;
};
const cols = number(grid[0], "열");
box.className = "b07-cross-layout";
const number = (value: number, label: string) =>
createInputField({
label,
type: "number",
value: String(value),
min: 1,
max: 10,
compact: true,
});
const cols = number(grid[0], "배치 열");
const rows = number(grid[1], "행");
const read = (): [number, number] | null => {
const value: [number, number] = [Number(cols.value), Number(rows.value)];
const value: [number, number] = [Number(cols.input.value), Number(rows.input.value)];
return value.every((n) => Number.isInteger(n) && n >= 1 && n <= 10) ? value : null;
};
const action = (text: string, title: string, run: (grid: [number, number]) => void) => {
const node = document.createElement("button");
node.type = "button";
node.className = "b07-plan-tools__button";
node.textContent = text;
node.title = title;
node.addEventListener("click", () => {
const value = read();
if (value) run(value);
else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error");
const action = (label: string, title: string, run: ((grid: [number, number]) => void) | null) => {
const button = createButton({
label,
variant: "ghost",
size: "sm",
disabled: !run,
onClick: () => {
const value = read();
if (value) run?.(value);
else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error");
},
});
return node;
button.title = title;
return button;
};
const label = document.createElement("span");
label.textContent = "배치 열";
const times = document.createElement("span");
times.textContent = "× 행";
box.append(
label,
cols,
times,
rows,
action("적용", "이 장만 이 배치로", onApply),
action("모든 장 기본", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault),
cols.root,
rows.root,
action("이 장", "이 장만 이 배치로", onApply),
action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault),
);
return box;
}
+30 -7
View File
@@ -225,18 +225,41 @@
color: var(--color-text-muted);
}
/* 횡단 장 배치(140-3) — 배치 열 [4] × 행 [4] [적용] [모든 장 기본] */
.b07-info__layout {
/* 횡단 장 배치(153-1) — 횡단도 묶음 맨 아래 전체 폭: 배치 열 [4] 행 [4] [이 장] [모든 장].
좌측이 182px 까지 줄어드니 줄바꿈 허용 · 공용 칸의 오류 자리는 접음. */
.b07-cross-layout-slot {
grid-column: 1 / -1;
}
.b07-cross-layout-slot:empty {
display: none;
}
.b07-cross-layout {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
color: var(--color-text-muted);
font-size: var(--text-caption);
gap: var(--spacing-4) var(--spacing-8);
}
.b07-info__layout input {
width: 44px;
.b07-cross-layout > .ui-field {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-4);
}
.b07-cross-layout .ui-field__label {
margin: 0;
white-space: nowrap;
}
.b07-cross-layout .ui-input {
width: 48px;
}
.b07-cross-layout .ui-field__error {
display: none;
}
/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */
@@ -0,0 +1,165 @@
/* 153-1 B07 횡단 장 배치 칸 헬퍼 — 좌측 「횡단도」 묶음 맨 아래 자리 · 공용 입력칸 · 작은 단추를
* Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 ·
* 공용 단추 · 입력칸은 진짜(`ui_template_elements`) · 알림만 기록용 가짜.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const B07 = path.join(ROOT, "B07_DesignDetail");
const UI = path.join(ROOT, "ui_template");
const cache = new Map();
class FakeNode {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.dataset = {};
this.attrs = {};
this.listeners = {};
this._text = "";
this._classes = new Set();
this.value = "";
this.disabled = false;
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(n)),
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
contains: (name) => self._classes.has(name),
};
}
set className(value) {
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
}
get className() {
return [...this._classes].join(" ");
}
set textContent(value) {
this._text = String(value);
this.children = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
setAttribute(key, value) {
this.attrs[key] = String(value);
}
append(...kids) {
kids.forEach((k) => this.children.push(k));
}
replaceChildren(...kids) {
this.children = [...kids];
}
addEventListener(type, handler) {
(this.listeners[type] ||= []).push(handler);
}
click() {
if (!this.disabled) (this.listeners.click || []).forEach((handler) => handler({}));
}
}
const all = (node) => [node, ...node.children.flatMap(all)];
const byClass = (node, name) => all(node).filter((n) => n.classList.contains(name));
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
const toasts = [];
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (request === "@ui/ui_template_elements")
return {
...loadTs(path.join(UI, "ui_template_elements.ts")),
showToast: (message, kind) => toasts.push([kind, message]),
};
if (request === "@ui/ui_template_collapsible") return { attachCollapsible: () => {} };
if (request === "@ui/ui_template_locale")
return {
currentLanguageIndex: 0,
ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }),
};
if (request.startsWith(".")) {
const target = path.join(path.dirname(full), `${request}.ts`);
return fs.existsSync(target) ? loadTs(target) : {};
}
return {}; // 이 시험이 안 부르는 바깥 길(API · 설정 · CAD)
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const { buildDrawingSidePanel, buildCrossLayoutControls } = loadTs(
path.join(B07, "B07_DesignDetail_UI_Panels.ts"),
);
const out = {};
const slot = new FakeNode("div");
slot.className = "b07-cross-layout-slot";
const panel = buildDrawingSidePanel(
[
{ id: "cross_standard", kind: "cross_standard", label: "표준 횡단면도", confirmed: false },
{ id: "cross_s1", kind: "cross", label: "1장", confirmed: false },
{ id: "cross_s2", kind: "cross", label: "2장", confirmed: true },
],
() => {},
undefined,
false,
undefined,
slot,
);
const cross = all(panel).find((n) => n.dataset.kind === "cross");
out.slotLast = cross.children.at(-1) === slot;
out.slotCount = all(panel).filter((n) => n === slot).length;
// 장 — [이 장] 켜짐 · 값 바꿔 누름
const applied = [];
const defaults = [];
slot.replaceChildren(
buildCrossLayoutControls(
[3, 4],
(grid) => applied.push(grid),
(grid) => defaults.push(grid),
),
);
const box = slot.children[0];
const buttons = () => all(slot).filter((n) => n.tagName === "BUTTON");
const inputs = () => all(slot).filter((n) => n.tagName === "INPUT");
out.sheet = {
boxClass: box.className,
labels: byClass(slot, "ui-field__label").map((n) => n.textContent),
inputs: inputs().map((n) => ({
value: n.value,
type: n.attrs.type,
min: n.attrs.min,
max: n.attrs.max,
cls: n.className,
})),
buttons: buttons().map((n) => ({ text: n.textContent, cls: n.className, disabled: n.disabled })),
};
inputs()[0].value = "2";
inputs()[1].value = "5";
buttons()[0].click();
buttons()[1].click();
inputs()[0].value = "11";
buttons()[0].click();
out.sheetClicks = { applied, defaults, toasts: [...toasts] };
// 장 아님 — [이 장] 꺼짐 · [모든 장]은 됨
const defaults2 = [];
slot.replaceChildren(buildCrossLayoutControls([4, 4], null, (grid) => defaults2.push(grid)));
buttons()[0].click();
buttons()[1].click();
out.notSheet = {
disabled: buttons().map((n) => n.disabled),
defaults: defaults2,
values: inputs().map((n) => n.value),
};
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""153-1 B07 횡단 장 배치 칸.
못박는 것
- 배치 칸 자리 = 좌측 「횡단도」 묶음 맨 아래(한 번만) — 정보 칸을 다시 그려도 안 사라짐.
- 공용 입력칸(ui-field__label · ui-input--sm · 1~10) 둘 + 작은 ghost 단추 [이 장] [모든 장].
- 장이 아니면 [이 장] 꺼짐 · [모든 장]은 됨 · 범위 밖 값 = 알림만.
Node 헬퍼 `helper_153_b07_cross_layout.cjs` 가 화면 그릇 흉내로 돌림. 화면은 사용자 확인.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_153_b07_cross_layout.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_횡단도_묶음_맨_아래(out):
assert out["slotLast"] is True
assert out["slotCount"] == 1
def test_공용_입력칸과_작은_단추(out):
sheet = out["sheet"]
assert sheet["boxClass"] == "b07-cross-layout"
assert sheet["labels"] == ["배치 열", "행"]
assert [i["value"] for i in sheet["inputs"]] == ["3", "4"]
for field in sheet["inputs"]:
assert field["type"] == "number" and field["min"] == "1" and field["max"] == "10"
assert "ui-input--sm" in field["cls"]
assert [b["text"] for b in sheet["buttons"]] == ["이 장", "모든 장"]
for button in sheet["buttons"]:
assert button["cls"] == "ui-btn ui-btn--ghost ui-btn--sm" and not button["disabled"]
def test_이_장_모든_장_누름(out):
clicks = out["sheetClicks"]
assert clicks["applied"] == [[2, 5]]
assert clicks["defaults"] == [[2, 5]]
assert [t[0] for t in clicks["toasts"]] == ["error"] # 11 = 범위 밖
def test_장_아니면_이_장_꺼짐(out):
not_sheet = out["notSheet"]
assert not_sheet["disabled"] == [True, False]
assert not_sheet["defaults"] == [[4, 4]]
assert not_sheet["values"] == ["4", "4"]