feat(484 B07 설정): 좌측 ⚙ 설정 모달 · [현재 도면 확정] · [수정] 지움 · 저장 후 이동(481-3) · 탭 줄 겹판 자리

- 공용 createPanelCard 곁값 onSettings — 캐럿 왼쪽 ⚙ · 누름이 접힘 안 일으킴(B08 · B09 그대로)
- 평면도 ⚙ = 평면도 관리 모달([평면도 관리] 단추 지움) · 횡단도 ⚙ = 배치 칸 · 표 배율 · 안내선(카드 밑 칸 지움) · 구조물 ⚙ = 「M02 그림 — 축척 없음」 · 종단 1장 · 토적 = 단추 안 ⚙ · 표지 없음
- ⚙ 머리 [원본에서 다시 그리기] — 그 묶음 도면을 연 때만 · 저장본이면 invalidate
- 잠금 없음(요구 281) — 읽기 전용 안 묶음 · 자동 B08 이동 · 「모든 횡단 확정」 잠금 지움
- onStepClick 한 길 = 고친 탭(●) + 지금 탭 저장 → B08 · B09 면 단계 4 완료(새 POST design-drawings-complete) → 이동 · 실패 = 머묾
- 저장 · 원본 묶음 = 새 B07_DesignDetail_UI_Save.ts · 모달 = 새 B07_DesignDetail_UI_Settings.ts(UI_Page 633 → 543줄)
- 탭 줄 = .cad-host 의 --cad-tab-slot-left/right(px · CSS left · right 꼴) 있으면 리본 빈 자리에 겹침 · 없으면 지금 자리
- 시험: 새 test_280_5_b07_settings · 130 · 153-1 · 180-3 · 212-3 · 269 · 311 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 22:13:04 +09:00
co-authored by Claude Opus 5.5
parent afe6e4a799
commit 659baa7751
23 changed files with 1258 additions and 285 deletions
@@ -186,6 +186,11 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P
);
}
/** B07 단계(4) 완료(281) — 페이지 이동 저장 뒤 B08 · B09 로 갈 때(확정 잠금 없음). */
export function completeDesignStage(projectId: string): Promise<void> {
return requestJson(`/projects/${projectId}/design-drawings-complete`, { method: "POST" });
}
/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3) · 표 · 글 배율(180-2). */
export interface CrossLayout {
default: [number, number];
@@ -557,6 +557,37 @@ async def invalidate_design_drawing(
)
@router.post("/{project_id}/design-drawings-complete")
async def complete_design_stage(project_id: UUID) -> Any:
"""B07 단계 완료 — 페이지 이동 저장(고친 도면만) 뒤 B08 · B09 로 갈 때(요구 281 · 확정 잠금 없음).
안 고친 도면은 늘 원본에서 새로 그리므로 「모든 횡단 장 확정」을 기다리지 않는다.
"""
try:
await _confirmed_source(project_id)
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), _B07_STAGE_NO)
await connection.commit()
except Exception:
await connection.rollback()
raise
return {"status": "success", "project_id": str(project_id)}
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except PermissionError as exc:
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B07 단계 완료 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "상세 설계 단계를 완료하지 못했습니다."},
)
class CrossLayoutRequest(BaseModel):
"""횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3) · 표 · 글 배율(180-2)."""
@@ -20,8 +20,9 @@ export function buildDevelopmentSection(
items: DesignDrawingItem[],
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
collapsed = true,
onSettings?: () => void,
): HTMLElement {
const section = createPanelCard(`전개도 ${items.length}`, collapsed);
const section = createPanelCard(`전개도 ${items.length}`, collapsed, onSettings);
section.classList.add("b07-drawing-group");
section.dataset.kind = "development";
if (items.length === 0) {
+124 -218
View File
@@ -12,17 +12,11 @@
import "./B07_DesignDetail_UI_Style.css";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
// 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04).
import {
buildCrossLayoutControls,
buildDesignInfoPanel,
buildDrawingSidePanel,
foldedGroups,
@@ -36,14 +30,13 @@ import {
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import {
confirmDesignDrawing,
completeDesignStage,
exportDrawing,
fetchCrossLayout,
fetchDesignDrawing,
fetchDesignDrawingList,
fetchFrameTemplate,
importFrameTemplate,
invalidateDesignDrawing,
resetFrameTemplate,
saveCrossLayout,
saveFrameTemplate,
@@ -55,6 +48,8 @@ import {
} from "./B07_DesignDetail_Api_Fetch";
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
import { buildGuideToggle, guideShown } from "./B07_DesignDetail_UI_GuideToggle";
import { hasRedrawable, redrawFromSource, saveEditedDrawings } from "./B07_DesignDetail_UI_Save";
import { openDrawingSettings } from "./B07_DesignDetail_UI_Settings";
// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3).
import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host";
import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit";
@@ -67,7 +62,7 @@ interface DesignMeta {
drawingId: string;
title: string;
info: string;
/** 확정본은 CAD에서 읽기 전용이 된다. 푸는 길은 [수정] 버튼 하나뿐. */
/** 참이면 CAD 읽기 전용 — 잠금 없음(요구 281)이라 늘 거짓 · 저장본도 그대로 고침. */
confirmed: boolean;
quantityTable: QuantityTable | null;
hasPrev: boolean;
@@ -116,9 +111,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const cad = createCadHost<CadDrawing, DesignMeta, QuantityTable>({
title: L("B07_Design_Title"),
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
// 편집 통지는 **미저장 표시**(탭 ●)만 세운다 — 저장은 페이지를 옮길 때(요구 281).
onChanged: (dirty) => {
if (frameEditor.isEditing()) return;
cadDirty = dirty;
@@ -135,18 +128,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
let currentDrawing: DesignDrawingItem | undefined;
let currentIndex = -1;
let currentConfirmed = false;
// 단계 완료 기준은 횡단도만 본다 (종단도 확정 여부는 다음 단계 진행과 무관).
const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross";
let allDrawingsConfirmed =
drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed);
let drawingListEl: HTMLElement | undefined;
const infoPanelHost = document.createElement("div");
infoPanelHost.className = "b07-info-host";
// 표준도 제원 칸·[여기서 고치기]는 2026-09-13 B08 「구조물도」 탭으로 이관(PLAN 3장 ④).
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
void fillCrossLayout(drawing);
guideToggle.disabled = !isCrossSheet(drawing); // 장의 안내선만
if (drawing.kind !== "cross") {
infoPanelHost.replaceChildren();
return;
@@ -159,61 +147,28 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
);
};
/** 장 배치 칸 자리(153-1) — 횡단도 묶음 맨 아래 · 정보 칸을 다시 그려도 남음. */
const crossLayoutSlot = document.createElement("div");
crossLayoutSlot.className = "b07-cross-layout-slot";
// 배치 칸을 못 받아도 안내선 단추는 남음 · 받으면 같은 줄 끝에 붙음
const guideOnly = document.createElement("div");
guideOnly.className = "b07-cross-layout";
guideOnly.append(guideToggle);
crossLayoutSlot.append(guideOnly);
let crossLayoutRequest: Promise<CrossLayout> | undefined;
const crossLayout = (): Promise<CrossLayout> =>
(crossLayoutRequest ??= fetchCrossLayout(projectId as string)).catch((error) => {
crossLayoutRequest = undefined; // 실패는 남기지 않음 — 다음 ⚙ 에 다시 받음
throw error;
});
/** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. 장 아니면 기본값 · [이 장] 꺼짐. */
const fillCrossLayout = async (drawing: DesignDrawingItem) => {
guideToggle.disabled = !isCrossSheet(drawing); // 장의 안내선만
if (!projectId) return;
let layout: CrossLayout;
/** 장 배치 저장(140-3 · ⚙ 횡단 모달) — 서버 쓰기 → 화면 다시 세움 · 같은 장 번호를 다시 엶. */
const saveCrossLayoutAndRebuild = async (next: CrossLayout, sheet: number) => {
if (!projectId || !mayDiscardEdits()) return;
showLoadingOverlay();
try {
layout = await (crossLayoutRequest ??= fetchCrossLayout(projectId));
} catch {
crossLayoutRequest = undefined;
return; // 배치 칸만 빠짐 — 도면 보기는 그대로
await saveCrossLayout(projectId, next);
reopenCrossSheet = sheet || undefined;
reopenDrawingId = sheet ? undefined : currentDrawing?.id;
cad.destroy();
void renderB07DesignDetail(root);
} catch (error) {
showToast(error instanceof Error ? error.message : "배치를 저장하지 못했습니다.", "error");
} finally {
hideLoadingOverlay();
}
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 || undefined;
reopenDrawingId = number ? undefined : currentDrawing?.id;
cad.destroy();
void renderB07DesignDetail(root);
} catch (error) {
showToast(error instanceof Error ? error.message : "배치를 저장하지 못했습니다.", "error");
} finally {
hideLoadingOverlay();
}
};
const controls = buildCrossLayoutControls(
(number && layout.sheets[String(number)]) || layout.default,
number
? (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } })
: null,
(grid) => void save({ ...layout, default: grid, sheets: {} }),
{
scale: {
value: layout.table_scale ?? 1,
onApply: (scale) => void save({ ...layout, table_scale: scale }),
},
},
);
controls.append(guideToggle);
crossLayoutSlot.replaceChildren(controls);
};
/**
@@ -223,28 +178,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
*/
let cadDirty = false;
/**
* 확정·미확정에 따라 버튼 한 자리를 바꾼다 (2026-09-01 사용자 확정).
* 미확정이면 [현재 도면 확정], 확정이면 [수정] — 확정을 푸는 유일한 길이다.
*/
const confirmButton = createButton({
label: "현재 도면 확정",
variant: "filled",
onClick: () => {
if (currentConfirmed) void reopenCurrentDrawing();
else void confirmCurrentDrawing();
},
});
confirmButton.disabled = true;
const applyConfirmButtonState = (): void => {
confirmButton.textContent = currentConfirmed ? "수정" : "현재 도면 확정";
confirmButton.title = currentConfirmed
? "확정을 풀고 이 도면을 고칩니다."
: "이 도면을 확정하고 저장합니다.";
confirmButton.disabled = !currentDrawing || currentDrawing.kind === "blank";
};
const findButton = (drawingId: string) =>
drawingListEl?.querySelector<HTMLButtonElement>(
`.b07-drawing-button[data-drawing-id="${drawingId}"]`,
@@ -265,7 +198,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
drawingId: drawing.id,
title: drawing.label,
info: drawing.kind === "cross" ? drawing.label : "",
confirmed: response.confirmed,
confirmed: false, // 잠금 없음(요구 281)
quantityTable: response.quantity_table ?? null,
hasPrev: index > 0,
hasNext: index < drawings.length - 1,
@@ -328,7 +261,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
return window.confirm(
"저장하지 않은 편집이 있습니다.\n" +
"지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" +
"남기려면 [취소]를 누르고 [현재 도면 확정]으로 저장하세요.",
"남기려면 [취소] — 고친 도면은 페이지를 옮길 때([<] [>]) 저장됩니다.",
);
};
@@ -347,9 +280,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const response = await requestDrawing(drawing);
currentDrawing = drawing;
currentIndex = index;
currentConfirmed = response.confirmed;
cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만
applyConfirmButtonState();
updateInfoPanel(drawing, response);
// 가이드 = 작도 영역 점선 · 넘친 칸 붉은 틀(169-1 · 169-2 · 보기만)
const guide = response.guide ?? null;
@@ -360,17 +291,17 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
}
if (response.drawing.stale_layout) {
showToast(
"옛 배치로 확정된 장입니다. [수정]을 누르면 표를 칸 아래 가운데에 맞춘 새 배치로 다시 만듭니다.",
"옛 배치로 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 표를 칸 아래 가운데에 맞춘 새 배치로 다시 만듭니다.",
"info",
);
} else if (response.drawing.stale_scale) {
showToast(
"옛 표 배율로 확정된 장입니다. [수정]을 누르면 지금 배율로 다시 만듭니다.",
"옛 표 배율로 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 지금 배율로 다시 만듭니다.",
"info",
);
} else if (response.drawing.stale_layers) {
showToast(
"측점 도면층 앞에 확정된 장입니다. [수정]을 누르면 측점마다 층(횡단 No.)으로 다시 만듭니다.",
"측점 도면층 앞에 저장된 장입니다. ⚙ [원본에서 다시 그리기]로 측점마다 층(횡단 No.)으로 다시 만듭니다.",
"info",
);
}
@@ -418,85 +349,19 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const requestCadDrawing = (): Promise<SaveResult> => cad.requestSave();
async function confirmCurrentDrawing(): Promise<void> {
if (!projectId || !currentDrawing) return;
showLoadingOverlay();
try {
const saved = await requestCadDrawing();
const result = await confirmDesignDrawing(
projectId,
currentDrawing.id,
saved.drawing,
currentDrawing.kind === "cross" ? (saved.quantityTable ?? null) : null,
);
currentConfirmed = true;
currentDrawing.confirmed = true;
cadDirty = false; // 저장했다
const savedTab = tabs.active();
if (savedTab) tabs.markClean(savedTab); // 받아 둔 편집본은 저장됨
applyConfirmButtonState();
drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다.
const button = findButton(currentDrawing.id);
if (button) button.dataset.confirmed = "true";
// 저장본을 다시 실어 CAD를 읽기 전용으로 돌린다 — 확정한 도면은 고칠 수 없다.
// **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직
// 편집이 열린 어긋난 순간이 생긴다.
await loadDrawing(currentDrawing, currentIndex);
// 확정한 설계(B06 정본 그대로 · 상태만 확정)로 지반/계획 정보 패널을 갱신한다.
if (currentDrawing.kind === "cross") {
infoPanelHost.replaceChildren(
buildDesignInfoPanel(
currentDrawing.label,
result.design ?? null,
isCrossSheet(currentDrawing) ? "sheet" : "station",
),
);
}
showToast("현재 도면을 확정하고 저장했습니다.", "success");
if (result.all_confirmed) {
allDrawingsConfirmed = true;
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[5]);
}
} catch (error) {
showToast(
error instanceof Error ? error.message : "현재 도면을 확정하지 못했습니다.",
"error",
);
} finally {
hideLoadingOverlay();
}
}
/**
* [수정] — 확정을 풀고 다시 고칠 수 있게 한다. **확정이 풀리는 유일한 길**이다
* (2026-09-01 사용자 확정). 예전에는 CAD의 편집 통지가 확정을 풀어서, 되돌리기나
* 색 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
*/
async function reopenCurrentDrawing(): Promise<void> {
if (!projectId || !currentDrawing || !currentConfirmed) return;
showLoadingOverlay();
try {
await invalidateDesignDrawing(projectId, currentDrawing.id);
drawingCache.delete(currentDrawing.id);
currentConfirmed = false;
allDrawingsConfirmed = false;
currentDrawing.confirmed = false;
const button = findButton(currentDrawing.id);
if (button) button.dataset.confirmed = "false";
applyConfirmButtonState();
// 확정을 풀면 서버가 원본에서 다시 그린다 — 그 도면을 실어야 편집이 열린다.
await loadDrawing(currentDrawing, currentIndex);
showToast("확정을 풀었습니다. 고친 뒤 다시 확정하세요.", "info");
} catch (error) {
showToast(
error instanceof Error ? error.message : "도면 확정 상태를 되돌리지 못했습니다.",
"error",
);
} finally {
hideLoadingOverlay();
}
}
/** 저장 · 원본 되돌림 묶음(`_UI_Save`)이 쓰는 상태 길. */
const store = {
projectId: projectId as string,
drawings,
tabs,
current: () => currentDrawing,
cadDirty: () => cadDirty,
setCadClean: () => void (cadDirty = false),
requestCad: requestCadDrawing,
isFrameEditing: () => frameEditor.isEditing(),
forget: (id: string) => void drawingCache.delete(id),
reload: (drawing: DesignDrawingItem) => loadDrawing(drawing, drawings.indexOf(drawing)),
};
/**
* 지금 보고 있는 도면을 DXF·DWG 파일로 내려받는다 (2026-09-06 사용자 지시).
@@ -545,49 +410,98 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
});
/** [평면도 관리] 모달을 닫음 — 고쳤으면 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다. */
const onPlanClosed = (changed: boolean, openId?: string): void => {
if (!changed) {
const target = drawings.find((item) => item.id === openId);
if (target) selectDrawing(target);
return;
}
if (!mayDiscardEdits()) return;
reopenDrawingId = openId ?? currentDrawing?.id;
cad.destroy();
void renderB07DesignDetail(root);
};
// 묶음 ⚙(280-5) — 평면도 = 평면도 관리 · 횡단도 = 장 배치 · 안내선 · 머리 [원본에서 다시 그리기]
const openSettings = (kind: DesignDrawingItem["kind"]): void =>
openDrawingSettings(kind, {
drawings,
current: () => currentDrawing,
canRedraw: () => hasRedrawable(store),
redraw: () => redrawFromSource(store),
plan: projectId ? { projectId, onClosed: onPlanClosed } : undefined,
cross: {
layout: crossLayout,
save: (next, sheet) => void saveCrossLayoutAndRebuild(next, sheet),
guideToggle,
},
});
const drawingPanel = buildDrawingSidePanel(
drawings,
selectDrawing,
drawingError,
devBypass,
projectId
? {
projectId,
// [평면도 관리] 모달을 닫음 — 고쳤으면 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다.
onClosed: (changed, openId) => {
if (!changed) {
const target = drawings.find((item) => item.id === openId);
if (target) selectDrawing(target);
return;
}
if (!mayDiscardEdits()) return;
reopenDrawingId = openId ?? currentDrawing?.id;
cad.destroy();
void renderB07DesignDetail(root);
},
}
: undefined,
crossLayoutSlot,
projectId ? openSettings : undefined,
folded,
);
drawingListEl = drawingPanel;
const confirmActions = document.createElement("div");
const actions = document.createElement("div");
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
// 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통).
confirmActions.className = "b07-drawing-actions ui-sidebar-actions";
// 액션 칸은 두 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집]·[현재 도면 확정]
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김).
const confirmButtonRow = document.createElement("div");
confirmButtonRow.className = "b07-drawing-actions__row";
confirmButtonRow.dataset.helpRow = "";
confirmButtonRow.append(frameEditor.button, confirmButton);
confirmActions.append(frameEditor.banner, confirmButtonRow);
actions.className = "b07-drawing-actions ui-sidebar-actions";
// 액션 칸은 두 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집]
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김 · 확정 단추는 281 에 지움).
const actionRow = document.createElement("div");
actionRow.className = "b07-drawing-actions__row";
actionRow.dataset.helpRow = "";
actionRow.append(frameEditor.button);
actions.append(frameEditor.banner, actionRow);
drawingPanel.append(infoPanelHost, confirmActions);
drawingPanel.append(infoPanelHost, actions);
const cadStack = document.createElement("div");
cadStack.className = "b07-cad-stack";
cadStack.append(tabs.element, cad.element);
// 탭 줄 겹판(280-6) — CAD 가 리본 [출력] 오른쪽 빈 자리를 `.cad-host` 에 `--cad-tab-slot-left/right`
// (px · CSS left · right 꼴)로 적으면 그 자리에 겹침 · 값이 없으면 CAD 위 지금 자리.
const placeTabs = (): void => {
const slotted = cad.element.style.getPropertyValue("--cad-tab-slot-left") !== "";
if (slotted === (tabs.element.parentElement === cad.element)) return;
if (slotted) cad.element.append(tabs.element);
else cadStack.prepend(tabs.element);
};
new MutationObserver(placeTabs).observe(cad.element, {
attributes: true,
attributeFilter: ["style"],
});
placeTabs();
/** 페이지 이동(281) — 고친 도면 저장 → B08 · B09 면 단계 4 완료 → 이동 · 실패면 머묾. */
let moving = false;
const moveToStage = async (stepIndex: number): Promise<void> => {
if (!projectId || moving) return;
moving = true;
try {
if (!(await saveEditedDrawings(store))) return;
if (stepIndex > 4 && drawings.length > 0) {
try {
await completeDesignStage(projectId);
} catch (error) {
showToast(
error instanceof Error ? error.message : "상세 설계 단계를 완료하지 못했습니다.",
"error",
);
return;
}
}
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
} finally {
moving = false;
}
};
const layout = createWorkflowLayout({
title: L("B07_Design_Title"),
steps: workflowSteps(),
@@ -595,19 +509,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
leftPanel: drawingPanel,
mainContent: cadStack,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
// B08 = 옮길 때 단계 4 를 완료하므로 운영에서 막힌 상태여도 켬(281) — 공용 판은 이 단계를 켬
currentStage: 5,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (!projectId) return;
// ⚠ 개발에서는 막지 않는다 (2026-09-09 사용자 지시) — B08·B09 를 확정 전에 봐야
// 화면 검증이 되고, 그 우회 단추가 **B08 안에** 있어 여기서 막히면 닿을 길이 없다.
// 운영에서는 그대로 막는다.
if (stepIndex > 5 && !allDrawingsConfirmed && !import.meta.env.DEV) {
showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning");
return;
}
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
// ☰ · 진행 단계 판 · [<] [>] 한 길 — 저장 후 이동(옛 「모든 횡단 확정」 잠금 · 자동 B08 이동 없음)
onStepClick: (stepIndex) => void moveToStage(stepIndex),
});
layout.root.classList.add("b07-design-layout");
root.replaceChildren(layout.root);
+42 -15
View File
@@ -12,7 +12,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
import { buildDevelopmentSection } from "./B07_DesignDetail_UI_Development";
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];
@@ -60,9 +59,8 @@ export function buildDrawingSidePanel(
onSelect: (drawing: DesignDrawingItem) => void,
errorMessage?: string,
devBypass = false,
plan?: PlanManageControls,
/** 횡단도 묶음 맨 아래에 붙일 자리(153-1 장 배치 칸) — 내용은 화면 조립이 도면을 열 때마다 채움. */
crossTools?: HTMLElement,
/** 묶음 ⚙(280-5) — 카드 제목 · 한 장 단추 안 오른쪽 · 표지 · 빈 도각은 없음. */
onSettings?: (kind: DesignDrawingItem["kind"]) => void,
/** 311 다시 세울 때 옛 접힘(`foldedGroups`) — 없는 묶음은 기본값. */
folded = new Map<string, boolean>(),
): HTMLDivElement {
@@ -107,24 +105,26 @@ export function buildDrawingSidePanel(
button.addEventListener("click", () => onSelect(drawing));
return button;
};
const settingsOf = (kind: DesignDrawingItem["kind"]) =>
onSettings && kind !== "cover" && kind !== "blank" ? () => onSettings(kind) : undefined;
for (const group of DRAWING_GROUPS) {
if (group.kind === "plan") {
// 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다.
const plans = drawings.filter((item) => item.kind === "plan");
panel.append(
buildPlanSection(
plans,
drawingButton,
plan ? () => openPlanManager(plan) : undefined,
isFolded("plan"),
),
);
panel.append(buildPlanSection(plans, drawingButton, settingsOf("plan"), isFolded("plan")));
continue;
}
if (group.kind === "development") {
const sheets = drawings.filter((item) => item.kind === "development");
panel.append(buildDevelopmentSection(sheets, drawingButton, isFolded("development")));
panel.append(
buildDevelopmentSection(
sheets,
drawingButton,
isFolded("development"),
settingsOf("development"),
),
);
continue;
}
const items = group.kind
@@ -158,11 +158,17 @@ export function buildDrawingSidePanel(
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
button.dataset.pending = String(drawing.kind === "blank");
if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다";
const settings = settingsOf(drawing.kind);
if (settings) button.append(buttonGear(settings));
panel.append(button);
continue;
}
// 컨테이너 = 좌측 패널 공용 카드(269 · B05/B06 「구조물 배치」 양식).
const section = createPanelCard(`${group.label} ${items.length}`, isFolded(group.kind ?? ""));
const section = createPanelCard(
`${group.label} ${items.length}`,
isFolded(group.kind ?? ""),
group.kind && settingsOf(group.kind),
);
section.classList.add("b07-drawing-group");
if (group.kind) section.dataset.kind = group.kind;
if (cross) {
@@ -180,13 +186,34 @@ export function buildDrawingSidePanel(
empty.textContent = "B05 · B06 구조물 배치에 연결된 구조물 도면이 없습니다.";
section.append(empty);
}
if (cross && crossTools) section.append(crossTools);
panel.append(section);
}
attachCollapsible(panel);
return panel;
}
/** 한 장 단추 안 오른쪽 ⚙(280-5) — 단추 안 단추 금지라 span · 누름 · Enter 가 도면 열기로 안 올라감. */
function buttonGear(onSettings: () => void): HTMLElement {
const gear = document.createElement("span");
gear.className = "b07-drawing-button__settings";
gear.setAttribute("role", "button");
gear.tabIndex = 0;
gear.textContent = "⚙";
gear.title = "설정";
gear.setAttribute("aria-label", "설정");
gear.addEventListener("click", (event) => {
event.stopPropagation();
onSettings();
});
gear.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
onSettings();
});
return gear;
}
const GROUND_TYPE_LABEL: Record<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = {
soil: "B06_Design_Ground_Soil",
ripping_rock: "B06_Design_Ground_Ripping",
@@ -252,7 +252,7 @@ export function groupPlanSheets(
});
}
/** 「평면도」 묶음 — 종류마다 이름 줄 + 장 단추, 아래에 [평면도 관리](130-1). */
/** 「평면도」 묶음 — 종류마다 이름 줄 + 장 단추 · 제목 ⚙ = 평면도 관리 모달(130-1 · 280-5). */
export function buildPlanSection(
items: DesignDrawingItem[],
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
@@ -260,7 +260,7 @@ export function buildPlanSection(
collapsed = true,
): HTMLElement {
// 컨테이너 = 좌측 패널 공용 카드(269)
const section = createPanelCard(`평면도 ${items.length}`, collapsed);
const section = createPanelCard(`평면도 ${items.length}`, collapsed, onManage);
section.classList.add("b07-drawing-group", "b07-plan");
section.dataset.kind = "plan";
@@ -273,13 +273,5 @@ export function buildPlanSection(
block.append(...buttons);
section.append(block);
}
if (onManage) {
// 종류 · 대피소 자리 · 장 미리보기는 모달 한 곳(좌측 공간 · 접근 혼돈 — 130-1).
const manage = el("button", "ui-panel-btn b07-plan-manage-open", "평면도 관리");
manage.type = "button";
manage.addEventListener("click", onManage);
section.append(manage);
}
return section;
}
@@ -34,6 +34,8 @@ export interface PlanManageControls {
projectId: string;
/** 모달을 닫음 — changed = 종류 · 자리 · 확정을 고쳤나 · openId = [CAD 에서 열기]로 고른 장. */
onClosed: (changed: boolean, openId?: string) => void;
/** 머리 단추 — ⚙ 로 열 때 [원본에서 다시 그리기](280-5). */
actions?: HTMLElement[];
}
function el<K extends keyof HTMLElementTagNameMap>(
@@ -54,7 +56,7 @@ function button(className: string, text: string, onClick: () => void): HTMLButto
return node;
}
export function openPlanManager(controls: PlanManageControls): void {
export function openPlanManager(controls: PlanManageControls): { close: () => void } {
const { projectId } = controls;
let changed = false;
let closed = false;
@@ -240,6 +242,7 @@ export function openPlanManager(controls: PlanManageControls): void {
title: "평면도 관리",
closeLabel: "닫기",
dialogClass: "b07-plan-manage",
actions: controls.actions,
beforeClose: () => {
finish();
return true;
@@ -252,4 +255,5 @@ export function openPlanManager(controls: PlanManageControls): void {
void refresh();
},
});
return { close: () => finish() };
}
@@ -0,0 +1,147 @@
/* =============================================================================
* B07_DesignDetail_UI_Save.ts
* 상세 설계 저장 · 원본 되돌림 묶음(요구 280-5 · 280-6 · 281) — `_UI_Page.ts` 가 700줄에 닿아 뗌.
*
* 저장 = 페이지를 옮길 때만([<] [>] · ☰ · 진행 단계 판 — `onStepClick` 한 길).
* 고친 탭(●) + 지금 탭을 `confirm` 길로 · 안 고친 도면은 보내지 않음(늘 원본에서 새로 그림).
* 잠금 없음 — 저장본도 그대로 고침 · 다시 옮길 때 다시 저장.
* 원본 되돌림 = ⚙ 모달 [원본에서 다시 그리기] — 저장본이면 `invalidate` · 고친 것 버리고 다시 실음.
* ========================================================================== */
import {
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import type { CadSaveResult } from "@ui/cad_host/cad_host";
import type { CadTabs } from "@ui/cad_host/cad_host_tabs";
import {
confirmDesignDrawing,
invalidateDesignDrawing,
type CadDrawing,
type DesignDrawingItem,
type QuantityTable,
} from "./B07_DesignDetail_Api_Fetch";
/** 화면 조립이 쥔 상태를 읽고 쓰는 길 — 이 묶음은 상태를 갖지 않음. */
export interface DrawingStore {
projectId: string;
drawings: DesignDrawingItem[];
tabs: CadTabs<CadDrawing>;
current: () => DesignDrawingItem | undefined;
/** CAD 에 저장 안 한 편집이 있나 · 지움 */
cadDirty: () => boolean;
setCadClean: () => void;
requestCad: () => Promise<CadSaveResult<CadDrawing, QuantityTable>>;
isFrameEditing: () => boolean;
/** 도면 캐시에서 뺌 — 다음 열기에 서버에서 다시 받음 */
forget: (id: string) => void;
/** 그 도면을 서버에서 다시 받아 실음 */
reload: (drawing: DesignDrawingItem) => Promise<void>;
}
/** 좌측 단추 저장본 띠(초록) — 저장본 = 서버 `confirmed`. */
function markButton(id: string, saved: boolean): void {
document
.querySelectorAll<HTMLElement>(`.b07-drawing-button[data-drawing-id="${id}"]`)
.forEach((button) => (button.dataset.confirmed = String(saved)));
}
/**
* 고친 도면을 저장 — 지금 탭(CAD 에서 받음 · 횡단은 수량표 같이) + 다른 고친 탭(받아 둔 편집본).
* 고친 것 없으면 요청 없이 참 · 하나라도 실패하면 알림 뒤 거짓(부르는 쪽은 머묾).
*/
export async function saveEditedDrawings(store: DrawingStore): Promise<boolean> {
if (store.isFrameEditing()) {
showToast("도각 편집을 마친 뒤 옮기세요.", "warning");
return false;
}
const current = store.current();
const active = store.tabs.active();
const editable = (item: DesignDrawingItem | undefined): item is DesignDrawingItem =>
!!item && item.kind !== "blank";
const currentEdited = editable(current) && (store.cadDirty() || !!active?.dirty);
const others = store.tabs
.tabs()
.filter((tab) => tab !== active && tab.dirty && tab.drawing)
.map((tab) => ({ tab, item: store.drawings.find((item) => item.id === tab.id) }))
.filter(({ item }) => editable(item));
if (!currentEdited && others.length === 0) return true;
const save = async (
item: DesignDrawingItem,
drawing: CadDrawing,
table: QuantityTable | null,
) => {
await confirmDesignDrawing(store.projectId, item.id, drawing, table);
item.confirmed = true;
markButton(item.id, true);
store.forget(item.id); // 저장본이 정본 — 다음 열기는 서버에서
};
showLoadingOverlay();
try {
if (currentEdited && current) {
const saved = await store.requestCad();
await save(
current,
saved.drawing,
current.kind === "cross" ? (saved.quantityTable ?? null) : null,
);
store.setCadClean();
if (active) store.tabs.markClean(active);
}
for (const { tab, item } of others) {
// 횡단 수량표는 서버가 그림의 표 글에서 다시 뽑음(받아 둔 편집본엔 수량표 칸이 없음)
await save(item as DesignDrawingItem, tab.drawing as CadDrawing, null);
store.tabs.markClean(tab);
}
return true;
} catch (error) {
showToast(
`${error instanceof Error ? error.message : "도면을 저장하지 못했습니다."} — 이 화면에 머묾`,
"error",
);
return false;
} finally {
hideLoadingOverlay();
}
}
/** 지금 도면에 버릴 것(저장본 · 고친 것)이 있나 — ⚙ [원본에서 다시 그리기] 켬. */
export function hasRedrawable(store: DrawingStore): boolean {
const current = store.current();
if (!current || current.kind === "blank") return false;
return current.confirmed || store.cadDirty() || !!store.tabs.active()?.dirty;
}
/** ⚙ [원본에서 다시 그리기] — 물은 뒤 저장본 풂(`invalidate`) · 고친 것 버림 · 다시 실음. 해냈으면 참. */
export async function redrawFromSource(store: DrawingStore): Promise<boolean> {
const current = store.current();
if (!current || !hasRedrawable(store)) return false;
const ok = await showConfirmDialog(
`「${current.label}」 의 저장본 · 고친 것을 버리고 원본에서 다시 그릴까요?`,
"다시 그리기",
);
if (!ok) return false;
showLoadingOverlay();
try {
if (current.confirmed) {
await invalidateDesignDrawing(store.projectId, current.id);
current.confirmed = false;
markButton(current.id, false);
}
store.forget(current.id);
store.setCadClean();
const active = store.tabs.active();
if (active) store.tabs.markClean(active);
await store.reload(current);
showToast("원본에서 다시 그렸습니다.", "success");
return true;
} catch (error) {
showToast(error instanceof Error ? error.message : "원본에서 다시 그리지 못했습니다.", "error");
return false;
} finally {
hideLoadingOverlay();
}
}
@@ -0,0 +1,124 @@
/* =============================================================================
* B07_DesignDetail_UI_Settings.ts
* 좌측 도면 묶음 ⚙ 설정 모달(요구 280-5) — 공용 `openModal` · 머리 [원본에서 다시 그리기](281).
*
* 평면도 = 평면도 관리 모달 그대로(`openPlanManager`)
* 횡단도 = 장 배치(열 · 행 · [이 장] · [모든 장]) · 표 배율 · [안내선 숨기기] — 좌측 카드 밑에서 옮김
* 구조물 도면 = 「M02 그림 — 축척 없음」 한 줄 · 종단 · 토적 · 전개도 = 몸 비움(축척 칸은 485-2)
* [원본에서 다시 그리기] = 지금 연 도면이 그 묶음일 때만 켬
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { CrossLayout, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
import {
buildCrossLayoutControls,
DRAWING_GROUPS,
isCrossSheet,
} from "./B07_DesignDetail_UI_Panels";
import { openPlanManager, type PlanManageControls } from "./B07_DesignDetail_UI_PlanManage";
type Kind = DesignDrawingItem["kind"];
export interface DrawingSettingsDeps {
drawings: DesignDrawingItem[];
current: () => DesignDrawingItem | undefined;
/** 지금 도면에 버릴 것(저장본 · 고친 것)이 있나 */
canRedraw: () => boolean;
/** 원본에서 다시 그리기 — 해냈으면 참(모달 닫음) */
redraw: () => Promise<boolean>;
plan?: PlanManageControls;
cross: {
layout: () => Promise<CrossLayout>;
/** 배치 저장 — 모달은 닫힌 뒤 · `sheet` = 지금 장 번호(장 아니면 0) */
save: (next: CrossLayout, sheet: number) => void;
guideToggle: HTMLButtonElement;
};
}
/** ⚙ 묶음에 드는 도면 종류 — 횡단도 카드엔 표준 횡단면도 줄이 같이 있음. */
const memberOf = (kind: Kind): Kind[] => (kind === "cross" ? ["cross", "cross_standard"] : [kind]);
const note = (text: string): HTMLElement => {
const line = document.createElement("p");
line.className = "b07-settings__note";
line.textContent = text;
return line;
};
/** 묶음 `kind` 의 ⚙ 모달을 엶. */
export function openDrawingSettings(kind: Kind, deps: DrawingSettingsDeps): void {
const current = deps.current();
const mine = !!current && memberOf(kind).includes(current.kind);
let closeModal = (): void => undefined;
const redraw = createButton({
label: "원본에서 다시 그리기",
variant: "ghost",
disabled: !(mine && deps.canRedraw()),
onClick: () => {
void deps.redraw().then((done) => done && closeModal());
},
});
redraw.title = !mine
? "이 묶음 도면을 연 뒤 쓸 수 있음"
: redraw.disabled
? "이미 원본 그대로"
: "저장본 · 고친 것을 버리고 B06 원본에서 새로 그림";
redraw.classList.add("b07-settings__redraw");
if (kind === "plan" && deps.plan) {
const manager = openPlanManager({ ...deps.plan, actions: [redraw] });
closeModal = manager.close;
return;
}
const label = DRAWING_GROUPS.find((group) => group.kind === kind)?.label ?? "도면";
openModal({
title: `${label} 설정`,
closeLabel: "닫기",
dialogClass: "b07-settings",
actions: [redraw],
mount: (body, close) => {
closeModal = close;
if (mine) body.append(note(`지금 도면: ${current?.label}`));
if (kind === "structure") body.append(note("M02 그림 — 축척 없음"));
if (kind === "cross") mountCross(body, deps, close);
},
});
}
/** 횡단 모달 몸 — 배치 칸은 받은 뒤 · 안내선 단추는 먼저(배치를 못 받아도 남음). */
function mountCross(body: HTMLElement, deps: DrawingSettingsDeps, close: () => void): void {
const current = deps.current();
const sheet =
current && isCrossSheet(current)
? deps.drawings.filter(isCrossSheet).findIndex((item) => item.id === current.id) + 1
: 0;
const holder = document.createElement("div");
holder.className = "b07-cross-layout";
holder.append(deps.cross.guideToggle);
body.append(holder);
void deps.cross
.layout()
.then((layout) => {
const apply = (next: CrossLayout): void => {
close();
deps.cross.save(next, sheet);
};
const controls = buildCrossLayoutControls(
(sheet && layout.sheets[String(sheet)]) || layout.default,
sheet
? (grid) => apply({ ...layout, sheets: { ...layout.sheets, [String(sheet)]: grid } })
: null,
(grid) => apply({ ...layout, default: grid, sheets: {} }),
{
scale: {
value: layout.table_scale ?? 1,
onApply: (scale) => apply({ ...layout, table_scale: scale }),
},
},
);
controls.append(deps.cross.guideToggle);
holder.replaceWith(controls);
})
.catch(() => holder.prepend(note("배치를 받지 못했습니다.")));
}
+55 -11
View File
@@ -89,6 +89,28 @@
text-overflow: ellipsis;
}
/* 한 장 단추 ⚙(280-5) — 단추 안 오른쪽 · 이름은 가운데 그대로 */
.b07-drawing-button:has(.b07-drawing-button__settings) {
position: relative;
padding-right: 22px;
padding-left: 22px;
}
.b07-drawing-button__settings {
position: absolute;
top: 50%;
right: 6px;
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);
@@ -200,17 +222,8 @@
color: var(--color-text-muted);
}
/* 횡단 장 배치(153-1 · 212-3) — 횡단도 묶음 맨 아래 전체 폭 · 한 줄에 한 일:
배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기].
단추 · 숫자 칸 크기 = 좌측 패널 공용 양식(`.ui-panel-form` · 269) · 한 줄 안 같은 너비. */
.b07-cross-layout-slot {
grid-column: 1 / -1;
}
.b07-cross-layout-slot:empty {
display: none;
}
/* 횡단 장 배치(153-1 · 212-3 · 280-5 ⚙ 횡단 모달) — 한 줄에 한 일:
배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기] · 한 줄 안 같은 너비. */
.b07-cross-layout {
display: flex;
flex-direction: column;
@@ -459,3 +472,34 @@ html.is-bm-route .b07-cad-stack > .cad-tabs {
flex: 1 1 0;
height: auto;
}
/* 탭 줄 겹판(280-6) — CAD 리본 탭 줄 빈 자리(CAD 가 `.cad-host` 에 적은 px) · 리본 탭 줄 26 px 안 */
.b07-cad-stack .cad-host > .cad-tabs {
position: absolute;
z-index: 3;
top: 0;
right: var(--cad-tab-slot-right, 0px);
left: var(--cad-tab-slot-left, 0px);
height: 26px;
padding: 2px 4px 0;
border-bottom: none;
background: transparent;
}
.b07-cad-stack .cad-host > .cad-tabs .cad-tabs__tab {
padding-block: 2px;
}
/* ⚙ 설정 모달(280-5) — 안내 한 줄 · 횡단 배치 칸 */
.b07-settings__note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-secondary);
font-size: 13px;
}
.b07-settings .b07-cross-layout {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 280px;
}
@@ -51,7 +51,8 @@ class FakeNode {
(this.listeners[type] ||= []).push(handler);
}
click() {
(this.listeners.click || []).forEach((handler) => handler({}));
const event = { stopPropagation() {}, preventDefault() {} };
(this.listeners.click || []).forEach((handler) => handler(event));
}
fire(type, event = {}) {
(this.listeners[type] || []).forEach((handler) => handler({ preventDefault() {}, ...event }));
@@ -246,6 +247,7 @@ function loadTs(file) {
const settle = () => new Promise((done) => setTimeout(done, 0));
const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Panels.ts"));
const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManage.ts"));
(async () => {
const out = {};
@@ -257,11 +259,14 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan
(drawing) => selected.push(drawing.id),
undefined,
false,
plan,
// 280-5 평면도 ⚙ = 평면도 관리 모달(화면 조립 `openDrawingSettings` 와 같은 길)
(kind) => kind === "plan" && openPlanManager(plan),
);
const gear = () => section.querySelectorAll(".ui-collapsible__settings")[0];
const section = panel.querySelector(".b07-plan");
out.left = {
manage: byText(section, "BUTTON", "평면도 관리").length,
manage: section.querySelectorAll(".ui-collapsible__settings").length,
manageButton: 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,
@@ -269,7 +274,7 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan
};
// 단추 → 모달
byText(section, "BUTTON", "평면도 관리")[0].click();
gear().click();
await settle();
const modal = modals[0];
const cad = cads[0];
@@ -396,7 +401,7 @@ const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Pan
out.close = { closes: [...closes], destroyed: cad.destroyed, open: modal.open };
// 다시 열어 고치지 않고 [CAD 에서 열기] → changed = false · 그 장 id
byText(section, "BUTTON", "평면도 관리")[0].click();
gear().click();
await settle();
await settle();
const modal2 = modals[1];
@@ -56,7 +56,8 @@ class FakeNode {
(this.listeners[type] ||= []).push(handler);
}
click() {
if (!this.disabled) (this.listeners.click || []).forEach((handler) => handler({}));
const event = { stopPropagation() {}, preventDefault() {} };
if (!this.disabled) (this.listeners.click || []).forEach((handler) => handler(event));
}
}
const all = (node) => [node, ...node.children.flatMap(all)];
@@ -104,6 +105,7 @@ const { buildDrawingSidePanel, buildCrossLayoutControls } = loadTs(
);
const out = {};
const settings = [];
const slot = new FakeNode("div");
slot.className = "b07-cross-layout-slot";
const panel = buildDrawingSidePanel(
@@ -115,12 +117,17 @@ const panel = buildDrawingSidePanel(
() => {},
undefined,
false,
undefined,
slot,
(kind) => settings.push(kind),
);
// 280-5 배치 칸은 ⚙ 횡단 모달로 — 카드엔 ⚙ 하나 · 배치 칸 없음
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 gears = byClass(cross, "ui-collapsible__settings");
out.card = {
gears: gears.length,
layoutInCard: byClass(cross, "b07-cross-layout").length,
};
gears[0].click();
out.card.settings = settings;
// 장 — [이 장] 켜짐 · 값 바꿔 누름
const applied = [];
@@ -0,0 +1,419 @@
/* 280-5 · 281 B07 ⚙ 설정 · 저장 후 이동 헬퍼 — 좌측 목록 ⚙ 자리 · 누름(접힘 · 도면 열기 안 감) ·
* ⚙ 모달(평면도 = 평면도 관리 · 횡단 = 배치 칸 + 안내선 · 구조물 = 축척 없음 줄 · [원본에서 다시 그리기]) ·
* 저장 묶음(`_UI_Save` — 고친 탭만 · 실패 = 거짓 · 원본 되돌림)을 Node 로 돌려 표준출력 JSON 한 벌로.
* TS 는 그 자리에서 트랜스파일 · 화면 그릇은 쓰는 만큼만 흉내 · 서버 길 · 모달 · 단추는 기록용 가짜.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1)
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.parent = null;
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)),
toggle: (name) =>
self._classes.has(name) ? self._classes.delete(name) : self._classes.add(name),
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) => {
if (k.parent) k.parent.children = k.parent.children.filter((c) => c !== k);
k.parent = this;
this.children.push(k);
});
}
prepend(...kids) {
kids.forEach((k) => (k.parent = this));
this.children.unshift(...kids);
}
replaceWith(node) {
const at = this.parent.children.indexOf(this);
node.parent = this.parent;
this.parent.children[at] = node;
}
addEventListener(type, handler) {
(this.listeners[type] ||= []).push(handler);
}
/** 누름 — 올라가기(bubble)까지 · stopPropagation 이면 멈춤 */
click() {
if (this.disabled) return;
let stopped = false;
const event = { target: this, stopPropagation: () => (stopped = true), preventDefault() {} };
for (let node = this; node && !stopped; node = node.parent)
(node.listeners.click || []).forEach((handler) => handler(event));
}
closest(selector) {
const name = selector.slice(1);
for (let node = this; node; node = node.parent) if (node.classList.contains(name)) return node;
return null;
}
}
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),
querySelectorAll: () => [],
};
const toasts = [];
const modals = [];
const plans = [];
const calls = [];
let failConfirm = false;
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 STUBS = {
"@ui/ui_template_elements": {
createButton: button,
createInputField: (opts) => {
const root = new FakeNode("label");
const input = new FakeNode("input");
input.value = opts.value ?? "";
input.setAttribute("type", opts.type);
const label = new FakeNode("span");
label.className = "ui-field__label";
label.textContent = opts.label;
root.append(label, input);
return { root, input };
},
showToast: (message, kind) => toasts.push([kind, message]),
showLoadingOverlay: () => calls.push(["overlay+"]),
hideLoadingOverlay: () => calls.push(["overlay-"]),
showConfirmDialog: async (message) => (calls.push(["ask", message]), answer),
},
"@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), new FakeNode("div"));
return { close: () => (modal.open = false) };
},
},
"@ui/ui_template_locale": {
currentLanguageIndex: 0,
ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }),
},
"./B07_DesignDetail_UI_PlanManage": {
openPlanManager: (controls) => {
const plan = { controls, open: true };
plans.push(plan);
return { close: () => (plan.open = false) };
},
},
"./B07_DesignDetail_Api_Fetch": {
confirmDesignDrawing: async (projectId, id, drawing, table) => {
calls.push(["confirm", id, drawing.tag, table]);
if (failConfirm) throw new Error("서버 오류");
return { all_confirmed: true };
},
invalidateDesignDrawing: async (projectId, id) => calls.push(["invalidate", 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 (CATALOG.isCatalogRequest(request)) return CATALOG;
if (STUBS[request]) return STUBS[request];
if (request === "@ui/ui_template_collapsible")
return loadTs(path.join(UI, "ui_template_collapsible.ts"));
if (request.startsWith(".")) {
const target = path.join(path.dirname(full), `${request}.ts`);
return fs.existsSync(target) ? loadTs(target) : {};
}
return {};
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Panels.ts"));
const { attachCollapsible } = loadTs(path.join(UI, "ui_template_collapsible.ts"));
const { openDrawingSettings } = loadTs(path.join(B07, "B07_DesignDetail_UI_Settings.ts"));
const Save = loadTs(path.join(B07, "B07_DesignDetail_UI_Save.ts"));
const item = (id, kind, extra = {}) => ({ id, kind, label: id, confirmed: false, ...extra });
const drawings = [
item("cover", "cover"),
item("plan_k", "plan", { plan_kind: "k", plan_kind_name: "지형" }),
item("longitudinal", "longitudinal"),
item("cross_standard", "cross_standard"),
item("cross_s1", "cross"),
item("cross_s2", "cross"),
item("mass_haul", "mass_haul"),
item("structure_k", "structure"),
item("dev_k", "development", { plan_kind: "dk", plan_kind_name: "옹벽" }),
];
(async () => {
const out = {};
// ── 좌측 ⚙ 자리 ───────────────────────────────────────────────────────────
const settings = [];
const selected = [];
const panel = buildDrawingSidePanel(
drawings,
(d) => selected.push(d.id),
undefined,
false,
(kind) => settings.push(kind),
);
const cards = byClass(panel, "b07-drawing-group");
out.cardGears = cards
.filter((c) => byClass(c, "ui-collapsible__settings").length)
.map((c) => c.dataset.kind);
out.cardGearInTitle = cards.every((c) =>
byClass(c, "ui-collapsible__settings").every((g) =>
g.parent.classList.contains("ui-collapsible__title"),
),
);
const buttonGears = byClass(panel, "b07-drawing-button__settings");
out.buttonGears = buttonGears.map((g) => g.parent.dataset.drawingId);
out.buttonGearRole = buttonGears.map((g) => [g.tagName, g.attrs.role, g.tabIndex]);
out.coverGear = byClass(panel, "b07-drawing-button")
.filter((b) => b.dataset.drawingId === "cover")
.flatMap((b) => byClass(b, "b07-drawing-button__settings")).length;
// 누름 — 카드 ⚙ = 접힘 그대로 · 단추 ⚙ = 도면 안 엶 · 제목 누름은 여전히 접음
const before = cards.map((c) => c.classList.contains("is-collapsed"));
cards.forEach((c) => byClass(c, "ui-collapsible__settings")[0]?.click());
out.collapsedSame = cards.every((c, i) => c.classList.contains("is-collapsed") === before[i]);
buttonGears.forEach((g) => g.click());
out.settingsCalls = [...settings];
out.selectedAfterGear = [...selected];
const crossCard = cards.find((c) => c.dataset.kind === "cross");
const crossTitle = byClass(crossCard, "ui-collapsible__title")[0];
const wasCollapsed = crossCard.classList.contains("is-collapsed");
crossTitle.click();
out.titleClickToggles = crossCard.classList.contains("is-collapsed") !== wasCollapsed;
byClass(panel, "b07-drawing-button")
.find((b) => b.dataset.drawingId === "mass_haul")
.click();
out.buttonClickOpens = selected.at(-1) === "mass_haul";
// Enter 도 도면 안 엶
const enterBefore = selected.length;
const gear = buttonGears[0];
let stopped = false;
gear.listeners.keydown.forEach((h) =>
h({ key: "Enter", preventDefault() {}, stopPropagation: () => (stopped = true) }),
);
out.enterStops = stopped && selected.length === enterBefore;
// ── ⚙ 모달 ──────────────────────────────────────────────────────────────
let current = drawings[4]; // cross_s1
let redraws = 0;
const saved = [];
const guideToggle = button({ label: "안내선 숨기기" });
const deps = {
drawings,
current: () => current,
canRedraw: () => true,
redraw: async () => (redraws++, true),
plan: { projectId: "p1", onClosed: () => {} },
cross: {
layout: async () => ({ default: [4, 4], sheets: { 1: [3, 2] }, table_scale: 1.2 }),
save: (next, sheet) => saved.push([next, sheet]),
guideToggle,
},
};
const texts = (node) =>
all(node)
.map((n) => n._text)
.filter(Boolean);
openDrawingSettings("cross", deps);
await new Promise((r) => setTimeout(r, 0));
const cross = modals.at(-1);
const crossInputs = all(cross.body).filter((n) => n.tagName === "INPUT");
out.crossModal = {
title: cross.opts.title,
labels: byClass(cross.body, "ui-field__label").map((n) => n.textContent),
values: crossInputs.map((n) => n.value),
buttons: all(cross.body)
.filter((n) => n.tagName === "BUTTON")
.map((n) => [n.textContent, n.disabled]),
guideLast: byClass(cross.body, "b07-cross-layout")[0].children.at(-1) === guideToggle,
head: cross.opts.actions.map((n) => [n.textContent, n.disabled]),
notes: texts(cross.body).filter((t) => t.startsWith("지금 도면")),
};
crossInputs[0].value = "2";
all(cross.body)
.find((n) => n.textContent === "이 장")
.click();
out.crossSave = { saved, closed: !cross.open };
openDrawingSettings("structure", deps);
const structure = modals.at(-1);
out.structureModal = {
title: structure.opts.title,
notes: texts(structure.body),
head: structure.opts.actions.map((n) => [n.textContent, n.disabled, n.title]),
};
openDrawingSettings("longitudinal", deps);
out.longModal = { title: modals.at(-1).opts.title, bodyKids: modals.at(-1).body.children.length };
current = drawings[1]; // plan_k
openDrawingSettings("plan", deps);
const plan = plans.at(-1);
out.planModal = {
viaManager: plans.length === 1 && modals.length === 3,
head: plan.controls.actions.map((n) => [n.textContent, n.disabled]),
};
plan.controls.actions[0].click();
await new Promise((r) => setTimeout(r, 0));
out.planRedraw = { redraws, closed: !plan.open };
current = drawings[3]; // 표준 횡단면도도 횡단 묶음
openDrawingSettings("cross", deps);
out.standardInCross = !modals.at(-1).opts.actions[0].disabled;
// ── 저장 묶음 ────────────────────────────────────────────────────────────
const tabsOf = (list, activeId) => ({
tabs: () => list,
active: () => list.find((t) => t.id === activeId),
markClean: (tab) => {
tab.dirty = false;
delete tab.drawing;
},
});
const storeOf = (over = {}) => {
let dirty = over.cadDirty ?? false;
const store = {
projectId: "p1",
drawings,
tabs: over.tabs,
current: () => over.current,
cadDirty: () => dirty,
setCadClean: () => (dirty = false),
requestCad: async () => ({ drawing: { tag: "live" }, quantityTable: { q: 1 } }),
isFrameEditing: () => !!over.frame,
forget: (id) => calls.push(["forget", id]),
reload: async (d) => calls.push(["reload", d.id]),
};
return store;
};
const reset = () => {
calls.length = 0;
drawings.forEach((d) => (d.confirmed = false));
};
// 고친 것 없음 = 요청 0 · 참
reset();
let ok = await Save.saveEditedDrawings(
storeOf({ current: drawings[4], tabs: tabsOf([{ id: "cross_s1" }], "cross_s1") }),
);
out.noEdits = { ok, calls: [...calls] };
// 지금 탭(CAD 편집) + 다른 고친 탭 = confirm 둘 · 안 고친 탭은 안 보냄
reset();
const list = [
{ id: "cross_s1", dirty: true },
{ id: "longitudinal", dirty: true, drawing: { tag: "stash" } },
{ id: "mass_haul" },
];
ok = await Save.saveEditedDrawings(
storeOf({ current: drawings[4], cadDirty: true, tabs: tabsOf(list, "cross_s1") }),
);
out.saveEdited = {
ok,
confirms: calls.filter((c) => c[0] === "confirm"),
clean: list.map((t) => !!t.dirty),
confirmed: drawings.filter((d) => d.confirmed).map((d) => d.id),
};
// 실패 = 거짓 · 알림
reset();
failConfirm = true;
const toastStart = toasts.length;
ok = await Save.saveEditedDrawings(
storeOf({
current: drawings[4],
cadDirty: true,
tabs: tabsOf([{ id: "cross_s1", dirty: true }], "cross_s1"),
}),
);
failConfirm = false;
out.saveFail = { ok, toasts: toasts.slice(toastStart).map((t) => t[0]) };
// 도각 편집 중 = 거짓 · 요청 0
reset();
ok = await Save.saveEditedDrawings(
storeOf({ current: drawings[4], cadDirty: true, frame: true, tabs: tabsOf([], "") }),
);
out.frameEditing = { ok, calls: calls.filter((c) => c[0] === "confirm").length };
// 원본에서 다시 그리기 — 저장본 = invalidate + 다시 실음 · 원본 그대로 = 꺼짐 · 물음 [취소] = 그대로
reset();
drawings[6].confirmed = true;
const mh = storeOf({ current: drawings[6], tabs: tabsOf([{ id: "mass_haul" }], "mass_haul") });
out.redrawable = Save.hasRedrawable(mh);
ok = await Save.redrawFromSource(mh);
out.redraw = {
ok,
calls: calls.filter((c) => c[0] !== "overlay+" && c[0] !== "overlay-").map((c) => c[0]),
confirmed: drawings[6].confirmed,
};
out.pristine = Save.hasRedrawable(mh);
answer = false;
reset();
drawings[6].confirmed = true;
ok = await Save.redrawFromSource(mh);
answer = true;
out.redrawCancel = { ok, invalidates: calls.filter((c) => c[0] === "invalidate").length };
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error && error.stack ? error.stack : error));
process.exit(1);
});
@@ -225,7 +225,6 @@ const first = b07.buildDrawingSidePanel(
undefined,
false,
undefined,
undefined,
b07.foldedGroups(fresh),
);
out.b07First = groups(first);
@@ -245,7 +244,6 @@ const again = b07.buildDrawingSidePanel(
undefined,
false,
undefined,
undefined,
b07.foldedGroups(host),
);
out.b07Redraw = groups(again);
+2 -1
View File
@@ -36,7 +36,8 @@ def out():
def test_좌측은_장_목록과_관리_단추만(out):
left = out["left"]
assert left["manage"] == 1
assert left["manage"] == 1 # 280-5 제목 ⚙ 하나
assert left["manageButton"] == 0 # [평면도 관리] 단추 지움
assert left["sheetButtons"] == ["plan_k_terrain", "plan_k_terrain_2", "plan_k_route"]
assert left["formFields"] == 0 # 드롭다운 · 이름 칸 · 체크박스 없음
assert left["remove"] == 0
@@ -2,7 +2,7 @@
"""153-1 B07 횡단 장 배치 칸.
못박는 것
- 배치 칸 자리 = 좌측 「횡단도」 묶음 맨 아래(한 번만) — 정보 칸을 다시 그려도 안 사라짐.
- 배치 칸 자리 = 「횡단도」 카드 ⚙ 모달(280-5) — 카드엔 ⚙ 하나 · 배치 칸 없음.
- 공용 입력칸(ui-field__label · ui-input--sm · 1~10) 둘 + 작은 ghost 단추 [이 장] [모든 장].
- 장이 아니면 [이 장] 꺼짐 · [모든 장]은 됨 · 범위 밖 값 = 알림만.
@@ -31,9 +31,11 @@ def out():
return json.loads(proc.stdout)
def test_횡단도_묶음_맨_아래(out):
assert out["slotLast"] is True
assert out["slotCount"] == 1
def test_횡단도_카드는_설정_단추만(out):
card = out["card"]
assert card["gears"] == 1
assert card["layoutInCard"] == 0
assert card["settings"] == ["cross"]
def test_공용_입력칸과_작은_단추(out):
@@ -68,7 +68,10 @@ def test_페이지_잇기_토스트_거름_장만():
assert "if (guideShown() && guide?.marks?.length) {" in page # 숨김 = 넘침 토스트 없음
assert "cad.setGuideVisible(visible)" in page
assert "if (!guideShown()) cad.setGuideVisible(false);" in page # 다시 세운 화면에 이어 줌
assert "controls.append(guideToggle);" in page # 배치 칸 줄 끝
# 배치 칸 줄 끝 = ⚙ 횡단 모달(280-5)
assert "controls.append(deps.cross.guideToggle);" in _read(
B07 / "B07_DesignDetail_UI_Settings.ts"
)
assert "guideToggle.disabled = !isCrossSheet(drawing);" in page # 장만
# 단추는 인쇄 칸선(도각 층) · 도면 개체를 건드리지 않음 — 파이썬 쪽 변경 없음
assert not re.search(
@@ -20,7 +20,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import use_project_templa
ROOT = Path(__file__).resolve().parents[2]
CSS = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Style.css").read_text(encoding="utf-8")
PANELS = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Panels.ts").read_text(encoding="utf-8")
PAGE = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Page.ts").read_text(encoding="utf-8")
SETTINGS = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Settings.ts").read_text(encoding="utf-8")
GUIDE = (ROOT / "B07_DesignDetail/openwebcad/src/integration/guide-area.ts").read_text(
encoding="utf-8"
)
@@ -59,8 +59,8 @@ def test_한_줄에_한_일_같은_너비():
assert "row(cols.root, rows.root)" in PANELS
assert re.search(r"row\(\s*action\(\"이 장\"[^;]*action\(\"모든 장\"", PANELS, re.S)
assert "box.append(row(scale.root, button));" in PANELS
# 안내선 단추 = 배치 칸 맨 아래 한 줄(전체 폭)
assert "controls.append(guideToggle);" in PAGE
# 안내선 단추 = 배치 칸 맨 아래 한 줄(전체 폭) · ⚙ 횡단 모달(280-5)
assert "controls.append(deps.cross.guideToggle);" in SETTINGS
def test_남는_검은_선은_인쇄_칸선_지우지_않음():
+5 -3
View File
@@ -82,12 +82,14 @@ def test_공용_카드_함수():
def test_B07_컨테이너_공용_카드_도면_단추_공용_꼴():
assert "createPanelCard(`${group.label} ${items.length}`, isFolded(" in B07_PANELS
assert "createPanelCard(`평면도 ${items.length}`, collapsed)" in B07_PLAN
assert "createPanelCard(\n `${group.label} ${items.length}`,\n isFolded(" in (
B07_PANELS.replace("\r\n", "\n")
)
assert "createPanelCard(`평면도 ${items.length}`, collapsed, onManage)" in B07_PLAN
for src in (B07_PANELS, B07_PLAN):
assert "ui-collapsible ui-sidebar-section" not in src # 손으로 짠 카드 없음
assert B07_PANELS.count('"b07-drawing-button ui-panel-btn"') == 2
assert '"ui-panel-btn b07-plan-manage-open"' in B07_PLAN
assert "b07-plan-manage-open" not in B07_PLAN # 280-5 [평면도 관리] 단추 → 제목 ⚙
def test_B07_페이지_덧칠_없음():
+218
View File
@@ -0,0 +1,218 @@
# -*- coding: utf-8 -*-
"""280-5 · 280-6 · 281 B07 좌측 ⚙ 설정 · 확정 단추 지움 · 저장 후 이동.
못박는 것
- ⚙ = 카드 제목(평면도 · 횡단도 · 구조물 · 전개도) · 한 장 단추 안(종단 1장 · 토적) · 표지 없음.
- ⚙ 누름 = 카드 접힘 그대로 · 도면 안 엶(Enter 도) · 제목 누름 · 단추 누름은 그대로.
- 모달 — 횡단 = 배치 칸(지금 장 값) + 표 배율 + 안내선 · 평면도 = 평면도 관리 모달 · 구조물 = 축척 없음 줄 ·
머리 [원본에서 다시 그리기] = 그 묶음 도면을 연 때만 켬(표준 횡단면도 = 횡단 묶음).
- 저장 묶음 — 고친 탭(●) + 지금 탭만 confirm · 고친 것 없으면 요청 0 · 실패 / 도각 편집 = 거짓(머묾) ·
원본 되돌림 = 물음 → invalidate → 다시 실음 · [취소] = 그대로.
- 페이지 — [현재 도면 확정] · [수정] · 자동 B08 이동 · 「모든 횡단 확정」 잠금 없음 · 읽기 전용 안 묶음 ·
☰ · 단계 판 · [<] [>] = 저장 → (B08 · B09) 단계 4 완료 → 이동 · 탭 줄 겹판.
- 서버 `design-drawings-complete` = 단계 4 완료.
Node 헬퍼 `helper_280_5_b07_settings.cjs` 가 화면 그릇 흉내로 돌림.
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import subprocess
from pathlib import Path
from uuid import UUID
import pytest
import B07_DesignDetail.B07_DesignDetail_Router as router
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = Path(HERE).parents[1]
B07 = ROOT / "B07_DesignDetail"
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8").replace("\r\n", "\n")
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_280_5_b07_settings.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["cardGears"] == ["plan", "cross", "structure", "development"]
assert out["cardGearInTitle"] is True
assert out["buttonGears"] == ["longitudinal", "mass_haul"]
assert out["buttonGearRole"] == [["SPAN", "button", 0]] * 2 # 단추 안 단추 금지
assert out["coverGear"] == 0
def test_설정_누름은_접힘_도면_열기_안_일으킴(out):
assert out["collapsedSame"] is True
assert out["selectedAfterGear"] == []
assert out["enterStops"] is True
assert out["settingsCalls"] == [
"plan",
"cross",
"structure",
"development",
"longitudinal",
"mass_haul",
]
assert out["titleClickToggles"] is True and out["buttonClickOpens"] is True
def test_횡단_모달은_배치_칸_표_배율_안내선(out):
cross = out["crossModal"]
assert cross["title"] == "횡단도 설정"
assert cross["labels"] == ["배치 열", "행", "표 배율"]
assert cross["values"] == ["3", "2", "1.2"] # 지금 장(1장) 값 · 표 배율
assert [b[0] for b in cross["buttons"]] == ["이 장", "모든 장", "배율 적용", "안내선 숨기기"]
assert cross["guideLast"] is True
assert cross["head"] == [["원본에서 다시 그리기", False]]
assert cross["notes"] == ["지금 도면: cross_s1"]
saved = out["crossSave"]
assert saved["closed"] is True
assert saved["saved"] == [[{"default": [4, 4], "sheets": {"1": [2, 2]}, "table_scale": 1.2}, 1]]
def test_구조물_종단_평면도_모달(out):
structure = out["structureModal"]
assert structure["title"] == "구조물 도면 설정"
assert structure["notes"] == ["M02 그림 — 축척 없음"]
assert structure["head"][0][1] is True # 다른 묶음 도면을 연 중 = 꺼짐
assert out["longModal"] == {"title": "종단도 설정", "bodyKids": 0} # 축척 칸은 485-2
assert out["planModal"] == {"viaManager": True, "head": [["원본에서 다시 그리기", False]]}
assert out["planRedraw"] == {"redraws": 1, "closed": True}
assert out["standardInCross"] is True
def test_저장은_고친_탭만_실패면_머묾(out):
assert out["noEdits"] == {"ok": True, "calls": []}
saved = out["saveEdited"]
assert saved["ok"] is True
assert saved["confirms"] == [
["confirm", "cross_s1", "live", {"q": 1}], # 지금 탭 = CAD 에서 · 수량표 같이
["confirm", "longitudinal", "stash", None], # 다른 고친 탭 = 받아 둔 편집본
]
assert saved["clean"] == [False, False, False]
assert sorted(saved["confirmed"]) == [
"cross_s1",
"longitudinal",
] # 안 고친 mass_haul 은 안 보냄
assert out["saveFail"] == {"ok": False, "toasts": ["error"]}
assert out["frameEditing"] == {"ok": False, "calls": 0}
def test_원본에서_다시_그리기(out):
assert out["redrawable"] is True
assert out["redraw"] == {
"ok": True,
"calls": ["ask", "invalidate", "forget", "reload"],
"confirmed": False,
}
assert out["pristine"] is False # 원본 그대로 = 꺼짐
assert out["redrawCancel"] == {"ok": False, "invalidates": 0}
def test_페이지_확정_단추_잠금_자동이동_없음():
page = _read(B07 / "B07_DesignDetail_UI_Page.ts")
for gone in (
"현재 도면 확정",
'"수정"',
"all_confirmed",
"allDrawingsConfirmed",
"confirmDesignDrawing",
"invalidateDesignDrawing",
"crossLayoutSlot",
):
assert gone not in page, gone
assert "confirmed: false, // 잠금 없음(요구 281)" in page
assert "actionRow.append(frameEditor.button);" in page
assert "onStepClick: (stepIndex) => void moveToStage(stepIndex)," in page
move = page[
page.index("const moveToStage = async") : page.index("const layout = createWorkflowLayout")
]
assert move.index("saveEditedDrawings(store)") < move.index("completeDesignStage(projectId)")
assert move.index("completeDesignStage(projectId)") < move.index("goToWorkflowStage(")
assert "if (stepIndex > 4 && drawings.length > 0)" in move
assert "currentStage: 5," in page
assert len(page.splitlines()) <= 700
def test_탭_줄_겹판_약속():
page = _read(B07 / "B07_DesignDetail_UI_Page.ts")
assert 'cad.element.style.getPropertyValue("--cad-tab-slot-left") !== ""' in page
assert "if (slotted) cad.element.append(tabs.element);" in page
assert "else cadStack.prepend(tabs.element);" in page
css = _read(B07 / "B07_DesignDetail_UI_Style.css")
block = css[css.index(".b07-cad-stack .cad-host > .cad-tabs {") :]
block = block[: block.index("}")]
assert "right: var(--cad-tab-slot-right, 0px);" in block
assert "left: var(--cad-tab-slot-left, 0px);" in block
assert re.search(r"height:\s*26px", block)
class _Cursor:
async def __aenter__(self) -> _Cursor:
return self
async def __aexit__(self, *_: object) -> bool:
return False
class _Connection:
async def begin(self) -> None: ...
async def commit(self) -> None: ...
async def rollback(self) -> None: ...
def cursor(self) -> _Cursor:
return _Cursor()
class _Acquire:
async def __aenter__(self) -> _Connection:
return _Connection()
async def __aexit__(self, *_: object) -> bool:
return False
def test_서버_단계_완료(monkeypatch, tmp_path: Path) -> None:
stages: list[tuple[str, int]] = []
async def source(_project_id: UUID) -> tuple[int, Path, Path, bool]:
return 1, tmp_path, tmp_path / "longitudinal.json", True
async def complete(_cursor: object, project_id: str, stage_no: int) -> None:
stages.append((project_id, stage_no))
monkeypatch.setattr(router, "_confirmed_source", source)
monkeypatch.setattr(router, "complete_stage", complete)
monkeypatch.setattr(
router, "get_db_pool", lambda: type("P", (), {"acquire": lambda self: _Acquire()})()
)
project = UUID("8b98ca75-c572-4002-8468-2adae4323414")
result = asyncio.run(router.complete_design_stage(project))
assert result == {"status": "success", "project_id": str(project)}
assert stages == [(str(project), 4)]
async def blocked(_project_id: UUID) -> tuple[int, Path, Path, bool]:
raise PermissionError("B06 종·횡단 확정 후 상세 설계를 진행할 수 있습니다.")
monkeypatch.setattr(router, "_confirmed_source", blocked)
assert asyncio.run(router.complete_design_stage(project)).status_code == 409
+3 -3
View File
@@ -89,7 +89,7 @@ def test_B07_다시_세우기는_옛_목록을_기다리기_전에_읽음():
page = _read("B07_DesignDetail/B07_DesignDetail_UI_Page.ts")
body = page[page.index("export async function renderB07DesignDetail(") :]
assert body.index("const folded = foldedGroups(root);") < body.index("await ")
assert "crossLayoutSlot,\n folded,\n );" in body.replace("\r\n", "\n")
assert "projectId ? openSettings : undefined,\n folded,\n );" in body.replace("\r\n", "\n")
def test_B07_첫_장은_표지_기억_있으면_그_장():
@@ -112,9 +112,9 @@ def test_B09_기본_접힘_좌측은_한_번만_세움():
def test_389_3_공용_카드_기본값_닫힘_그_밖_자리():
assert "createPanelCard(title: string, collapsed = true)" in _read(
assert " title: string,\n collapsed = true,\n onSettings?: () => void," in _read(
"ui_template/ui_template_collapsible.ts"
)
).replace("\r\n", "\n")
assert "collapsed = true,\n): { root: HTMLElement; body: HTMLElement }" in _read(
"A00_Common/design/design_panel.ts"
).replace("\r\n", "\n")
+21 -2
View File
@@ -11,13 +11,32 @@
* 스타일은 전역 `ui_template_theme.css`에 있어 어떤 페이지에서도 클래스만 붙이면 동작한다.
* ========================================================================== */
/** 좌측 패널 컨테이너 공용 카드(269) — 접는 제목 + 공용 양식(`ui-panel-form`) · 내용은 카드에 바로 붙임 · 기본 닫힘(389-3). */
export function createPanelCard(title: string, collapsed = true): HTMLElement {
/**
* 좌측 패널 컨테이너 공용 카드(269) — 접는 제목 + 공용 양식(`ui-panel-form`) · 내용은 카드에 바로 붙임 · 기본 닫힘(389-3).
* `onSettings` 를 넘기면 캐럿 바로 왼쪽 ⚙(280-5) — 누름은 접힘을 안 일으킴.
*/
export function createPanelCard(
title: string,
collapsed = true,
onSettings?: () => void,
): HTMLElement {
const section = document.createElement("section");
section.className = `ui-collapsible ui-sidebar-section ui-sidebar-card ui-panel-form${collapsed ? " is-collapsed" : ""}`;
const heading = document.createElement("h3");
heading.className = "ui-collapsible__title";
heading.textContent = title;
if (onSettings) {
const gear = document.createElement("button");
gear.type = "button";
gear.className = "ui-collapsible__settings";
gear.textContent = "⚙";
gear.title = gear.ariaLabel = "설정";
gear.addEventListener("click", (event) => {
event.stopPropagation(); // 제목 누름(접기 위임)으로 안 올라감
onSettings();
});
heading.append(gear);
}
section.append(heading);
return section;
}
+18
View File
@@ -470,6 +470,24 @@
content: "▸";
}
/* 카드 제목 ⚙(280-5) — 캐럿 바로 왼쪽 · 제목 높이 그대로(음수 margin) */
.ui-collapsible__settings {
flex: 0 0 auto;
margin: -0.4em 0 -0.4em auto;
padding: 0 2px;
border: none;
background: none;
color: var(--color-text-muted);
font: inherit;
line-height: 1;
cursor: pointer;
}
.ui-collapsible__settings:hover,
.ui-collapsible__settings:focus-visible {
color: var(--color-primary);
}
.ui-collapsible.is-collapsed > :not(.ui-collapsible__title) {
display: none;
}