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

This commit is contained in:
2026-10-08 20:08:12 +09:00
7 changed files with 793 additions and 68 deletions
+9 -4
View File
@@ -79,7 +79,7 @@ function L(key: keyof typeof ui_locales): string {
/* -----------------------------------------------------------------------------
* 화면 조립
* -------------------------------------------------------------------------- */
/** 종류 · 대피소 자리를 고쳐 화면을 다시 세울 때 — 보던 장 id. 다시 세운 뒤 그 장을 연다(없으면 첫 장). */
/** [평면도 관리]에서 고쳐 화면을 다시 세울 때 — 보던 장 id. 다시 세운 뒤 그 장을 연다(없으면 첫 장). */
let reopenDrawingId: string | undefined;
export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
@@ -463,10 +463,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
projectId
? {
projectId,
// 종류 · 대피소 자리를 고쳤다 — 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다.
onChanged: () => {
// [평면도 관리] 모달을 닫음 — 고쳤으면 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다.
onClosed: (changed, openId) => {
if (!changed) {
const target = drawings.find((item) => item.id === openId);
if (target) selectDrawing(target);
return;
}
if (!mayDiscardEdits()) return;
reopenDrawingId = currentDrawing?.id;
reopenDrawingId = openId ?? currentDrawing?.id;
cad.destroy();
void renderB07DesignDetail(root);
},
@@ -9,7 +9,8 @@
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
import { buildPlanSection, type PlanKindsControls } from "./B07_DesignDetail_UI_PlanKinds";
import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds";
import { openPlanManager, type PlanManageControls } from "./B07_DesignDetail_UI_PlanManage";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -43,7 +44,7 @@ export function buildDrawingSidePanel(
onSelect: (drawing: DesignDrawingItem) => void,
errorMessage?: string,
devBypass = false,
plan?: PlanKindsControls,
plan?: PlanManageControls,
): HTMLDivElement {
const panel = document.createElement("div");
panel.className = "b07-drawing-list";
@@ -90,7 +91,9 @@ export function buildDrawingSidePanel(
if (group.kind === "plan") {
// 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다.
const plans = drawings.filter((item) => item.kind === "plan");
panel.append(buildPlanSection(plans, drawingButton, plan));
panel.append(
buildPlanSection(plans, drawingButton, plan ? () => openPlanManager(plan) : undefined),
);
continue;
}
const items = group.kind
@@ -1,7 +1,8 @@
/* =============================================================================
* B07_DesignDetail_UI_PlanKinds.ts
* 좌측 도면 목록의 「평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
* 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장).
* 사용자 추가)별 도면 단추 + [평면도 관리] 단추 (PLAN 60 · 67 · 130장).
* 종류 추가 · 업데이트 폼 · 대피소 · 사토장 자리 폼은 [평면도 관리] 모달(`_UI_PlanManage.ts`)이 씀.
*
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다.
* 드롭다운 · 체크박스는 공용(`createSelectField` · `ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸.
@@ -12,8 +13,6 @@ import { createSelectField, showToast } from "@ui/ui_template_elements";
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
import {
addPlanKind,
deletePlanKind,
fetchPlanKinds,
savePlanMarks,
updatePlanKind,
type DesignDrawingItem,
@@ -43,7 +42,10 @@ function el<K extends keyof HTMLElementTagNameMap>(
}
/** 실패는 토스트로 알리고 삼킨다 — 성공했을 때만 화면을 다시 세운다. */
async function run(action: () => Promise<unknown>, controls: PlanKindsControls): Promise<void> {
export async function run(
action: () => Promise<unknown>,
controls: PlanKindsControls,
): Promise<void> {
try {
await action();
controls.onChanged();
@@ -59,7 +61,7 @@ const LAYOUTS: { value: PlanKindLayout; text: string }[] = [
];
/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 양식 고르고 [추가]. */
function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
export function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
const form = el("div", "b07-plan-tools__block");
form.append(el("strong", "", "종류"));
const pick = createSelectField({
@@ -137,7 +139,7 @@ function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLEleme
return form;
}
function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
export function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
const form = el("div", "b07-plan-tools__block");
form.append(el("strong", "", "대피소 · 사토장 자리"));
form.append(
@@ -192,68 +194,54 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem
return form;
}
/** 「평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */
export function buildPlanSection(
/** 장을 종류별로 묶음 — 여러 장이면 「이름 N장」 · 한 장이면 이름(좌측 목록 · 관리 모달 공용). */
export function groupPlanSheets(
items: DesignDrawingItem[],
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
controls?: PlanKindsControls,
): HTMLElement {
const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section");
section.dataset.kind = "plan";
section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`));
): { kindId: string; name: string; sheets: { drawing: DesignDrawingItem; label: string }[] }[] {
const kinds = new Map<string, DesignDrawingItem[]>();
for (const item of items) {
const key = item.plan_kind ?? item.id;
kinds.set(key, [...(kinds.get(key) ?? []), item]);
}
for (const [kindId, sheets] of kinds) {
return [...kinds].map(([kindId, sheets]) => {
const name = sheets[0].plan_kind_name ?? sheets[0].label;
return {
kindId,
name,
sheets: sheets.map((drawing, index) => ({
drawing,
label: sheets.length > 1 ? `${name} ${index + 1}장` : name,
})),
};
});
}
/** 「평면도」 묶음 — 종류마다 이름 줄 + 장 단추, 아래에 [평면도 관리](130-1). */
export function buildPlanSection(
items: DesignDrawingItem[],
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
onManage?: () => void,
): HTMLElement {
const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section");
section.dataset.kind = "plan";
section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`));
for (const { kindId, name, sheets } of groupPlanSheets(items)) {
const block = el("div", "b07-plan-kind");
block.dataset.planKind = kindId;
// 한 장이면 [도면 단추][×] 한 줄 · 여러 장이면 [이름][×] 아래 장 단추.
const head = el("div", "b07-plan-kind__head");
const buttons = sheets.map((drawing, index) =>
makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name),
);
head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]);
if (controls) {
const remove = el("button", "b07-plan-tools__remove", "×");
remove.type = "button";
remove.title = `「${name}」 종류 지우기`;
remove.addEventListener("click", () => {
if (!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`))
return;
void run(() => deletePlanKind(controls.projectId, kindId), controls);
});
head.append(remove);
}
block.append(head, ...(sheets.length > 1 ? buttons : []));
// 한 장이면 [도면 단추] 한 줄 · 여러 장이면 [이름] 아래 장 단추.
const buttons = sheets.map(({ drawing, label }) => makeButton(drawing, label));
if (sheets.length > 1) block.append(el("span", "b07-plan-kind__name", name));
block.append(...buttons);
section.append(block);
}
if (controls) {
// 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다.
const tools = el("details", "b07-plan-tools");
tools.append(el("summary", "", "종류 추가 · 업데이트 · 대피소 자리"));
const load = (): void => {
if (!tools.open || tools.dataset.loaded) return;
tools.dataset.loaded = "true";
void fetchPlanKinds(controls.projectId)
.then((state) => tools.append(kindForm(state, controls), marksForm(state, controls)))
.catch((error: unknown) => {
delete tools.dataset.loaded;
showToast(
error instanceof Error ? error.message : "종류 목록을 받지 못했습니다.",
"error",
);
});
};
tools.addEventListener("toggle", load);
// 기본 펼침 — 종류마다 얹을 정보(사용자 지정)가 바로 보이게(93-6).
tools.open = true;
load();
section.append(tools);
if (onManage) {
// 종류 · 대피소 자리 · 장 미리보기는 모달 한 곳(좌측 공간 · 접근 혼돈 — 130-1).
const manage = el("button", "b07-plan-tools__button b07-plan-manage-open", "평면도 관리");
manage.type = "button";
manage.addEventListener("click", onManage);
section.append(manage);
}
return section;
}
@@ -0,0 +1,210 @@
/* =============================================================================
* B07_DesignDetail_UI_PlanManage.ts
* [평면도 관리] 모달(PLAN 130-1) — 좌측 패널이 좁아 종류 · 대피소 자리 · 장 보기를 한 창에 모음.
*
* 왼쪽 = 종류 목록(× 지우기) · 종류 추가 · 업데이트 폼 · 대피소 · 사토장 자리 · 평면도 장 목록
* 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 장 바꾸기) · [확정 풀기] · [CAD 에서 열기]
* 고치기는 지금 서버 길 그대로(종류 파일 · 확정 풀기) — 고칠 때마다 모달 안을 다시 받고,
* 닫을 때 `onClosed(changed)` 로 좌측 목록 · 도면을 다시 읽음.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { createCadHost } from "@ui/cad_host/cad_host";
import {
deletePlanKind,
fetchDesignDrawing,
fetchDesignDrawingList,
fetchPlanKinds,
invalidateDesignDrawing,
type CadDrawing,
type DesignDrawingItem,
type PlanKindsState,
} from "./B07_DesignDetail_Api_Fetch";
import {
groupPlanSheets,
kindForm,
marksForm,
run,
type PlanKindsControls,
} from "./B07_DesignDetail_UI_PlanKinds";
export interface PlanManageControls {
projectId: string;
/** 모달을 닫음 — changed = 종류 · 자리 · 확정을 고쳤나 · openId = [CAD 에서 열기]로 고른 장. */
onClosed: (changed: boolean, openId?: string) => void;
}
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
className: string,
text = "",
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
node.className = className;
if (text) node.textContent = text;
return node;
}
function button(className: string, text: string, onClick: () => void): HTMLButtonElement {
const node = el("button", className, text);
node.type = "button";
node.addEventListener("click", onClick);
return node;
}
export function openPlanManager(controls: PlanManageControls): void {
const { projectId } = controls;
let changed = false;
let closed = false;
/** 미리보기 중인 장 — 늦게 온 응답이 다른 장을 덮지 않게 이것과 맞춰 봄. */
let picked: string | undefined;
const preview = createCadHost<CadDrawing>({ title: "평면도 미리보기" });
const side = el("div", "b07-plan-manage__side");
const head = el("div", "b07-plan-manage__head");
const view = el("div", "b07-plan-manage__view");
view.append(head, preview.element);
// 종류 · 자리 폼은 좌측에 있던 것 그대로 — 고친 뒤 모달 안만 다시 세움(화면은 닫을 때).
const edits: PlanKindsControls = {
projectId,
onChanged: () => {
changed = true;
void refresh();
},
};
const kindList = (state: PlanKindsState, sheets: DesignDrawingItem[]): HTMLElement => {
const list = el("div", "b07-plan-tools__block b07-plan-manage__kinds");
list.append(el("strong", "", "종류 목록"));
for (const kind of state.kinds) {
const row = el("div", "b07-plan-kind__head");
row.dataset.planKind = kind.id;
const count = sheets.filter((item) => item.plan_kind === kind.id).length;
row.append(el("span", "b07-plan-kind__name", `${kind.name} · ${count}장`));
const remove = button("b07-plan-tools__remove", "×", () => {
if (
!window.confirm(`「${kind.name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`)
)
return;
void run(() => deletePlanKind(projectId, kind.id), edits);
});
remove.title = `「${kind.name}」 종류 지우기`;
row.append(remove);
list.append(row);
}
return list;
};
const sheetList = (sheets: DesignDrawingItem[]): HTMLElement => {
const list = el("div", "b07-plan-tools__block b07-plan-manage__sheets");
list.append(el("strong", "", "평면도 장"));
for (const group of groupPlanSheets(sheets)) {
for (const { drawing, label } of group.sheets) {
const item = button("b07-drawing-button", label, () => void show(drawing, label));
item.dataset.drawingId = drawing.id;
item.dataset.confirmed = String(drawing.confirmed);
list.append(item);
}
}
return list;
};
const markActive = (): void =>
side
.querySelectorAll<HTMLElement>(".b07-plan-manage__sheets .b07-drawing-button")
.forEach((item) => (item.dataset.active = String(item.dataset.drawingId === picked)));
/** 장 하나를 미리보기에 — 보기 전용 · 확정이면 [확정 풀기](지금 [수정]과 같은 서버 길). */
const show = async (drawing: DesignDrawingItem, label: string): Promise<void> => {
picked = drawing.id;
markActive();
head.replaceChildren(el("strong", "", label));
preview.beginLoading();
try {
const response = await fetchDesignDrawing(projectId, drawing.id);
if (closed || picked !== drawing.id) return;
const badge = el("span", "b07-plan-manage__badge", response.confirmed ? "확정" : "확정 전");
badge.dataset.confirmed = String(response.confirmed);
head.append(badge);
if (response.confirmed)
head.append(
button("b07-plan-tools__button", "확정 풀기", () => {
void run(() => invalidateDesignDrawing(projectId, drawing.id), edits);
}),
);
head.append(button("b07-plan-tools__button", "CAD 에서 열기", () => finish(drawing.id)));
preview.load(
response.drawing,
null,
false,
{},
{
recoveryScope: "b07-plan-preview",
readOnly: true,
viewOnly: true,
hostTitle: "평면도 미리보기",
ribbonCollapsed: true,
panelsCollapsed: true,
},
);
} catch (error) {
if (closed || picked !== drawing.id) return;
preview.fail(error instanceof Error ? error.message : "평면도를 불러오지 못했습니다.");
}
};
/** 종류 · 장 목록을 서버에서 다시 받아 왼쪽을 세우고 보던 장(없으면 첫 장)을 다시 봄. */
const refresh = async (): Promise<void> => {
try {
const [state, list] = await Promise.all([
fetchPlanKinds(projectId),
fetchDesignDrawingList(projectId),
]);
if (closed) return;
const sheets = list.drawings.filter((item) => item.kind === "plan");
side.replaceChildren(
kindList(state, sheets),
kindForm(state, edits),
marksForm(state, edits),
sheetList(sheets),
);
const all = groupPlanSheets(sheets).flatMap((group) => group.sheets);
const keep = all.find((sheet) => sheet.drawing.id === picked) ?? all[0];
if (keep) void show(keep.drawing, keep.label);
else {
picked = undefined;
head.replaceChildren(el("span", "b07-plan-kind__name", "평면도 장이 없습니다."));
}
} catch (error) {
showToast(error instanceof Error ? error.message : "평면도 목록을 받지 못했습니다.", "error");
}
};
let closeModal = (): void => undefined;
/** 한 번만 — 미리보기 듣기를 멈추고 페이지에 알림. */
const finish = (openId?: string): void => {
if (closed) return;
closed = true;
preview.destroy();
closeModal();
controls.onClosed(changed, openId);
};
openModal({
title: "평면도 관리",
closeLabel: "닫기",
dialogClass: "b07-plan-manage",
beforeClose: () => {
finish();
return true;
},
mount: (body, close) => {
closeModal = close;
const grid = el("div", "b07-plan-manage__grid");
grid.append(side, view);
body.append(grid);
void refresh();
},
});
}
+48 -5
View File
@@ -236,7 +236,7 @@
/* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */
/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 종류 추가 · 업데이트 · 대피소 자리(PLAN 60 · 67장) */
/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 [평면도 관리](PLAN 60 · 67 · 130장) */
.b07-plan-kind {
display: flex;
flex-direction: column;
@@ -257,14 +257,57 @@
font-size: var(--text-caption);
}
.b07-plan-tools {
.b07-plan-manage-open {
margin-top: var(--spacing-4);
font-size: var(--text-body-sm);
}
.b07-plan-tools summary {
/* [평면도 관리] 모달(130-1) — 왼쪽 종류 · 자리 · 장 목록 / 오른쪽 미리보기 · 공용 모달에 자기 class 로 큰 창 */
.ui-modal.b07-plan-manage {
width: min(1320px, 96vw);
--modal-max: 94vh;
}
.b07-plan-manage__grid {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: var(--spacing-16);
height: calc(94vh - 96px);
min-height: 0;
}
.b07-plan-manage__side {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-height: 0;
overflow-y: auto;
}
.b07-plan-manage__view {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-height: 0;
}
.b07-plan-manage__view > .cad-host {
flex: 1 1 0;
height: auto;
}
.b07-plan-manage__head {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.b07-plan-manage__badge {
color: var(--color-text-muted);
cursor: pointer;
font-size: var(--text-caption);
}
.b07-plan-manage__badge[data-confirmed="true"] {
color: var(--color-success);
}
.b07-plan-tools__block {
@@ -0,0 +1,373 @@
/* 130-1 [평면도 관리] 모달 헬퍼 — 좌측 「평면도」 묶음 · 모달(종류 추가 · 업데이트 · 지우기 · 장 미리보기)을
* Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 ·
* 서버 길(`_Api_Fetch`)은 메모리 가짜 · 공용 모달 · CAD · 드롭다운 · 체크박스는 기록용 가짜.
*/
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 cache = new Map();
// ── 화면 그릇 흉내 ─────────────────────────────────────────────────────────────
class FakeNode {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.dataset = {};
this.listeners = {};
this._text = "";
this._classes = new Set();
this.value = "";
this.hidden = false;
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(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("");
}
append(...kids) {
kids.forEach((k) => this.children.push(k));
}
replaceChildren(...kids) {
this.children = [...kids];
}
addEventListener(type, handler) {
(this.listeners[type] ||= []).push(handler);
}
click() {
(this.listeners.click || []).forEach((handler) => handler({}));
}
/** `.a` · `.a .b`(자손) 만. */
querySelectorAll(selector) {
const parts = selector
.trim()
.split(/\s+/)
.map((p) => p.slice(1));
const found = [];
const walk = (node, depth) =>
node.children.forEach((kid) => {
let next = depth;
if (kid.classList && kid.classList.contains(parts[depth])) {
if (depth === parts.length - 1) found.push(kid);
else next = depth + 1;
}
walk(kid, next);
});
walk(this, 0);
return found;
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
}
const all = (node) => [node, ...node.children.flatMap(all)];
const byText = (node, tag, text) =>
all(node).filter((n) => n.tagName === tag && n.textContent === text);
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
const confirms = [];
globalThis.window = { confirm: (message) => (confirms.push(message), true) };
// ── 공용 부품 가짜 ─────────────────────────────────────────────────────────────
const toasts = [];
const modals = [];
const cads = [];
const selects = [];
const STUBS = {
"@ui/ui_template_elements": {
showToast: (message, kind) => toasts.push([kind, message]),
createSelectField: (opts) => {
const root = new FakeNode("select");
const select = { value: opts.options[0]?.value ?? "", disabled: false };
const field = {
root,
select,
label: opts.ariaLabel,
change: (value) => ((select.value = value), opts.onChange && opts.onChange()),
setOptions: (options, value) => (select.value = value ?? options[0]?.value ?? ""),
};
selects.push(field);
return field;
},
},
"@ui/ui_template_checkbox": {
createCheckbox: (label, opts = {}) => {
const root = new FakeNode("label");
root.textContent = label;
return { root, input: { value: opts.value ?? "", checked: !!opts.checked } };
},
},
"@ui/ui_template_modal": {
openModal: (opts) => {
const body = new FakeNode("div");
const modal = { opts, body, open: true };
modals.push(modal);
opts.mount(body, () => (modal.open = false));
return { close: () => (modal.open = false) };
},
},
"@ui/cad_host/cad_host": {
createCadHost: (opts) => {
const cad = { opts, element: new FakeNode("div"), loads: [], destroyed: false };
cad.beginLoading = () => {};
cad.fail = (detail) => toasts.push(["error", detail]);
cad.load = (drawing, meta, frameEdit, fields, extra) =>
cad.loads.push({ drawing, meta, extra });
cad.destroy = () => (cad.destroyed = true);
cads.push(cad);
return cad;
},
},
"@ui/ui_template_collapsible": { attachCollapsible: () => {} },
"@ui/ui_template_locale": {
currentLanguageIndex: 0,
ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }),
},
"../B05_Profile/B05_Profile_UI_Structures_Fields": {
stationFields: () => ({ wrap: new FakeNode("div"), write: () => {}, read: () => 0 }),
},
};
// ── 서버 길 가짜 — 종류 파일 · 장 목록이 메모리에 ───────────────────────────────
const calls = [];
const state = {
pieces: [
{ id: "terrain", name: "지형" },
{ id: "route", name: "노선" },
],
scales: [1000, 1200],
default_scale: 1200,
watershed_scale: 6000,
kinds: [
{ id: "k_terrain", name: "지형", pieces: ["terrain"], layout: "plan", scale: 1200 },
{ id: "k_route", name: "노선배치도", pieces: ["route"], layout: "plan", scale: 1200 },
],
marks: [],
station_interval_m: 20,
};
let confirmed = new Set(["plan_k_route"]);
/** 종류 하나 = 장 하나 · 「지형」만 두 장. */
const sheets = () =>
state.kinds.flatMap((kind) =>
(kind.id === "k_terrain" ? [1, 2] : [1]).map((n) => {
const id = n === 1 ? `plan_${kind.id}` : `plan_${kind.id}_${n}`;
return {
id,
kind: "plan",
label: kind.name,
chainage_m: null,
confirmed: confirmed.has(id),
plan_kind: kind.id,
plan_kind_name: kind.name,
};
}),
);
const copy = () => JSON.parse(JSON.stringify(state));
const API = {
fetchPlanKinds: async () => copy(),
fetchDesignDrawingList: async () => ({ drawings: [{ id: "cover", kind: "cover" }, ...sheets()] }),
fetchDesignDrawing: async (_, id) => (
calls.push(["fetchDrawing", id]),
{ id, confirmed: confirmed.has(id), drawing: { entities: [{ id }], layers: [] } }
),
addPlanKind: async (_, name, pieces, layout, scale) => {
calls.push(["add", name, pieces, layout, scale]);
state.kinds.push({ id: "k_new", name, pieces, layout, scale });
return copy();
},
updatePlanKind: async (_, id, name, pieces, scale) => {
calls.push(["update", id, name, pieces, scale]);
Object.assign(
state.kinds.find((k) => k.id === id),
{ name, pieces, scale },
);
return copy();
},
deletePlanKind: async (_, id) => {
calls.push(["delete", id]);
state.kinds = state.kinds.filter((k) => k.id !== id);
return copy();
},
savePlanMarks: async () => copy(),
invalidateDesignDrawing: async (_, id) => {
calls.push(["invalidate", id]);
confirmed.delete(id);
},
};
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 (STUBS[request]) return STUBS[request];
if (request === "./B07_DesignDetail_Api_Fetch") return API;
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
throw new Error(`stub 없음: ${request}`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const settle = () => new Promise((done) => setTimeout(done, 0));
const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Panels.ts"));
(async () => {
const out = {};
const closes = [];
const selected = [];
const plan = { projectId: "p1", onClosed: (changed, openId) => closes.push([changed, openId]) };
const panel = buildDrawingSidePanel(
[{ id: "cover", kind: "cover", label: "표지", confirmed: false }, ...sheets()],
(drawing) => selected.push(drawing.id),
undefined,
false,
plan,
);
const section = panel.querySelector(".b07-plan");
out.left = {
manage: byText(section, "BUTTON", "평면도 관리").length,
sheetButtons: section.querySelectorAll(".b07-drawing-button").map((b) => b.dataset.drawingId),
formFields: all(section).filter((n) => ["SELECT", "INPUT", "LABEL"].includes(n.tagName)).length,
remove: byText(section, "BUTTON", "×").length,
texts: section.textContent,
};
// 단추 → 모달
byText(section, "BUTTON", "평면도 관리")[0].click();
await settle();
const modal = modals[0];
const cad = cads[0];
const kindRows = () =>
modal.body
.querySelectorAll(".b07-plan-manage__kinds .b07-plan-kind__head")
.map((row) => row.dataset.planKind);
const sheetIds = () =>
modal.body
.querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button")
.map((b) => b.dataset.drawingId);
const kindPick = () => selects.filter((s) => s.label === "종류").at(-1);
const action = () =>
all(modal.body).find(
(n) => n.tagName === "BUTTON" && ["추가", "업데이트"].includes(n.textContent),
);
out.open = {
title: modal.opts.title,
dialogClass: modal.opts.dialogClass,
kinds: kindRows(),
sheets: sheetIds(),
hasKindForm: !!kindPick(),
hasMarks: all(modal.body).some((n) => n.textContent === "대피소 · 사토장 자리"),
preview: cad.loads.at(-1).drawing.entities[0].id,
extra: cad.loads.at(-1).extra,
};
// 종류 추가 — 「새 종류」 · 이름 · 조각 하나
kindPick().change("");
all(modal.body).find((n) => n.tagName === "INPUT" && n.placeholder === "종류 이름").value =
"sub6임시";
action().click();
await settle();
await settle();
out.add = { kinds: kindRows(), sheets: sheetIds(), call: calls.find((c) => c[0] === "add") };
// 업데이트 — 새 종류를 골라 이름 · 축척 바꿈
kindPick().change("k_new");
out.updateLabel = action().textContent;
all(modal.body).find((n) => n.tagName === "INPUT" && n.placeholder === "종류 이름").value =
"sub6임시2";
selects.filter((s) => s.label === "축척").at(-1).select.value = "1000";
action().click();
await settle();
await settle();
out.update = {
call: calls.find((c) => c[0] === "update"),
rowText: modal.body
.querySelectorAll(".b07-plan-manage__kinds .b07-plan-kind__head")
.find((row) => row.dataset.planKind === "k_new").textContent,
};
// 지우기 — 그 줄의 ×
const row = modal.body
.querySelectorAll(".b07-plan-manage__kinds .b07-plan-kind__head")
.find((r) => r.dataset.planKind === "k_new");
byText(row, "BUTTON", "×")[0].click();
await settle();
await settle();
out.remove = {
kinds: kindRows(),
call: calls.find((c) => c[0] === "delete"),
confirms: confirms.length,
};
// 미리보기 장 바꾸기 — 「지형 2장」 → 「노선배치도」(확정 → [확정 풀기])
const pickSheet = async (id) => {
modal.body
.querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button")
.find((b) => b.dataset.drawingId === id)
.click();
await settle();
};
await pickSheet("plan_k_terrain_2");
const head = () => modal.body.querySelector(".b07-plan-manage__head");
out.sheet2 = {
preview: cad.loads.at(-1).drawing.entities[0].id,
head: head().textContent,
active: modal.body
.querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button")
.filter((b) => b.dataset.active === "true")
.map((b) => b.dataset.drawingId),
};
await pickSheet("plan_k_route");
out.routeHead = head().textContent;
byText(head(), "BUTTON", "확정 풀기")[0].click();
await settle();
await settle();
out.release = {
call: calls.find((c) => c[0] === "invalidate"),
head: head().textContent,
preview: cad.loads.at(-1).drawing.entities[0].id,
};
// 닫기 — 고친 게 있으니 changed = true · 미리보기 듣기 멈춤
modal.opts.beforeClose();
out.close = { closes: [...closes], destroyed: cad.destroyed, open: modal.open };
// 다시 열어 고치지 않고 [CAD 에서 열기] → changed = false · 그 장 id
byText(section, "BUTTON", "평면도 관리")[0].click();
await settle();
await settle();
const modal2 = modals[1];
modal2.body
.querySelectorAll(".b07-plan-manage__sheets .b07-drawing-button")
.find((b) => b.dataset.drawingId === "plan_k_route")
.click();
await settle();
byText(modal2.body.querySelector(".b07-plan-manage__head"), "BUTTON", "CAD 에서 열기")[0].click();
out.openInCad = { closes: closes.slice(1), destroyed: cads[1].destroyed, open: modal2.open };
out.toasts = toasts;
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error && error.stack ? error.stack : error));
process.exit(1);
});
@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
"""130-1 B07 [평면도 관리] 모달.
못박는 것
- 좌측 「평면도」 = 장 단추 + [평면도 관리] 하나 · 종류 폼 · 대피소 칸 · × 없음.
- 단추 → 공용 모달(큰 창) · 안에 종류 목록 · 종류 폼 · 대피소 · 사토장 자리 · 장 목록 · 첫 장 미리보기(보기 전용).
- 종류 추가 · 업데이트(축척 포함) · 지우기 = 지금 서버 길 · 고칠 때마다 모달 안을 다시 받음.
- 미리보기 장 바꾸기 · 확정이면 [확정 풀기](invalidate) · 닫으면 changed=True · [CAD 에서 열기] = 그 장 id.
Node 헬퍼 `helper_130_b07_plan_manage.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_130_b07_plan_manage.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):
left = out["left"]
assert left["manage"] == 1
assert left["sheetButtons"] == ["plan_k_terrain", "plan_k_terrain_2", "plan_k_route"]
assert left["formFields"] == 0 # 드롭다운 · 이름 칸 · 체크박스 없음
assert left["remove"] == 0
assert "대피소" not in left["texts"] and "종류 추가" not in left["texts"]
def test_단추를_누르면_모달(out):
opened = out["open"]
assert opened["title"] == "평면도 관리"
assert opened["dialogClass"] == "b07-plan-manage"
assert opened["kinds"] == ["k_terrain", "k_route"]
assert opened["sheets"] == ["plan_k_terrain", "plan_k_terrain_2", "plan_k_route"]
assert opened["hasKindForm"] and opened["hasMarks"]
def test_첫_장_미리보기는_보기_전용(out):
assert out["open"]["preview"] == "plan_k_terrain"
extra = out["open"]["extra"]
assert extra["readOnly"] is True and extra["viewOnly"] is True
assert extra["recoveryScope"] == "b07-plan-preview" # 본 CAD 자동백업 칸과 안 겹침
def test_종류_추가(out):
assert out["add"]["call"] == ["add", "sub6임시", [], "plan", 1200]
assert out["add"]["kinds"][-1] == "k_new"
assert out["add"]["sheets"][-1] == "plan_k_new" # 장 목록도 다시 받음
def test_종류_업데이트(out):
assert out["updateLabel"] == "업데이트"
assert out["update"]["call"] == ["update", "k_new", "sub6임시2", [], 1000]
assert out["update"]["rowText"].startswith("sub6임시2 · 1장")
def test_종류_지우기(out):
assert out["remove"]["call"] == ["delete", "k_new"]
assert out["remove"]["confirms"] == 1
assert out["remove"]["kinds"] == ["k_terrain", "k_route"]
def test_미리보기_장_바꾸기(out):
s2 = out["sheet2"]
assert s2["preview"] == "plan_k_terrain_2"
assert s2["active"] == ["plan_k_terrain_2"]
assert "확정 전" in s2["head"] and "확정 풀기" not in s2["head"]
def test_확정이면_확정_풀기(out):
assert "확정 풀기" in out["routeHead"]
rel = out["release"]
assert rel["call"] == ["invalidate", "plan_k_route"]
assert "확정 풀기" not in rel["head"]
assert rel["preview"] == "plan_k_route" # 보던 장 그대로
def test_닫으면_좌측_다시_읽기_신호(out):
assert out["close"] == {"closes": [[True, None]], "destroyed": True, "open": False}
def test_CAD_에서_열기는_그_장(out):
assert out["openInCad"] == {
"closes": [[False, "plan_k_route"]],
"destroyed": True,
"open": False,
}
assert out["toasts"] == []