refactor(B07): 상세 설계 화면 페이지 자체 변종을 부품 상자 부품으로 — 도면 단추 · 장 번호 · 도구 단추 · 카드 · 띠 알림 · 값 목록 (PLAN 553-1)

- 도면 단추(날 ui-panel-btn) · 장 번호 · 도구 단추 = 단추 부품 · ⚙ = 아이콘 단추 꼴 · 평면도 줄 = 목록 줄 꼴
- 머리 = 패널 머리 + 배지 · 확정 건너뜀 = 띠 알림 · 빈 글 = 빈 상태 · 측점 정보 = 값 목록 · ⚙ 모달 그룹 상자 = 카드 · 종류 이름 칸 = 입력
- window.confirm 둘 → 확인창 부품 · 죽은 CSS 걷음 · 시험 헬퍼 부품 가짜 한 벌(helper_b07_part_stubs) · 153-1 옛 오류(removeAttribute) 고침 · 가이드 선택자 · 지킴 시험

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:29:22 +09:00
co-authored by Claude Sonnet 5.5
parent 6bb0a5312d
commit 835f9d9290
16 changed files with 312 additions and 279 deletions
@@ -24,7 +24,7 @@ export function buildGuideToggle(apply: (visible: boolean) => void): HTMLButtonE
const button = createButton({
label: labelOf(shown),
variant: "ghost",
size: "sm",
size: 28, // ⚙ 설정 단추 줄 한 규격
onClick: () => {
shown = !shown;
paint();
+11 -7
View File
@@ -18,6 +18,7 @@ import {
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { askConfirm } from "@ui/ui_template_modal";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
// 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04).
@@ -175,7 +176,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
/** 장 배치 저장(140-3 · ⚙ 횡단 모달) — 서버 쓰기 → 화면 다시 세움 · 같은 장 번호를 다시 엶. */
const saveCrossLayoutAndRebuild = async (next: CrossLayout, sheet: number) => {
if (!projectId || !mayDiscardEdits()) return;
if (!projectId || !(await mayDiscardEdits())) return;
showLoadingOverlay();
try {
await saveCrossLayout(projectId, next);
@@ -218,13 +219,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
});
/** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */
const mayDiscardEdits = (): boolean => {
const mayDiscardEdits = async (): Promise<boolean> => {
if (!cadDirty && !tabs.anyDirty()) return true;
return window.confirm(
"저장하지 않은 편집이 있습니다.\n" +
return askConfirm({
message:
"저장하지 않은 편집이 있습니다.\n" +
"지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" +
"남기려면 [취소] — 고친 도면은 페이지를 옮길 때([<] [>]) 저장됩니다.",
);
okLabel: "버리고 이동",
danger: true,
});
};
/**
@@ -389,13 +393,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
});
/** [평면도 관리] 모달을 닫음 — 고쳤으면 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다. */
const onPlanClosed = (changed: boolean, openId?: string): void => {
const onPlanClosed = async (changed: boolean, openId?: string): Promise<void> => {
if (!changed) {
const target = drawings.find((item) => item.id === openId);
if (target) selectDrawing(target);
return;
}
if (!mayDiscardEdits()) return;
if (!(await mayDiscardEdits())) return;
reopenDrawingId = openId ?? currentDrawing?.id;
cad.destroy();
void renderB07DesignDetail(root);
+66 -68
View File
@@ -6,8 +6,14 @@
* DOM 만 만들고 상태는 갖지 않는다 — 고르기 동작은 인자로 받은 콜백에 넘긴다.
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createButton } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { createKv } from "@ui/ui_template_kv";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createPanelHead } from "@ui/ui_template_panel_head";
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
import { buildDevelopmentSection } from "./B07_DesignDetail_UI_Development";
import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds";
@@ -66,41 +72,38 @@ export function buildDrawingSidePanel(
const isFolded = (kind: string): boolean => folded.get(kind) ?? true; // 389-3 모든 묶음 기본 닫힘
const panel = document.createElement("div");
panel.className = "b07-drawing-list";
const heading = document.createElement("div");
heading.className = "b07-drawing-list__heading";
const title = document.createElement("strong");
title.textContent = "설계 도면";
const count = document.createElement("span");
count.textContent = `${drawings.length}건`;
heading.append(title, count);
const heading = createPanelHead({
title: "설계 도면",
actions: [createBadge(`${drawings.length}건`, "neutral")],
divider: true,
}).root;
heading.classList.add("b07-drawing-list__heading");
panel.append(heading);
// 확정을 건너뛴 상태라는 것을 **조용히 두지 않는다** — 그냥 열면 다음 사람이
// 「왜 값이 없나」로 헤맨다(2026-09-09). 되돌리기는 B08 좌측 [확정 없이 다음으로] 옆.
if (devBypass) {
const notice = document.createElement("p");
notice.className = "b07-drawing-list__bypass";
notice.textContent = "확정을 건너뛴 상태입니다 — 개발 전용. 값이 비어 보일 수 있습니다.";
panel.append(notice);
panel.append(
createBanner({
tone: "warning",
text: "확정을 건너뛴 상태입니다 — 개발 전용. 값이 비어 보일 수 있습니다.",
}),
);
}
if (errorMessage || drawings.length === 0) {
const empty = document.createElement("p");
empty.className = "b07-drawing-list__empty";
empty.textContent = errorMessage ?? "확정된 종·횡단 도면이 없습니다.";
const empty = createEmpty({
text: errorMessage ?? "확정된 종·횡단 도면이 없습니다.",
small: true,
});
empty.classList.add("b07-drawing-list__empty");
panel.append(empty);
return panel;
}
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => {
const button = document.createElement("button");
button.type = "button";
button.className = "b07-drawing-button ui-panel-btn";
const button = listButton(label);
button.dataset.drawingId = drawing.id;
button.dataset.confirmed = String(drawing.confirmed);
const name = document.createElement("span");
name.className = "b07-drawing-button__name";
name.textContent = label;
button.append(name);
button.addEventListener("click", () => onSelect(drawing));
return button;
};
@@ -140,16 +143,10 @@ export function buildDrawingSidePanel(
const [drawing] = items;
// 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다.
if (!drawing) {
const placeholder = document.createElement("button");
placeholder.type = "button";
placeholder.className = "b07-drawing-button ui-panel-btn";
const placeholder = listButton(group.label);
placeholder.disabled = true;
placeholder.title = "준비 중";
placeholder.dataset.pending = "true";
const name = document.createElement("span");
name.className = "b07-drawing-button__name";
name.textContent = group.label;
placeholder.append(name);
panel.append(placeholder);
continue;
}
@@ -183,9 +180,11 @@ export function buildDrawingSidePanel(
section.append(button);
}
if (structure && items.length === 0) {
const empty = document.createElement("p");
empty.className = "b07-drawing-list__empty";
empty.textContent = "B05 · B06 구조물 배치에 연결된 구조물 도면이 없습니다.";
const empty = createEmpty({
text: "B05 · B06 구조물 배치에 연결된 구조물 도면이 없습니다.",
small: true,
});
empty.classList.add("b07-drawing-list__empty");
section.append(empty);
}
panel.append(section);
@@ -194,10 +193,20 @@ export function buildDrawingSidePanel(
return panel;
}
/** 도면 단추 한 개 — 단추 부품(고스트 32) · 이름 칸 이름만 B07 자리 이름을 더함. */
function listButton(label: string): HTMLButtonElement {
const button = createButton({ label, variant: "ghost", size: 32 });
button.classList.add("b07-drawing-button");
button.children[button.children.length - 1]?.classList.add("b07-drawing-button__name");
return button;
}
/** 한 장 단추 안 오른쪽 ⚙(280-5) — 단추 안 단추 금지라 span · 누름 · Enter 가 도면 열기로 안 올라감. */
function buttonGear(onSettings: () => void): HTMLElement {
const gear = document.createElement("span");
gear.className = "b07-drawing-button__settings";
// 아이콘 단추 부품 꼴을 입힘 — 단추 안 단추는 못 둬서 span 으로 둠
gear.className = "b07-drawing-button__settings ui-icon-btn ui-icon-btn--ghost";
gear.dataset.size = "20";
gear.setAttribute("role", "button");
gear.tabIndex = 0;
gear.textContent = "⚙";
@@ -244,19 +253,6 @@ function ditchLabel(design: CrossDesignInfo): string {
return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
}
function infoRow(label: string, value: string): HTMLElement {
const row = document.createElement("div");
row.className = "b07-info__row";
const key = document.createElement("span");
key.className = "b07-info__key";
key.textContent = label;
const val = document.createElement("span");
val.className = "b07-info__val";
val.textContent = value;
row.append(key, val);
return row;
}
/**
* 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (B06 정본 값 · 확정 배지 없음 · 요구 343).
*
@@ -271,19 +267,16 @@ export function buildDesignInfoPanel(
): HTMLElement {
const panel = document.createElement("div");
panel.className = "b07-info";
const heading = document.createElement("div");
heading.className = "b07-info__heading";
const stationName = document.createElement("strong");
const scopeLabel = scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station");
stationName.textContent = `${scopeLabel} ${title}`;
heading.append(stationName);
panel.append(heading);
panel.append(createPanelHead({ title: `${scopeLabel} ${title}`, level: 4 }).root);
if (!design) {
const empty = document.createElement("p");
empty.className = "b07-info__empty";
empty.textContent = scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign");
panel.append(empty);
panel.append(
createEmpty({
text: scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign"),
small: true,
}),
);
return panel;
}
@@ -293,12 +286,15 @@ export function buildDesignInfoPanel(
groundTitle.textContent = L("B07_Info_Ground_Title");
ground.append(
groundTitle,
infoRow(L("B07_Info_GroundType"), L(GROUND_TYPE_LABEL[design.ground_type])),
infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)),
infoRow(
L("B07_Info_DitchSide"),
design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"),
),
createKv([
{ label: L("B07_Info_GroundType"), value: L(GROUND_TYPE_LABEL[design.ground_type]) },
{ label: L("B07_Info_CutSide"), value: cutSideLabel(design.section_mode) },
{
label: L("B07_Info_DitchSide"),
value:
design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"),
},
]),
);
const plan = document.createElement("section");
@@ -307,13 +303,15 @@ export function buildDesignInfoPanel(
planTitle.textContent = L("B07_Info_Plan_Title");
plan.append(
planTitle,
infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`),
infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`),
infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`),
infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`),
infoRow(L("B07_Info_Ditch"), ditchLabel(design)),
infoRow(L("B07_Info_CutArea"), `${design.cut_area_m2.toFixed(2)}㎡`),
infoRow(L("B07_Info_FillArea"), `${design.fill_area_m2.toFixed(2)}㎡`),
createKv([
{ label: L("B07_Info_DesignElevation"), value: `${design.design_elevation_m.toFixed(2)}m` },
{ label: L("B07_Info_CutSlope"), value: `1:${design.cut_slope_ratio}` },
{ label: L("B07_Info_FillSlope"), value: `1:${design.fill_slope_ratio}` },
{ label: L("B07_Info_RoadWidth"), value: `${design.roadbed_width_m.toFixed(2)}m` },
{ label: L("B07_Info_Ditch"), value: ditchLabel(design) },
{ label: L("B07_Info_CutArea"), value: `${design.cut_area_m2.toFixed(2)}㎡` },
{ label: L("B07_Info_FillArea"), value: `${design.fill_area_m2.toFixed(2)}㎡` },
]),
);
panel.append(ground, plan);
@@ -10,7 +10,13 @@
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createPanelCard } from "@ui/ui_template_collapsible";
import { createSelectField, showToast } from "@ui/ui_template_elements";
import {
createButton,
createInputField,
createSelectField,
showToast,
} from "@ui/ui_template_elements";
import { askConfirm } from "@ui/ui_template_modal";
import {
addPlanKind,
deletePlanKind,
@@ -37,6 +43,19 @@ function el<K extends keyof HTMLElementTagNameMap>(
return node;
}
/** 도구 단추 — 단추 부품(고스트 24) · 자리 이름 `b07-plan-tools__button` 을 더함. */
export function toolButton(label: string, onClick?: () => void): HTMLButtonElement {
const button = createButton({ label, variant: "ghost", size: 24, onClick });
button.classList.add("b07-plan-tools__button");
return button;
}
/** 단추 글자만 바꿈(단추 부품의 이름 칸은 마지막 자식) */
export function setButtonText(button: HTMLElement, text: string): void {
const label = button.children[button.children.length - 1];
if (label) label.textContent = text;
}
/** 실패는 토스트로 알리고 삼킨다 — 성공했을 때만 화면을 다시 세운다. */
export async function run(
action: () => Promise<unknown>,
@@ -120,10 +139,10 @@ export function kindForm(
compact: true,
ariaLabel: "등고선 간격",
});
const name = el("input", "b07-plan-tools__name");
name.type = "text";
name.placeholder = "종류 이름";
const nameField = createInputField({ type: "text", placeholder: "종류 이름", compact: true });
const name = nameField.input;
name.maxLength = 40;
name.classList.add("b07-plan-tools__name");
const boxes = state.pieces.map((piece) => createCheckbox(piece.name, { value: piece.id }));
const pieceList = el("div", "b07-plan-tools__pieces");
const grouped = PIECE_GROUPS.map((ids) => boxes.filter(({ input }) => ids.includes(input.value)));
@@ -136,10 +155,8 @@ export function kindForm(
if (index) pieceList.append(el("hr", "b07-plan-tools__divider"));
pieceList.append(...group.map((box) => box.root));
});
const action = el("button", "b07-plan-tools__button");
action.type = "button";
const remove = el("button", "b07-plan-tools__button", "지우기");
remove.type = "button";
const action = toolButton("");
const remove = toolButton("지우기");
// 유역도 양식은 그림이 정해져 있어 얹을 정보를 고르지 않는다.
const showPieces = (): void => {
const watershed = layout.select.value === "watershed";
@@ -169,7 +186,7 @@ export function kindForm(
layout.select.value = kind?.layout ?? "plan";
layout.select.disabled = Boolean(kind);
layout.root.hidden = kind?.layout === "plan"; // 평면도는 늘 같은 값 — 새 종류 · 유역도만 보임
action.textContent = kind ? "업데이트" : "추가";
setButtonText(action, kind ? "업데이트" : "추가");
remove.hidden = !kind;
showPieces();
};
@@ -178,9 +195,13 @@ export function kindForm(
remove.addEventListener("click", () => {
const kind = state.kinds.find((item) => item.id === pick.select.value);
if (!kind) return;
if (!window.confirm(`「${kind.name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`))
return;
void run(() => deletePlanKind(controls.projectId, kind.id), controls);
void askConfirm({
message: `「${kind.name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`,
okLabel: "지우기",
danger: true,
}).then((ok) => {
if (ok) void run(() => deletePlanKind(controls.projectId, kind.id), controls);
});
});
action.addEventListener("click", () => {
const pieces = boxes.filter((box) => box.input.checked).map((box) => box.input.value);
@@ -201,7 +222,7 @@ export function kindForm(
actions.append(action, remove);
const fields = el("div", "b07-plan-tools__fields");
fields.append(pick.root, layout.root, scale.root, contour.root);
form.append(fields, name, pieceList, actions);
form.append(fields, nameField.root, pieceList, actions);
return {
root: form,
pick: (kindId) => {
@@ -10,7 +10,7 @@
* 처음 열 때 = 페이지 목록 · 미리받은 장(`Prefetch.openDrawing`) 그대로 · 같은 목록으로 다시 열면 요청 없음(522-3).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { createButton, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { createCadHost } from "@ui/cad_host/cad_host";
import {
@@ -27,6 +27,7 @@ import {
kindForm,
run,
type KindForm,
toolButton,
type PlanKindsControls,
} from "./B07_DesignDetail_UI_PlanKinds";
@@ -56,10 +57,10 @@ function el<K extends keyof HTMLElementTagNameMap>(
return node;
}
function button(className: string, text: string, onClick: () => void): HTMLButtonElement {
const node = el("button", className, text);
node.type = "button";
node.addEventListener("click", onClick);
/** 장 번호 · 이름 단추 — 단추 부품(고스트 22) · 자리 이름을 더함. */
function sheetButton(className: string, text: string, onClick: () => void): HTMLButtonElement {
const node = createButton({ label: text, variant: "ghost", size: 22, onClick });
node.classList.add(...className.split(" "));
return node;
}
@@ -105,16 +106,18 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo
const ids = state.kinds.map((kind) => kind.id);
let dragId: string | null = null;
for (const kind of state.kinds) {
const row = el("div", "b07-plan-kind__head b07-plan-manage__row");
// 목록 줄 부품 꼴(28) · 끌기 손잡이 — 줄 끌어 놓기는 아래 이벤트가 맡음
const row = el("div", "b07-plan-kind__head b07-plan-manage__row ui-list-row ui-sidebar-row");
row.dataset.size = "28";
row.dataset.planKind = kind.id;
row.draggable = true;
const grip = el("span", "b07-plan-manage__grip", "≡");
const grip = el("span", "b07-plan-manage__grip ui-list-row__grip");
grip.title = "끌어 놓아 종류 순서 바꾸기";
const group = kindSheets(kind.id);
// 한 장이면 이름이 곧 장 단추 · 여러 장이면 이름 + 작은 장 단추
const name =
group.length === 1
? button("b07-plan-manage__sheet b07-plan-kind__name", kind.name, () =>
? sheetButton("b07-plan-manage__sheet b07-plan-kind__name", kind.name, () =>
pickSheet(group[0].drawing, group[0].label),
)
: el("span", "b07-plan-kind__name", kind.name);
@@ -125,7 +128,7 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo
row.append(grip, name);
if (group.length > 1)
group.forEach(({ drawing, label }, index) => {
const item = button("b07-plan-manage__sheet", String(index + 1), () =>
const item = sheetButton("b07-plan-manage__sheet", String(index + 1), () =>
pickSheet(drawing, label),
);
item.title = label;
@@ -166,9 +169,10 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo
side
.querySelectorAll<HTMLElement>(".b07-plan-manage__sheet")
.forEach((item) => (item.dataset.active = String(item.dataset.drawingId === picked)));
side
.querySelectorAll<HTMLElement>(".b07-plan-manage__row")
.forEach((row) => (row.dataset.active = String(row.dataset.planKind === pickedKind)));
side.querySelectorAll<HTMLElement>(".b07-plan-manage__row").forEach((row) => {
row.dataset.active = String(row.dataset.planKind === pickedKind);
row.classList.toggle("is-active", row.dataset.planKind === pickedKind);
});
};
/** 장 하나를 미리보기에 — 보기 전용 · 확정 풀기는 메인 [수정](140-1). */
@@ -180,7 +184,7 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo
try {
const response = await openDrawing(drawing);
if (closed || picked !== drawing.id) return;
head.append(button("b07-plan-tools__button", "CAD 에서 열기", () => finish(drawing.id)));
head.append(toolButton("CAD 에서 열기", () => finish(drawing.id)));
preview.load(
response.drawing,
null,
@@ -15,7 +15,8 @@ import { openModal } from "@ui/ui_template_modal";
import type { CrossLayout, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
import { DRAWING_GROUPS, isCrossSheet } from "./B07_DesignDetail_UI_Panels";
import { openPlanManager, type PlanManageControls } from "./B07_DesignDetail_UI_PlanManage";
import { mountDrawingScale, settingsGroup } from "./B07_DesignDetail_UI_Settings_Scale";
import { createText } from "@ui/ui_template_text";
import { groupBody, mountDrawingScale, settingsGroup } from "./B07_DesignDetail_UI_Settings_Scale";
type Kind = DesignDrawingItem["kind"];
@@ -43,9 +44,8 @@ const CROSS_SCALE_MIN = 0.5;
const CROSS_SCALE_MAX = 2;
const note = (text: string): HTMLElement => {
const line = document.createElement("p");
line.className = "b07-settings__note";
line.textContent = text;
const line = createText(text, "muted", 14);
line.classList.add("b07-settings__note");
return line;
};
@@ -84,7 +84,7 @@ export function openDrawingSettings(kind: Kind, deps: DrawingSettingsDeps): void
closeModal = close;
if (mine) body.append(note(`지금 도면: ${current?.label}`));
if (kind === "structure") body.append(settingsGroup("축척", note("M02 그림 — 축척 없음")));
const confirm = createButton({ label: "확인", onClick: () => void apply() });
const confirm = createButton({ label: "확인", size: 28, onClick: () => void apply() });
const refresh = (): void => {
confirm.disabled = scale?.read() === "invalid" || cross?.read() === "invalid";
};
@@ -100,13 +100,18 @@ export function openDrawingSettings(kind: Kind, deps: DrawingSettingsDeps): void
if (nextLayout) deps.cross.save(nextLayout, cross!.sheet);
else if (nextScale) deps.plan?.onClosed(true, deps.current()?.id); // 목록 · 도면 다시 받음
};
const cancel = createButton({ label: "취소", variant: "ghost", onClick: close });
const cancel = createButton({ label: "취소", variant: "ghost", size: 28, onClick: close });
cancel.classList.add("b07-settings__cancel");
const footer = document.createElement("div");
footer.className = "b07-settings__actions";
if (cross) footer.append(deps.cross.guideToggle);
if (scale) {
const reset = createButton({ label: "기본값", variant: "ghost", onClick: scale.reset });
const reset = createButton({
label: "기본값",
variant: "ghost",
size: 28,
onClick: scale.reset,
});
reset.title = "축척 칸을 기본으로 채움 — [확인]으로 씀";
footer.append(reset);
}
@@ -178,7 +183,7 @@ function mountCross(
table.input.value = String(got.table_scale ?? 1);
for (const field of [cols, rows, table]) field.input.disabled = false;
})
.catch(() => layoutBox.append(note("배치를 받지 못했습니다.")));
.catch(() => groupBody(layoutBox).append(note("배치를 받지 못했습니다.")));
return {
sheet,
@@ -10,6 +10,7 @@
* ========================================================================== */
import {
createCard,
createInputField,
hideLoadingOverlay,
showConfirmDialog,
@@ -24,6 +25,7 @@ import {
fetchDrawingScales,
saveDrawingScales,
} from "./B07_DesignDetail_Api_Fetch";
import { createText } from "@ui/ui_template_text";
import type { DrawingSettingsDeps } from "./B07_DesignDetail_UI_Settings";
import { readScaleAxis, scaleToSend } from "./logic/B07_DesignDetail_Scale_Check";
@@ -38,16 +40,18 @@ const AXIS_LABEL: Record<Axis, string> = { x: "X(가로) 1/", y: "Y(세로) 1/",
const isScaleKind = (kind: string): kind is DrawingScaleKind => kind in LABEL;
/** 모달 안 그룹 상자 — 제목(legend) + 칸. */
export function settingsGroup(legend: string, ...children: HTMLElement[]): HTMLFieldSetElement {
const box = document.createElement("fieldset");
box.className = "b07-settings__group";
const title = document.createElement("legend");
title.textContent = legend;
box.append(title, ...children);
/** 모달 안 그룹 상자 — 카드 부품(제목 + 칸). 나중에 넣을 칸은 `groupBody(box)` 에. */
export function settingsGroup(legend: string, ...children: HTMLElement[]): HTMLElement {
const box = createCard({ title: legend, body: children, pad: 12 });
box.classList.add("b07-settings__group");
return box;
}
/** 그룹 상자의 칸 자리 — 카드 부품의 마지막 자식 */
export function groupBody(box: HTMLElement): HTMLElement {
return (box.children[box.children.length - 1] as HTMLElement | undefined) ?? box;
}
/** 축척 칸 한 벌 — 모달 [확인] · [기본값] 이 씀. */
export interface ScaleFields {
/** null = 그대로(못 받음 포함) · "invalid" = 붉은 칸 있음 · 그 밖 = 보낼 값 */
@@ -66,9 +70,8 @@ export function mountDrawingScale(
): ScaleFields | undefined {
if (!isScaleKind(kind) || !deps.plan) return undefined;
const { projectId } = deps.plan;
const status = document.createElement("p");
status.className = "b07-settings__note";
status.textContent = "축척 받는 중…";
const status = createText("축척 받는 중…", "muted", 14);
status.classList.add("b07-settings__note");
const section = settingsGroup(
kind === "cross" ? "축척 — 횡단도 장(표준 횡단면도 뺌)" : `축척 — ${LABEL[kind]}`,
status,
@@ -137,9 +140,8 @@ function buildScaleFields(
});
// 칸 밑 오류 글은 숨기고(틀린 칸만 길어짐) 줄 아래 한 줄로 알림
const alertLine = document.createElement("p");
alertLine.className = "b07-scale__error";
alertLine.setAttribute("role", "alert");
const alertLine = createText("", "error");
alertLine.classList.add("b07-scale__error");
alertLine.hidden = true;
const check = (): void => {
const errors = new Map<string, string[]>();
@@ -159,7 +161,7 @@ function buildScaleFields(
const row = document.createElement("div");
row.className = "b07-scale__row";
row.append(...fields.map((f) => f.field.root));
section.append(row, alertLine);
groupBody(section).append(row, alertLine);
return {
read: () => {
const read = fields.map((f) => [f, f.read()] as const);
+25 -138
View File
@@ -26,20 +26,6 @@
overflow-y: auto;
}
.b07-drawing-list__heading {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.b07-drawing-list__heading span,
.b07-drawing-list__empty {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
/* 횡단도는 2열 배치 — 단, **칸이 좁아지면 1열로 접는다**(2026-09-08).
좌측 패널이 320 고정이던 동안은 2열이 늘 128.8px 라 문제가 없었으나, 공용
오버레이가 좁은 폭에서 패널을 280·227·182 로 물리면서 칸이 108.8 → 82.4 → 60px
@@ -76,16 +62,12 @@
}
.b07-drawing-button {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
/* 격자 칸의 기본 최소폭은 글자 길이라, 이름이 길면 칸이 패널을 밀어내 단추가
오른쪽으로 튀어나갔다(2026-09-04 실측 14px). 0으로 풀면 이름이 `…`로 잘린다. */
min-width: 0;
/* 크기 · 테두리 · 바탕 · 글자 = 공용 `.ui-panel-btn`(269) · 여기선 확정 여부 좌측 색 띠(미확정: 투명)만 */
/* 크기 · 테두리 · 바탕 · 글자 = 단추 부품(고스트 32) · 여기선 확정 여부 좌측 색 띠(미확정: 투명)만 */
border-left: 3px solid transparent;
text-align: center;
}
.b07-drawing-button__name {
@@ -101,29 +83,19 @@
padding-left: 22px;
}
/* ⚙ = 아이콘 단추 부품 꼴(span) · 자리만 단추 안 오른쪽 */
.b07-drawing-button__settings {
position: absolute;
top: 50%;
right: 6px;
right: 4px;
transform: translateY(-50%);
color: var(--color-text-muted);
line-height: 1;
cursor: pointer;
}
.b07-drawing-button__settings:hover,
.b07-drawing-button__settings:focus-visible {
color: var(--color-primary);
}
.b07-drawing-button:hover,
.b07-drawing-button[data-active="true"] {
border-color: var(--color-border);
background: var(--color-mist-violet);
}
.b07-drawing-button[data-active="true"] {
color: var(--color-primary);
background: var(--color-accent-soft);
color: var(--color-accent);
}
/* 확정: 좌측 띠 + 측점 글자색을 함께 성공색으로 반영 */
@@ -174,13 +146,6 @@
gap: var(--spacing-8);
}
.b07-info__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
}
.b07-info__block {
display: flex;
flex-direction: column;
@@ -193,27 +158,6 @@
color: var(--color-text-muted);
}
.b07-info__row {
display: flex;
justify-content: space-between;
gap: var(--spacing-8);
font-size: 0.76rem;
}
.b07-info__key {
color: var(--color-text-muted);
}
.b07-info__val {
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.b07-info__empty {
font-size: 0.76rem;
color: var(--color-text-muted);
}
/* 횡단 장 배치(153-1 · 280-5 ⚙ 횡단 모달 · 523-1 그룹 상자) — 한 줄에 한 일:
「장 배치」 배치 열 · 행 / 적용 · 「표」 표 배율 · 한 줄 안 같은 너비. */
.b07-cross-layout {
@@ -250,15 +194,6 @@
min-width: 0;
}
/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */
.b07-drawing-list__bypass {
margin: var(--spacing-4) 0 0;
padding: var(--spacing-4) var(--spacing-8);
border-left: 3px solid var(--color-warning, #d9a441);
color: var(--color-text-muted);
font-size: var(--text-caption);
}
/* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */
/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 [평면도 관리](PLAN 60 · 67 · 130장) */
@@ -322,17 +257,7 @@
}
/* 「평면도 장」 한 블록(140-2) — 종류마다 한 줄 [≡] 이름 [1][2] · 줄 끌어 놓기 = 종류 순서 */
.b07-plan-manage__row {
padding: 2px var(--spacing-4);
border: 1px solid transparent;
border-radius: var(--radius-buttons);
}
.b07-plan-manage__row[data-active="true"] {
border-color: var(--color-border);
background: var(--color-mist-violet);
}
/* 줄 = 목록 줄 부품(28) · 여기선 끌어 놓을 자리 선만 */
.b07-plan-manage__row[data-drop="before"] {
box-shadow: inset 0 2px 0 var(--color-primary);
}
@@ -341,25 +266,13 @@
box-shadow: inset 0 -2px 0 var(--color-primary);
}
.b07-plan-manage__grip {
color: var(--color-text-muted);
cursor: grab;
.b07-plan-manage__sheet.b07-plan-kind__name {
flex: 1 1 auto;
justify-content: flex-start;
}
.b07-plan-manage__sheet {
min-width: 26px;
padding: 2px var(--spacing-4);
border: 1px solid var(--color-border);
border-left: 3px solid transparent;
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-caption);
cursor: pointer;
}
.b07-plan-manage__sheet.b07-plan-kind__name {
text-align: left;
}
.b07-plan-manage__sheet[data-confirmed="true"] {
@@ -367,8 +280,8 @@
}
.b07-plan-manage__sheet[data-active="true"] {
color: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-accent);
border-color: var(--color-accent);
}
.b07-plan-tools__block {
@@ -409,15 +322,6 @@
min-width: 0;
}
.b07-plan-tools__button {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
}
/* 목록에서 연 도면 탭 줄 + CAD(82-4) */
.b07-cad-stack {
display: flex;
@@ -469,30 +373,6 @@ html.is-bm-route .b07-cad-stack > .cad-tabs {
white-space: nowrap;
}
.b07-settings__note {
margin: 0;
color: var(--color-text-secondary);
font-size: 13px;
}
.b07-settings__group {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
margin: 0;
padding: var(--spacing-8) var(--spacing-12) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.b07-settings__group > legend {
padding: 0 var(--spacing-4);
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 600;
}
.b07-settings__actions {
display: flex;
align-items: center;
@@ -507,9 +387,6 @@ html.is-bm-route .b07-cad-stack > .cad-tabs {
/* 단추 줄 한 규격 — 안내선 단추(좌측 패널 작은 꼴)도 같은 키 · 좁은 폭에 맞춘 옆 여백 */
.b07-settings__actions .ui-btn {
padding: var(--spacing-8) var(--spacing-12);
font-size: var(--text-body-sm);
border-radius: var(--radius-buttons);
white-space: nowrap;
}
@@ -533,10 +410,8 @@ html.is-bm-route .b07-cad-stack > .cad-tabs {
display: none;
}
.b07-scale__error {
margin: 0;
color: var(--color-danger);
font-size: 12px;
.b07-scale__error[hidden] {
display: none;
}
/* 받지 못한 도면(508-3b) — 미리받기 · 열기가 두 번 다 실패 · 누르면 다시 받음 */
@@ -548,3 +423,15 @@ html.is-bm-route .b07-cad-stack > .cad-tabs {
content: " · 다시 받기";
color: var(--color-danger);
}
/* 설정 모달 그룹 상자 = 카드 부품 · 제목은 옛 legend 크기로 작게(모달 h3 규칙이 키우지 않게) */
.b07-settings__group > .ui-card__title {
margin-bottom: var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-caption);
font-weight: 600;
}
.b07-settings__group > .ui-card__body {
gap: var(--spacing-8);
}
@@ -6,6 +6,7 @@
const fs = require("fs");
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const { partStubs } = require("./helper_b07_part_stubs.cjs");
const ROOT = path.join(__dirname, "..", "..");
const B07 = path.join(ROOT, "B07_DesignDetail");
@@ -24,6 +25,8 @@ class FakeNode {
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(n)),
toggle: (name, force) =>
(force ?? !self._classes.has(name)) ? self._classes.add(name) : self._classes.delete(name),
contains: (name) => self._classes.has(name),
};
}
@@ -88,7 +91,7 @@ const byText = (node, tag, text) =>
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
const confirms = [];
globalThis.window = { confirm: (message) => (confirms.push(message), true) };
const parts = partStubs((tag) => new FakeNode(tag));
// ── 공용 부품 가짜 ─────────────────────────────────────────────────────────────
const toasts = [];
@@ -96,7 +99,9 @@ const modals = [];
const cads = [];
const selects = [];
const STUBS = {
...parts.modules,
"@ui/ui_template_elements": {
...parts.elements,
showToast: (message, kind) => toasts.push([kind, message]),
createSelectField: (opts) => {
const root = new FakeNode("select");
@@ -120,6 +125,7 @@ const STUBS = {
},
},
"@ui/ui_template_modal": {
askConfirm: async (opts) => (confirms.push(opts.message), true),
openModal: (opts) => {
const body = new FakeNode("div");
const modal = { opts, body, open: true };
@@ -6,6 +6,7 @@
const fs = require("fs");
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const { partStubs } = require("./helper_b07_part_stubs.cjs");
const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1)
const ROOT = path.join(__dirname, "..", "..");
@@ -46,6 +47,9 @@ class FakeNode {
setAttribute(key, value) {
this.attrs[key] = String(value);
}
removeAttribute(key) {
delete this.attrs[key];
}
append(...kids) {
kids.forEach((k) => this.children.push(k));
}
@@ -64,6 +68,7 @@ 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 parts = partStubs((tag) => new FakeNode(tag));
const toasts = [];
const modals = [];
@@ -83,7 +88,8 @@ const { loadTs } = makeLoader({
...loadTs(path.join(UI, "ui_template_elements.ts")),
showToast: (message, kind) => toasts.push([kind, message]),
};
if (request === "@ui/ui_template_modal") return { openModal };
if (request === "@ui/ui_template_modal") return { openModal, askConfirm: async () => true };
if (parts.modules[request]) return parts.modules[request];
if (request === "./B07_DesignDetail_UI_PlanManage") return {};
if (request === "./B07_DesignDetail_Api_Fetch") return {};
// 공용 카드(269) = 진짜 모듈
+5 -10
View File
@@ -6,6 +6,7 @@
const fs = require("fs");
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const { partStubs } = require("./helper_b07_part_stubs.cjs");
const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1)
const ROOT = path.join(__dirname, "..", "..");
@@ -122,17 +123,11 @@ const SCALES = {
};
let answer = true;
const button = (opts) => {
const node = new FakeNode("button");
node.className = "ui-btn";
node.textContent = opts.label;
node.disabled = !!opts.disabled;
if (opts.onClick) node.addEventListener("click", opts.onClick);
return node;
};
const parts = partStubs((tag) => new FakeNode(tag));
const STUBS = {
...parts.modules,
"@ui/ui_template_elements": {
createButton: button,
...parts.elements,
createInputField: (opts) => {
const root = new FakeNode("label");
const input = new FakeNode("input");
@@ -290,7 +285,7 @@ const drawings = [
let current = drawings[4]; // cross_s1
let redraws = 0;
const saved = [];
const guideToggle = button({ label: "안내선 숨기기" });
const guideToggle = parts.elements.createButton({ label: "안내선 숨기기" });
const closedCalls = [];
const deps = {
drawings,
+5 -2
View File
@@ -5,6 +5,7 @@
const fs = require("fs");
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const { partStubs } = require("./helper_b07_part_stubs.cjs");
const ROOT = path.join(__dirname, "..", "..");
@@ -115,14 +116,16 @@ const press = (root, section) => {
};
const node = (tag = "div") => new FakeNode(tag);
const parts = partStubs((tag) => new FakeNode(tag));
const STUBS = {
...parts.modules,
"@ui/ui_template_modal": { askConfirm: async () => true },
"@ui/ui_template_locale": {
ui_locales: new Proxy({}, { get: (_, k) => [String(k)] }),
currentLanguageIndex: 0,
},
"@ui/ui_template_elements": {
createButton: ({ label }) => Object.assign(node("button"), { _text: label }),
createInputField: () => ({ root: node(), input: node("input") }),
...parts.elements,
createSelectField: () => ({ root: node(), select: { value: "" } }),
showToast: () => undefined,
},
@@ -0,0 +1,60 @@
/* B07 시험 헬퍼 공용 — 템플릿 부품(단추 · 카드 · 배지 · 띠 알림 · 빈 상태 · 값 목록 · 패널 머리 · 보조 글)의
* 기록용 가짜. 화면 그릇(FakeNode)은 헬퍼 것을 받아 씀 — 진짜 부품이 내는 클래스 · 자식 차례만 같게 흉내.
*
* const { partStubs } = require("./helper_b07_part_stubs.cjs");
* const parts = partStubs((tag) => new FakeNode(tag));
* STUBS["@ui/ui_template_elements"] = { ...parts.elements, 나머지 };
* STUBS = { ...STUBS, ...parts.modules };
*/
function partStubs(makeNode) {
const node = (tag, className, text) => {
const made = makeNode(tag);
if (className) made.className = className;
if (text !== undefined) made.textContent = text;
if (!made.dataset) made.dataset = {};
return made;
};
/** 단추 부품 — `ui-btn ui-btn--<꼴>` · 마지막 자식 = 이름 칸(`ui-btn__label`) */
const createButton = (opts) => {
const button = node("button", `ui-btn ui-btn--${opts.variant ?? "filled"}`);
if (opts.size !== undefined) button.dataset.size = String(opts.size);
button.append(node("span", "ui-btn__label", opts.label));
button.disabled = !!opts.disabled;
if (opts.onClick) button.addEventListener("click", opts.onClick);
return button;
};
const createInputField = (opts = {}) => {
const input = node("input", opts.compact ? "ui-input ui-input--sm" : "ui-input");
input.value = opts.value ?? "";
input.placeholder = opts.placeholder ?? "";
const root = node("div", "ui-field");
root.append(input);
return { root, input, setError: () => undefined };
};
const createCard = (opts) => {
const card = node("div", "ui-card");
if (opts.title) card.append(node("h3", "ui-card__title", opts.title));
const body = node("div", "ui-card__body");
body.append(...(opts.body ?? []));
card.append(body);
return card;
};
return {
node,
elements: { createButton, createInputField, createCard },
modules: {
"@ui/ui_template_badge": { createBadge: (text) => node("span", "ui-badge", text) },
"@ui/ui_template_banner": { createBanner: (opts) => node("div", "ui-banner", opts.text) },
"@ui/ui_template_empty": { createEmpty: (opts) => node("div", "ui-empty", opts.text) },
"@ui/ui_template_kv": { createKv: () => node("dl", "ui-kv") },
"@ui/ui_template_panel_head": {
createPanelHead: (opts) => ({ root: node("header", "ui-panel-head", opts.title) }),
},
"@ui/ui_template_text": {
createText: (text, kind = "muted") => node("p", `ui-text ui-text--${kind}`, text),
},
},
};
}
module.exports = { partStubs };
+1 -1
View File
@@ -87,7 +87,7 @@ def test_종류_추가(out):
def test_종류_업데이트(out):
assert out["updateLabel"] == "업데이트"
assert out["update"]["call"] == ["update", "k_new", "sub6임시2", [], 1000]
assert out["update"]["rowText"] == "≡sub6임시2" # 한 장이면 이름이 곧 장 단추
assert out["update"]["rowText"] == "sub6임시2" # 한 장이면 이름이 곧 장 단추 · 손잡이는 목록 줄 부품의 점(글 없음)
def test_종류_지우기(out):
@@ -1,7 +1,7 @@
"""180-3 B07 횡단 장 안내선 보이기 / 숨기기 단추.
- 끄는 것 = 점선 작도 영역 + 붉은 넘침 틀(화면 전용) · 인쇄 칸선 · 도면 개체는 그대로
- 단추 = 배치 칸 줄 · ghost sm · 글 「안내선 숨기기」 ↔ 「안내선 보이기」 · 상태는 모듈 값(저장 안 함)
- 단추 = 설정 단추 줄 · ghost 28 · 글 「안내선 숨기기」 ↔ 「안내선 보이기」 · 상태는 모듈 값(저장 안 함)
- 숨김이면 넘침 토스트도 안 띄움 · 메시지 한 줄(`aislo:b08:guide-visible`) · cad_host `setGuideVisible`
"""
@@ -32,7 +32,7 @@ def test_단추_글_누름_상태_이어짐():
out = json.loads(proc.stdout)
assert out["first"] == {
"text": "안내선 숨기기",
"cls": "ui-btn ui-btn--ghost ui-btn--sm",
"cls": "ui-btn ui-btn--ghost",
"pressed": "false",
"shown": True,
}
+42
View File
@@ -120,3 +120,45 @@ def test_b04_css_common_values_only():
assert not OLD_ALIAS.search(css), f"{name}: 옛 보라 별칭"
body = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
assert not RAW_COLOR.search(body), f"{name}: 날 색"
def test_b07_guide_selectors_alive():
"""가이드 촬영 장면이 잡는 B07 자기 클래스(`b07-…`)는 소스에 살아 있어야 함 — CAD 앱 안(`cad-…`)은 다른 몫."""
text = (ROOT / "A05_EduDetail/guide/B07/_scenes.json").read_text(encoding="utf-8")
for name in set(re.findall(r"\.(b07-[a-z0-9_-]+)", text)):
assert _defined("B07_DesignDetail", name), f"가이드 선택자 .{name} 가 B07 소스에 없음"
def test_b07_uses_parts_not_raw_widgets():
"""도면 단추 · 장 번호 · 도구 단추 · 그룹 상자 · 알림 · 값 줄이 부품으로 바뀜 · 시스템 확인창 없음."""
ts = "".join(_src("B07_DesignDetail", ".ts").values())
css = "".join(_src("B07_DesignDetail", ".css").values())
for part in (
"createButton",
"createBadge",
"createBanner",
"createEmpty",
"createKv",
"createPanelHead",
"createCard",
"createText",
"askConfirm",
):
assert part in ts, f"B07 이 부품 {part} 를 안 씀"
assert "window.confirm" not in ts
assert 'createElement("button")' not in ts and 'el("button"' not in ts
assert "ui-panel-btn" not in ts and "ui-panel-btn" not in css # 옛 날 단추 꼴
for old in (
"b07-info__row",
"b07-info__key",
"b07-plan-tools__button {",
"b07-settings__group {",
):
assert old not in css and old not in ts, f"옛 변종 {old} 가 남음"
def test_b07_css_common_values_only():
for name, css in _src("B07_DesignDetail", ".css").items():
assert not OLD_ALIAS.search(css), f"{name}: 옛 보라 별칭"
body = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
assert not RAW_COLOR.search(body), f"{name}: 날 색"