diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index b034c665a..75b378f9a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -189,6 +189,20 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P ); } +/** [초기화](298-2) 답 — 풀린 장 수 · 지운 설정 파일 · 잠정으로 돌린 측점 수. */ +export interface DesignDrawingResetResponse { + status: string; + project_id: string; + released: number; + cleared: string[]; + provisional: number; +} + +/** [초기화](298-2) — 저장본 확정 풂 · 설정 지움(서버 `_Router_Reset.py`) · 본문 없음. */ +export function resetDesignDrawings(projectId: string): Promise { + return requestJson(`/projects/${projectId}/design-drawings/reset`, { method: "POST" }); +} + /** B07 단계(4) 완료(281) — 페이지 이동 저장 뒤 B08 · B09 로 갈 때(확정 잠금 없음). */ export function completeDesignStage(projectId: string): Promise { return requestJson(`/projects/${projectId}/design-drawings-complete`, { method: "POST" }); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index e990d0d76..5a6d8292b 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -12,7 +12,12 @@ import "./B07_DesignDetail_UI_Style.css"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { + createButton, + 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). @@ -24,6 +29,7 @@ import { } from "./B07_DesignDetail_UI_Panels"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { + clearWorkflowStateCache, fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, @@ -48,7 +54,13 @@ 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 { + hasRedrawable, + redrawFromSource, + resetDrawings, + saveDrawingsNow, + 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"; @@ -451,13 +463,35 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로 // 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통). actions.className = "b07-drawing-actions ui-sidebar-actions"; - // 액션 칸은 두 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집] - // (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김 · 확정 단추는 281 에 지움). + // 액션 칸은 세 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집] + // (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김 · 확정 단추는 281 에 지움), + // 3행 [?] [<] [>] [저장] [초기화](298-2 · B05 · B06 과 같은 차례 · [?] [<] [>] 는 공용이 앞에 붙임). + const frameRow = document.createElement("div"); + frameRow.className = "b07-drawing-actions__row"; + frameRow.append(frameEditor.button); const actionRow = document.createElement("div"); actionRow.className = "b07-drawing-actions__row"; actionRow.dataset.helpRow = ""; - actionRow.append(frameEditor.button); - actions.append(frameEditor.banner, actionRow); + const refreshStages = (): void => { + if (!projectId) return; + clearWorkflowStateCache(); + void fetchWorkflowState(projectId) + .then((state) => layout.setStages(state.stages)) + .catch(() => undefined); + }; + const saveButton = createButton({ + label: L("Common_Btn_Save"), + variant: "ghost", + onClick: () => void saveDrawingsNow(store, refreshStages), + }); + const resetButton = createButton({ + label: L("Common_Btn_Reset"), + variant: "danger-ghost", + onClick: () => void resetDrawings(store), + }); + saveButton.disabled = resetButton.disabled = !projectId; + actionRow.append(saveButton, resetButton); + actions.append(frameEditor.banner, frameRow, actionRow); drawingPanel.append(infoPanelHost, actions); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Save.ts b/B07_DesignDetail/B07_DesignDetail_UI_Save.ts index 94d40d7f5..14baf3598 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Save.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Save.ts @@ -6,6 +6,8 @@ * 고친 탭(●) + 지금 탭을 `confirm` 길로 · 안 고친 도면은 보내지 않음(늘 원본에서 새로 그림). * 잠금 없음 — 저장본도 그대로 고침 · 다시 옮길 때 다시 저장. * 원본 되돌림 = ⚙ 모달 [원본에서 다시 그리기] — 저장본이면 `invalidate` · 고친 것 버리고 다시 실음. + * 좌측 [저장](298-2) = 이동 없이 같은 저장 · [초기화] = 물음 → `reset`(저장본 풂 · 설정 지움) → + * CAD 자동백업 지움 → 페이지 다시 읽음(기준 = 지금 앞 단계로 그린 원본). * ========================================================================== */ import { @@ -19,6 +21,7 @@ import type { CadTabs } from "@ui/cad_host/cad_host_tabs"; import { confirmDesignDrawing, invalidateDesignDrawing, + resetDesignDrawings, type CadDrawing, type DesignDrawingItem, type QuantityTable, @@ -145,3 +148,48 @@ export async function redrawFromSource(store: DrawingStore): Promise { hideLoadingOverlay(); } } + +/** 좌측 [저장](298-2) — 이동 없이 고친 탭 저장 · 저장했으면 `onSaved`(단계 색 다시 읽음). */ +export async function saveDrawingsNow(store: DrawingStore, onSaved: () => void): Promise { + if (!store.cadDirty() && !store.tabs.anyDirty()) { + showToast("고친 도면이 없습니다.", "info"); + return; + } + if (!(await saveEditedDrawings(store))) return; + showToast("저장했습니다.", "success"); + onSaved(); +} + +/** CAD 자동백업 칸(`openwebcad/src/helpers/autosave.ts`) — 칸 이름 = 도면 id. */ +const CAD_RECOVERY_KEY = "OPEN_WEB_CAD__RECOVERY"; + +/** + * 좌측 [초기화](298-2 · 300 C1 · C3) — 물은 뒤 서버가 저장본을 풀고 설정을 지움 → 고친 것 · CAD 자동백업 버림 → + * 페이지 다시 읽음(다음 GET 이 지금 앞 단계 저장본으로 원본을 그림). [취소] = 그대로. + */ +export async function resetDrawings(store: DrawingStore): Promise { + if (store.isFrameEditing()) { + showToast("도각 편집을 마친 뒤 초기화하세요.", "warning"); + return; + } + const ok = await showConfirmDialog( + "이 페이지에서 저장한 도면 · 고친 것 · 설정(축척 · 횡단 배치 · 평면도 종류 · 표식)을 모두 지우고 " + + "지금 앞 단계 값으로 다시 그릴까요?", + "초기화", + ); + if (!ok) return; + showLoadingOverlay(); + try { + await resetDesignDrawings(store.projectId); + store.drawings.forEach((item) => localStorage.removeItem(`${CAD_RECOVERY_KEY}__${item.id}`)); + showToast("초기화했습니다.", "success"); + location.reload(); + } catch (error) { + showToast( + error instanceof Error ? error.message : "상세 설계를 초기화하지 못했습니다.", + "error", + ); + } finally { + hideLoadingOverlay(); + } +} diff --git a/resources/tester/helper_298_2_b07_buttons.cjs b/resources/tester/helper_298_2_b07_buttons.cjs new file mode 100644 index 000000000..f145823dd --- /dev/null +++ b/resources/tester/helper_298_2_b07_buttons.cjs @@ -0,0 +1,107 @@ +/* 298-2 B07 좌측 [저장] · [초기화] 헬퍼 — `_UI_Save` 의 saveDrawingsNow · resetDrawings 를 Node 로 돌려 + * 부른 차례(물음 · 오버레이 · 창구 · 자동백업 지움 · 다시 읽음)를 표준출력 JSON 한 벌로. + * TS 는 그 자리에서 트랜스파일 · 서버 길 · 알림 · 물음은 기록용 가짜. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const B07 = path.join(__dirname, "..", "..", "B07_DesignDetail"); +const calls = []; +let answer = true; +let failReset = false; + +const STUBS = { + "@ui/ui_template_elements": { + showToast: (message, kind) => calls.push(["toast", kind]), + showLoadingOverlay: () => calls.push(["overlay+"]), + hideLoadingOverlay: () => calls.push(["overlay-"]), + showConfirmDialog: async () => (calls.push(["ask"]), answer), + }, + "./B07_DesignDetail_Api_Fetch": { + confirmDesignDrawing: async (projectId, id) => calls.push(["confirm", id]), + invalidateDesignDrawing: async () => calls.push(["invalidate"]), + resetDesignDrawings: async (projectId) => { + calls.push(["reset", projectId]); + if (failReset) throw new Error("서버 오류"); + return { status: "success", released: 1, cleared: [], provisional: 0 }; + }, + }, +}; + +const storage = new Map(); +global.localStorage = { + removeItem: (key) => (calls.push(["drop", key]), storage.delete(key)), +}; +global.location = { reload: () => calls.push(["reload"]) }; +global.document = { querySelectorAll: () => [] }; + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (r) => STUBS[r] ?? {}); + return box.exports; +} + +const Save = loadTs(path.join(B07, "B07_DesignDetail_UI_Save.ts")); + +const storeOf = (over = {}) => { + let dirty = !!over.cadDirty; + const tab = { id: "cross_s1", dirty }; + return { + projectId: "p1", + drawings: [ + { id: "cover", kind: "cover", label: "표지", confirmed: false }, + { id: "cross_s1", kind: "cross", label: "횡단 1", confirmed: false }, + ], + tabs: { + tabs: () => [tab], + active: () => tab, + markClean: (t) => ((t ?? tab).dirty = false), + anyDirty: () => tab.dirty, + }, + current: () => ({ id: "cross_s1", kind: "cross", label: "횡단 1", confirmed: false }), + cadDirty: () => dirty, + setCadClean: () => (dirty = false), + requestCad: async () => ({ drawing: {}, quantityTable: null }), + isFrameEditing: () => !!over.frame, + forget: () => undefined, + reload: async () => undefined, + }; +}; +const take = () => calls.splice(0).map((c) => c.join(":")); + +(async () => { + const out = {}; + let saved = 0; + const onSaved = () => saved++; + + await Save.saveDrawingsNow(storeOf(), onSaved); + out.saveNone = { calls: take(), saved }; + + await Save.saveDrawingsNow(storeOf({ cadDirty: true }), onSaved); + out.saveEdited = { calls: take(), saved }; + + await Save.resetDrawings(storeOf({ cadDirty: true })); + out.reset = take(); + + answer = false; + await Save.resetDrawings(storeOf()); + answer = true; + out.resetCancel = take(); + + failReset = true; + await Save.resetDrawings(storeOf()); + failReset = false; + out.resetFail = take(); + + await Save.resetDrawings(storeOf({ frame: true })); + out.resetFrame = take(); + + 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/test_280_5_b07_settings.py b/resources/tester/test_280_5_b07_settings.py index ab3b69b86..9fec1b557 100644 --- a/resources/tester/test_280_5_b07_settings.py +++ b/resources/tester/test_280_5_b07_settings.py @@ -141,7 +141,7 @@ def test_페이지_확정_단추_잠금_자동이동_없음(): ): assert gone not in page, gone assert "confirmed: false, // 잠금 없음(요구 281)" in page - assert "actionRow.append(frameEditor.button);" in page + assert "frameRow.append(frameEditor.button);" in page # 298-2 — [도각 편집] = 윗줄 assert "onStepClick: (stepIndex) => void moveToStage(stepIndex)," in page move = page[ page.index("const moveToStage = async") : page.index("const layout = createWorkflowLayout") diff --git a/resources/tester/test_298_2_b07_buttons.py b/resources/tester/test_298_2_b07_buttons.py new file mode 100644 index 000000000..2f42c8c64 --- /dev/null +++ b/resources/tester/test_298_2_b07_buttons.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +"""298-2 B07 좌측 아래 단추 줄 [?] [<] [>] [저장] [초기화]. + +못박는 것 + - 줄 차례 — [도각 편집] = 윗줄 · 아래 줄(help-row) = [저장] [초기화](앞 [?] [<] [>] 는 공용) · + [초기화] = danger-ghost(B05 · B06 과 같은 꼴) · 맨 오른쪽. + - [저장] = 이동 없이 고친 탭 저장 · 고친 것 없으면 요청 0 · 저장 뒤 단계 색 다시 읽음. + - [초기화] = 물음 → 오버레이 → POST reset → CAD 자동백업(도면마다) 지움 → 다시 읽음 · + [취소] = 요청 0 · 실패 = 머묾(다시 읽음 없음) · 도각 편집 중 = 막음. + - 창구 = `POST /projects/{id}/design-drawings/reset` 본문 없음. + +Node 헬퍼 `helper_298_2_b07_buttons.cjs` 가 저장 묶음을 돌림. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +B07 = Path(HERE).parents[1] / "B07_DesignDetail" + + +def _read(name: str) -> str: + return (B07 / name).read_text(encoding="utf-8").replace("\r\n", "\n") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_298_2_b07_buttons.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_줄_차례_꼴(): + page = _read("B07_DesignDetail_UI_Page.ts") + assert "frameRow.append(frameEditor.button);" in page + assert "actionRow.dataset.helpRow" in page + assert "actionRow.append(saveButton, resetButton);" in page + assert "actions.append(frameEditor.banner, frameRow, actionRow);" in page + reset = page[page.index("const resetButton = createButton({") :] + reset = reset[: reset.index("});")] + assert 'label: L("Common_Btn_Reset")' in reset + assert 'variant: "danger-ghost"' in reset + assert "resetDrawings(store)" in reset + save = page[page.index("const saveButton = createButton({") :] + save = save[: save.index("});")] + assert 'label: L("Common_Btn_Save")' in save + assert "saveDrawingsNow(store, refreshStages)" in save + assert "layout.setStages(state.stages)" in page + assert len(page.splitlines()) <= 700 + + +def test_창구(): + api = _read("B07_DesignDetail_Api_Fetch.ts") + block = api[api.index("export function resetDesignDrawings") :] + block = block[: block.index("}\n")] + assert "/projects/${projectId}/design-drawings/reset" in block + assert '{ method: "POST" }' in block + assert "body" not in block + + +def test_저장(out): + assert out["saveNone"] == {"calls": ["toast:info"], "saved": 0} + assert out["saveEdited"] == { + "calls": ["overlay+", "confirm:cross_s1", "overlay-", "toast:success"], + "saved": 1, + } + + +def test_초기화(out): + assert out["reset"] == [ + "ask", + "overlay+", + "reset:p1", + "drop:OPEN_WEB_CAD__RECOVERY__cover", + "drop:OPEN_WEB_CAD__RECOVERY__cross_s1", + "toast:success", + "reload", + "overlay-", + ] + assert out["resetCancel"] == ["ask"] + assert out["resetFail"] == ["ask", "overlay+", "reset:p1", "toast:error", "overlay-"] + assert out["resetFrame"] == ["toast:warning"]