From 659baa77515c1487a7c55b1554f8294d12a7ae3b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 22:13:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(484=20B07=20=EC=84=A4=EC=A0=95):=20?= =?UTF-8?q?=EC=A2=8C=EC=B8=A1=20=E2=9A=99=20=EC=84=A4=EC=A0=95=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=C2=B7=20[=ED=98=84=EC=9E=AC=20=EB=8F=84=EB=A9=B4?= =?UTF-8?q?=20=ED=99=95=EC=A0=95]=20=C2=B7=20[=EC=88=98=EC=A0=95]=20?= =?UTF-8?q?=EC=A7=80=EC=9B=80=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=ED=9B=84=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99(481-3)=20=C2=B7=20=ED=83=AD=20=EC=A4=84=20?= =?UTF-8?q?=EA=B2=B9=ED=8C=90=20=EC=9E=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 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 --- .../B07_DesignDetail_Api_Fetch.ts | 5 + B07_DesignDetail/B07_DesignDetail_Router.py | 31 ++ .../B07_DesignDetail_UI_Development.ts | 3 +- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 342 ++++++-------- .../B07_DesignDetail_UI_Panels.ts | 57 ++- .../B07_DesignDetail_UI_PlanKinds.ts | 12 +- .../B07_DesignDetail_UI_PlanManage.ts | 6 +- B07_DesignDetail/B07_DesignDetail_UI_Save.ts | 147 ++++++ .../B07_DesignDetail_UI_Settings.ts | 124 ++++++ .../B07_DesignDetail_UI_Style.css | 66 ++- .../tester/helper_130_b07_plan_manage.cjs | 15 +- .../tester/helper_153_b07_cross_layout.cjs | 17 +- .../tester/helper_280_5_b07_settings.cjs | 419 ++++++++++++++++++ resources/tester/helper_311_panel_folded.cjs | 2 - resources/tester/test_130_b07_plan_manage.py | 3 +- .../tester/test_153_1_b07_cross_layout.py | 10 +- .../tester/test_180_3_b07_guide_toggle.py | 5 +- .../test_212_3_b07_cross_layout_buttons.py | 6 +- resources/tester/test_269_panel_form.py | 8 +- resources/tester/test_280_5_b07_settings.py | 218 +++++++++ resources/tester/test_311_panel_folded.py | 6 +- ui_template/ui_template_collapsible.ts | 23 +- ui_template/ui_template_theme.css | 18 + 23 files changed, 1258 insertions(+), 285 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_UI_Save.ts create mode 100644 B07_DesignDetail/B07_DesignDetail_UI_Settings.ts create mode 100644 resources/tester/helper_280_5_b07_settings.cjs create mode 100644 resources/tester/test_280_5_b07_settings.py diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 92285c869..d169a75d0 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -186,6 +186,11 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P ); } +/** B07 단계(4) 완료(281) — 페이지 이동 저장 뒤 B08 · B09 로 갈 때(확정 잠금 없음). */ +export function completeDesignStage(projectId: string): Promise { + return requestJson(`/projects/${projectId}/design-drawings-complete`, { method: "POST" }); +} + /** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3) · 표 · 글 배율(180-2). */ export interface CrossLayout { default: [number, number]; diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 291e865da..679eabf88 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -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).""" diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Development.ts b/B07_DesignDetail/B07_DesignDetail_UI_Development.ts index 5203ea0b2..002d8e32d 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Development.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Development.ts @@ -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) { diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 368f5c4a3..e990d0d76 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -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 { const cad = createCadHost({ 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 { 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 { ); }; - /** 장 배치 칸 자리(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 | undefined; + const crossLayout = (): Promise => + (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 { */ 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( `.b07-drawing-button[data-drawing-id="${drawingId}"]`, @@ -265,7 +198,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { 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 { return window.confirm( "저장하지 않은 편집이 있습니다.\n" + "지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" + - "남기려면 [취소]를 누르고 [현재 도면 확정]으로 저장하세요.", + "남기려면 [취소] — 고친 도면은 페이지를 옮길 때([<] [>]) 저장됩니다.", ); }; @@ -347,9 +280,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { 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 { } 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 { const requestCadDrawing = (): Promise => cad.requestSave(); - async function confirmCurrentDrawing(): Promise { - 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 { - 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 { 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 => { + 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 { 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); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 55af92418..7f805e943 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -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(), ): 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 = { soil: "B06_Design_Ground_Soil", ripping_rock: "B06_Design_Ground_Ripping", diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 684029b0a..8b8971d54 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -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; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts index af52ce824..b4dbe70bd 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts @@ -34,6 +34,8 @@ export interface PlanManageControls { projectId: string; /** 모달을 닫음 — changed = 종류 · 자리 · 확정을 고쳤나 · openId = [CAD 에서 열기]로 고른 장. */ onClosed: (changed: boolean, openId?: string) => void; + /** 머리 단추 — ⚙ 로 열 때 [원본에서 다시 그리기](280-5). */ + actions?: HTMLElement[]; } function el( @@ -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() }; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Save.ts b/B07_DesignDetail/B07_DesignDetail_UI_Save.ts new file mode 100644 index 000000000..94d40d7f5 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_Save.ts @@ -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; + current: () => DesignDrawingItem | undefined; + /** CAD 에 저장 안 한 편집이 있나 · 지움 */ + cadDirty: () => boolean; + setCadClean: () => void; + requestCad: () => Promise>; + isFrameEditing: () => boolean; + /** 도면 캐시에서 뺌 — 다음 열기에 서버에서 다시 받음 */ + forget: (id: string) => void; + /** 그 도면을 서버에서 다시 받아 실음 */ + reload: (drawing: DesignDrawingItem) => Promise; +} + +/** 좌측 단추 저장본 띠(초록) — 저장본 = 서버 `confirmed`. */ +function markButton(id: string, saved: boolean): void { + document + .querySelectorAll(`.b07-drawing-button[data-drawing-id="${id}"]`) + .forEach((button) => (button.dataset.confirmed = String(saved))); +} + +/** + * 고친 도면을 저장 — 지금 탭(CAD 에서 받음 · 횡단은 수량표 같이) + 다른 고친 탭(받아 둔 편집본). + * 고친 것 없으면 요청 없이 참 · 하나라도 실패하면 알림 뒤 거짓(부르는 쪽은 머묾). + */ +export async function saveEditedDrawings(store: DrawingStore): Promise { + 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 { + 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(); + } +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts b/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts new file mode 100644 index 000000000..e4012364c --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_Settings.ts @@ -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; + plan?: PlanManageControls; + cross: { + layout: () => Promise; + /** 배치 저장 — 모달은 닫힌 뒤 · `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("배치를 받지 못했습니다."))); +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 0ee251ae8..bd92bbb2c 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -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; +} diff --git a/resources/tester/helper_130_b07_plan_manage.cjs b/resources/tester/helper_130_b07_plan_manage.cjs index b3a1e2334..e7649734e 100644 --- a/resources/tester/helper_130_b07_plan_manage.cjs +++ b/resources/tester/helper_130_b07_plan_manage.cjs @@ -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]; diff --git a/resources/tester/helper_153_b07_cross_layout.cjs b/resources/tester/helper_153_b07_cross_layout.cjs index 272cd5cbe..1266a6f49 100644 --- a/resources/tester/helper_153_b07_cross_layout.cjs +++ b/resources/tester/helper_153_b07_cross_layout.cjs @@ -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 = []; diff --git a/resources/tester/helper_280_5_b07_settings.cjs b/resources/tester/helper_280_5_b07_settings.cjs new file mode 100644 index 000000000..179a6018e --- /dev/null +++ b/resources/tester/helper_280_5_b07_settings.cjs @@ -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); +}); diff --git a/resources/tester/helper_311_panel_folded.cjs b/resources/tester/helper_311_panel_folded.cjs index 2df9bb2fa..545a7a2f3 100644 --- a/resources/tester/helper_311_panel_folded.cjs +++ b/resources/tester/helper_311_panel_folded.cjs @@ -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); diff --git a/resources/tester/test_130_b07_plan_manage.py b/resources/tester/test_130_b07_plan_manage.py index ca7023a5d..25d45793a 100644 --- a/resources/tester/test_130_b07_plan_manage.py +++ b/resources/tester/test_130_b07_plan_manage.py @@ -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 diff --git a/resources/tester/test_153_1_b07_cross_layout.py b/resources/tester/test_153_1_b07_cross_layout.py index edc706067..af9d9d6ec 100644 --- a/resources/tester/test_153_1_b07_cross_layout.py +++ b/resources/tester/test_153_1_b07_cross_layout.py @@ -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): diff --git a/resources/tester/test_180_3_b07_guide_toggle.py b/resources/tester/test_180_3_b07_guide_toggle.py index abbdef089..a8c0899c2 100644 --- a/resources/tester/test_180_3_b07_guide_toggle.py +++ b/resources/tester/test_180_3_b07_guide_toggle.py @@ -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( diff --git a/resources/tester/test_212_3_b07_cross_layout_buttons.py b/resources/tester/test_212_3_b07_cross_layout_buttons.py index 6cc3f02fd..49a303508 100644 --- a/resources/tester/test_212_3_b07_cross_layout_buttons.py +++ b/resources/tester/test_212_3_b07_cross_layout_buttons.py @@ -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_남는_검은_선은_인쇄_칸선_지우지_않음(): diff --git a/resources/tester/test_269_panel_form.py b/resources/tester/test_269_panel_form.py index c2c5cc4b8..aa8d31a3b 100644 --- a/resources/tester/test_269_panel_form.py +++ b/resources/tester/test_269_panel_form.py @@ -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_페이지_덧칠_없음(): diff --git a/resources/tester/test_280_5_b07_settings.py b/resources/tester/test_280_5_b07_settings.py new file mode 100644 index 000000000..7ff3cc559 --- /dev/null +++ b/resources/tester/test_280_5_b07_settings.py @@ -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 diff --git a/resources/tester/test_311_panel_folded.py b/resources/tester/test_311_panel_folded.py index 030526eaf..35528886e 100644 --- a/resources/tester/test_311_panel_folded.py +++ b/resources/tester/test_311_panel_folded.py @@ -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") diff --git a/ui_template/ui_template_collapsible.ts b/ui_template/ui_template_collapsible.ts index 4292a3f3f..ae341d60d 100644 --- a/ui_template/ui_template_collapsible.ts +++ b/ui_template/ui_template_collapsible.ts @@ -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; } diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 86d157de8..e77b4ed91 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -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; }