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:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user