From 08a9bc2e46dbdf89b712cafd7be3189931f5217d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 20 Oct 2026 19:41:35 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(495-2=20M02=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C):=20=EB=8F=84=EB=A9=B4=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=C2=B7=20=EB=8F=84=EB=A9=B4=20=EC=96=91=EC=8B=9D=20?= =?UTF-8?q?[=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C]=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=E2=80=94=20DWG=20=3D=20CAD=20[=EC=82=BD=EC=9E=85?= =?UTF-8?q?=20=E2=86=92=20DWG=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0]=20?= =?UTF-8?q?=ED=95=9C=20=EA=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_MasterTemplete_Drawing.ts — importDrawingFile · noImport · [파일 업로드] 덩이 지움 - DrawingCard · AreaDrawing — noImport 곁값 지움 · CardHelp 글 = CAD [삽입] DWG 불러오기 - 서버 /api/m02/drawing-import = 부르는 곳 0 · 남김 - 시험 test_m02_card_shell 두 개 고쳐 씀 · ui_checks 47-1 기대 detailImport = 0 Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_AreaDrawing.ts | 1 - .../M02_MasterTemplete_CardHelp.ts | 4 +- .../M02_MasterTemplete_Drawing.css | 7 -- .../M02_MasterTemplete_Drawing.ts | 67 ++----------------- .../M02_MasterTemplete_DrawingCard.ts | 1 - resources/tester/test_m02_card_shell.py | 16 +++-- .../tester/ui_checks/47-1_카드_도움말.js | 16 +++-- 7 files changed, 25 insertions(+), 87 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts index 9a274dd26..b1ae795f5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts @@ -160,7 +160,6 @@ export function createAreaDrawing( originMarker: true, noArea: true, frame: true, - noImport: true, pickTabs: true, }); frame = stage.querySelector("iframe"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_CardHelp.ts b/M02_MasterTemplete/M02_MasterTemplete_CardHelp.ts index d44febe33..46020e95a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_CardHelp.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_CardHelp.ts @@ -40,7 +40,7 @@ export const CARD_HELP: Record<"structure" | "labor" | "area", CardHelp> = { features: [ SAVE, take("도면"), - ["수정", "도면 상세(전체 편집 화면)를 엶 — 파일 업로드(DXF · DWG)는 여기"], + ["수정", "도면 상세(전체 편집 화면)를 엶 — DWG 불러오기(CAD [삽입])는 여기"], clear("도면"), FOLD, ], @@ -98,7 +98,7 @@ export const CARD_HELP: Record<"structure" | "labor" | "area", CardHelp> = { /** 상세에만 있는 단추 줄(49-7) — 카드 도움말 글 한 벌 뒤에 더함 */ const DETAIL_ONLY: Record = { - structure: [["파일 업로드", "DXF · DWG 파일을 도면으로 불러옴(상세 머리 [파일 업로드])"]], + structure: [["DWG 불러오기", "DWG · DXF 파일을 지금 도면에 더함(CAD 리본 [삽입])"]], labor: [], area: [["도면으로 보내기", "고른 범위를 도면 표로 보냄(상세 머리)"]], }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.css b/M02_MasterTemplete/M02_MasterTemplete_Drawing.css index 02d2c40af..157e61d99 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.css @@ -67,13 +67,6 @@ gap: var(--spacing-8); } -.m02-drawing__actions { - display: flex; - gap: var(--spacing-8); - margin-left: auto; - white-space: nowrap; -} - .m02-pop { display: block; background-color: transparent; /* 단추 아래 뜨는 작은 창 — 뒤를 가리지 않음 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index fc082800e..dd9de6500 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -39,7 +39,7 @@ export interface DrawingTemplateOptions { projectId?: string | null; /** 저장 안 한 고침이 생기면 true — CAD 편집 · 작도 영역 값 바꿈. */ onChanged?: (dirty: boolean) => void; - /** 페이지 제목 줄 안의 빈 칸 — 있으면 작도 영역 칸 · [파일 업로드]를 거기 한 줄로 놓는다. */ + /** 페이지 제목 줄 안의 빈 칸 — 있으면 작도 영역 칸을 거기 한 줄로 놓는다. */ headerSlot?: HTMLElement; /** CAD 좌측 패널(특성 · 도면층)을 접힌 채로 연다 — 누르면 펼침(구조물 도면 미리보기). */ panelsCollapsed?: boolean; @@ -51,8 +51,6 @@ export interface DrawingTemplateOptions { noArea?: boolean; /** B07 도각을 외곽 좌하단 (0,0) 에 얹음 — 잠금 층 · 보여주기만(돌려줄 때 뺌 · 저장 안 됨). */ frame?: boolean; - /** [파일 업로드] 단추를 안 둠 — 집계표 카드 미리보기(불러오기는 도면 상세에서만). */ - noImport?: boolean; /** 장id(82-1) — 자동백업 칸을 장마다 나눔(1장 = 빈 값 · 옛 칸 그대로) */ sheet?: string; /** 장 도번(82-2) — 얹은 도각 `{{도면번호}}` 자리 */ @@ -142,25 +140,6 @@ function withoutFrame(drawing: DrawingTemplateDoc): DrawingTemplateDoc { }; } -async function importDrawingFile( - file: File, -): Promise<{ drawing: DrawingTemplateDoc; entity_count: number }> { - const form = new FormData(); - form.append("file", file); - const response = await fetch(`${API_BASE_URL}/m02/drawing-import`, { - method: "POST", - credentials: "include", - body: form, - }); - const payload = (await response.json()) as { - drawing: DrawingTemplateDoc; - entity_count: number; - message?: string; - }; - if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); - return payload; -} - export function mountDrawingTemplate( container: HTMLElement, doc: DrawingTemplateDoc, @@ -292,45 +271,6 @@ export function mountDrawingTemplate( after?.(); }; - const actions = document.createElement("div"); - actions.className = "m02-drawing__actions"; - const canImport = !readOnly && options.noImport !== true; - if (canImport) { - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = ".dxf,.dwg"; - fileInput.hidden = true; - fileInput.addEventListener("change", () => { - const file = fileInput.files?.[0]; - fileInput.value = ""; - if (!file) return; - importButton.disabled = true; - importDrawingFile(file) - .then((response) => { - // 불러온 도각은 아직 저장하지 않는다 — 자리표를 놓고 [저장]을 눌러야 양식이 된다. - void load(response.drawing); - options.onChanged?.(true); // 불러온 것은 아직 저장 전 — 떠날 때 묻게 함 - showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success"); - }) - .catch((error) => - showToast( - error instanceof Error ? error.message : "도각 파일을 불러오지 못했습니다.", - "error", - ), - ) - .finally(() => (importButton.disabled = false)); - }); - const importButton = createButton({ - label: "파일 업로드", - variant: "filled", - onClick: () => fileInput.click(), - }); - // 구조물 조합 층 단추([마스터])와 같은 꼴 — 실무자료 [파일 업로드]와 같은 종류(362-2) - importButton.classList.add("m02-layers__btn"); - importButton.title = importButton.ariaLabel = "파일 업로드 (DXF · DWG)"; - actions.append(importButton, fileInput); - } - const getDoc = async (): Promise => { const { drawing: saved } = await cad.requestSave(); const drawing = options.frame ? withoutFrame(saved) : saved; @@ -345,13 +285,14 @@ export function mountDrawingTemplate( }; // 한 줄 — 페이지가 머리 칸(headerSlot)을 주면 거기, 아니면 그림 칸 위에 작게. - toolbar.append(...(noArea ? [] : [areaButton]), actions); + // DWG · DXF 불러오기 = CAD 리본 [삽입 → DWG 불러오기] · 끌어다 놓기 한 길(495-2) + toolbar.append(...(noArea ? [] : [areaButton])); const tabs = options.tabs ? [options.tabs] : []; if (options.headerSlot) { toolbar.classList.add("m02-drawing__toolbar--slot"); options.headerSlot.append(toolbar); root.append(...tabs, cad.element); - } else root.append(...(noArea && !canImport ? [] : [toolbar]), ...tabs, cad.element); + } else root.append(...(noArea ? [] : [toolbar]), ...tabs, cad.element); container.append(root); void load(doc); diff --git a/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts b/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts index 35e7f6764..099adde23 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts @@ -85,7 +85,6 @@ function mountStructure( originMarker: true, // 좌표 0,0 기준점 십자 noArea: true, // 작도 영역 칸 없음 frame: true, // 도각 = 보여주기만 · 외곽 좌하단 (0,0)(56장) - noImport: true, // 파일 업로드는 도면 상세에서만(47-2) pickTabs: true, ...hooks, }); diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index ec645f62b..49976ec1e 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -117,10 +117,12 @@ def test_도움말_제목_옆_네_절(): def test_미리보기_파일_불러오기_없음(): - """47-2 — 도면 카드 미리보기엔 [파일 업로드] 없음 · 도면 상세(전체 편집)는 그대로.""" + """47-2 · 495-2 — M02 [파일 업로드] 없음(카드 · 상세 모두) · DWG = CAD [삽입 → DWG 불러오기] 한 길.""" drawing = _text("M02_MasterTemplete_Drawing.ts") - assert "const canImport = !readOnly && options.noImport !== true;" in drawing - assert "noImport: true," in _text("M02_MasterTemplete_DrawingCard.ts") + for gone in ("noImport", "canImport", "importDrawingFile", "/m02/drawing-import"): + assert gone not in drawing + for name in ("M02_MasterTemplete_DrawingCard.ts", "M02_MasterTemplete_AreaDrawing.ts"): + assert "noImport" not in _text(name) def test_일위대가_줄_고름과_옵션_단추(): @@ -181,10 +183,10 @@ def test_옵션_줄_구분_이름_별칭_병합(): def test_도면_상세_머리_파일_업로드_단추(): - """362-2 — 도면 상세 머리 📂 → 글 단추 [파일 업로드](구조물 조합 층 단추 꼴 · 실무자료와 같은 종류).""" + """362-2 → 495-2 — 도면 상세 머리 [파일 업로드] 지움 · 도움말 = CAD [삽입] DWG 불러오기.""" drawing = _text("M02_MasterTemplete_Drawing.ts") - assert 'label: "파일 업로드",' in drawing and 'variant: "filled",' in drawing - assert 'importButton.classList.add("m02-layers__btn");' in drawing - assert '"파일 업로드 (DXF · DWG)"' in drawing + assert "파일 업로드" not in drawing and "importButton" not in drawing assert "📂" not in drawing and "파일 불러오기 (DXF" not in drawing + help_ = _text("M02_MasterTemplete_CardHelp.ts") + assert "파일 업로드" not in help_ and '["DWG 불러오기",' in help_ assert "📂" not in _text("M02_MasterTemplete_CardHelp.ts") diff --git a/resources/tester/ui_checks/47-1_카드_도움말.js b/resources/tester/ui_checks/47-1_카드_도움말.js index 302ffbe11..322a91eec 100644 --- a/resources/tester/ui_checks/47-1_카드_도움말.js +++ b/resources/tester/ui_checks/47-1_카드_도움말.js @@ -1,4 +1,4 @@ -/* 47-1 · 47-2 — 구조물 조합 카드 셋 [?] = 제목 바로 옆 · 도움말 네 절 · 도면 미리보기 [파일 업로드] 없음 · 도면 상세 [파일 업로드] 있음 +/* 47-1 · 47-2 — 구조물 조합 카드 셋 [?] = 제목 바로 옆 · 도움말 네 절 · 도면 미리보기 · 도면 상세 [파일 업로드] 없음(495-2 · DWG = CAD [삽입]) * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · * 나무에 item1(구조물 조합)이 있음 * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌 · 20초 안) @@ -6,7 +6,7 @@ * 기대 수치: * - cards = 3(도면 정보 · 일위대가 · 면적산출) · 카드마다 nextToTitle = true(? 가 제목 글 바로 다음) · * inTools = false · parts = ["about","keys","features","steps"] · opened = true · closed = true - * - previewImport = 0(카드 미리보기 [파일 업로드]) · detailImport = 1(도면 상세 [파일 업로드]) · back = true(카드 화면으로 돌아옴) + * - previewImport = 0(카드 미리보기 [파일 업로드]) · detailImport = 0(도면 상세 [파일 업로드] · 495-2 지움) · back = true(카드 화면으로 돌아옴) */ (async () => { const w = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -57,10 +57,14 @@ if (!edit) return JSON.stringify({ ...out, error: "도면 [수정] 없음" }); const hash = location.hash; edit.click(); - out.detailImport = (await until(() => imports(), 8000)) || 0; - [...document.querySelectorAll("button")] - .find((b) => vis(b) && b.innerText.trim() === "돌아가기") - ?.click(); + const back = () => + [...document.querySelectorAll("button")].find( + (b) => vis(b) && b.innerText.trim() === "돌아가기", + ); + await until(back, 8000); + await w(2500); + out.detailImport = imports(); + back()?.click(); out.back = !!(await until( () => location.hash === hash && document.querySelector(".m02-struct__card"), )); From d35b9b74d459d770640585d1a91e852bd0c14cc7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 20 Oct 2026 19:42:49 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(500-1=20=EA=B3=A1=EC=84=A0=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=EA=B3=A0=EB=A6=84=20=EC=9C=A0=EC=A7=80):=20B05=20?= =?UTF-8?q?=ED=8F=89=EB=A9=B4=20=EA=B5=90=EA=B0=81=EC=A0=90=20=C2=B7=20?= =?UTF-8?q?=EC=86=90=EC=9E=A1=EC=9D=B4=20=C2=B7=20=ED=98=B8=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=3D=20=EC=B8=A1=EC=A0=90=20=EA=B3=A0=EB=A6=84=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=20=C2=B7=20=ED=92=80=EB=A6=BC=20=3D=20?= =?UTF-8?q?=EC=98=A4=EB=A5=B8=EC=AA=BD=20=ED=8C=90=EB=8F=84=20=EB=B9=84?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EC=9E=84=EC=8B=9C=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EB=9D=A0=20=C2=B112=20px=20=C2=B7=20=EC=B0=BD=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=EC=A0=9C=EB=AA=A9=20=EC=A4=84=20=C2=B7=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EC=A4=84=20=EB=81=8C=EC=96=B4=20=EC=98=AE=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../B05_Profile_UI_Profile_CrossModal.css | 10 ++- .../B05_Profile_UI_Profile_CrossModal.ts | 58 ++++++++++++-- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 3 +- .../B05_Profile_UI_RouteEdit_Pointer.ts | 7 +- ...B05_Profile_UI_RouteEdit_StructureMarks.ts | 4 +- .../logic/B05_Profile_Profile_CrossPick.ts | 2 +- .../tester/helper_411_a_cross_structure.cjs | 2 +- .../tester/test_409_mark_top_deselect.py | 5 +- .../tester/test_411_a_cross_structure.py | 8 +- resources/tester/test_490_1_cross_pick.py | 12 +-- .../tester/test_500_1_curve_keeps_pick.py | 80 +++++++++++++++++++ 11 files changed, 168 insertions(+), 23 deletions(-) create mode 100644 resources/tester/test_500_1_curve_keeps_pick.py diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css index b78246e7c..471b4f913 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css @@ -22,8 +22,12 @@ .b05-crossmodal__head { display: flex; - align-items: baseline; + flex-wrap: wrap; + align-items: center; gap: var(--spacing-8, 8px); + cursor: move; + touch-action: none; + user-select: none; } .b05-crossmodal__station { @@ -45,10 +49,12 @@ line-height: 1.5; } +/* 단추 = 제목 줄 안 오른쪽 맞춤(500-1) */ .b05-crossmodal__actions { display: flex; - justify-content: flex-end; gap: var(--spacing-8, 8px); + margin-left: auto; + cursor: default; } /* 임시 횡단이 열리는 자리 가까이 표시(490-1) — 누름은 아래 그래프 몫(통과) · 세로 축(5) 밑 */ diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts index b89a16345..2e40dfdd2 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts @@ -130,6 +130,50 @@ export function mountCrossHint( chart.addEventListener("pointerleave", hide); } +/** 제목 줄 끌어 창 옮김(500-1) — 종단 칸 · 화면 안으로 가둠 · 단추 누름은 단추 몫 · 돌려줌 = 끌어 둔 자리 다시 가둠. */ +function bindHeadDrag(root: HTMLElement, head: HTMLElement): () => void { + let start: { id: number; x: number; y: number; left: number; top: number } | null = null; + const put = (left: number, top: number): void => { + const box = root.parentElement!.getBoundingClientRect(); + const now = root.getBoundingClientRect(); + const [dx, dy] = [left - root.offsetLeft, top - root.offsetTop]; + const [minX, maxX] = [ + Math.max(box.left, 0) - now.left, + Math.min(box.right, innerWidth) - now.right, + ]; + const [minY, maxY] = [ + Math.max(box.top, 0) - now.top, + Math.min(box.bottom, innerHeight) - now.bottom, + ]; + root.style.left = `${root.offsetLeft + Math.max(minX, Math.min(dx, maxX))}px`; + root.style.top = `${root.offsetTop + Math.max(minY, Math.min(dy, maxY))}px`; + root.style.right = "auto"; + }; + head.addEventListener("pointerdown", (event) => { + if (event.button !== 0 || (event.target as Element).closest("button")) return; + start = { + id: event.pointerId, + x: event.clientX, + y: event.clientY, + left: root.offsetLeft, + top: root.offsetTop, + }; + head.setPointerCapture(event.pointerId); + }); + head.addEventListener("pointermove", (event) => { + if (start?.id !== event.pointerId) return; + put(start.left + event.clientX - start.x, start.top + event.clientY - start.y); + }); + const end = (event: PointerEvent): void => { + if (start?.id === event.pointerId) start = null; + }; + head.addEventListener("pointerup", end); + head.addEventListener("pointercancel", end); + return () => { + if (root.style.left) put(root.offsetLeft, root.offsetTop); + }; +} + interface CrossModal { open(request: CrossModalOpen): void; } @@ -145,14 +189,14 @@ function createCrossModal(host: HTMLElement): CrossModal {
임시 횡단 +
+ + + +
-
-
- - - -
`; +
`; const $ = (selector: string): T => root.querySelector(selector)!; const station = $(".b05-crossmodal__station"); const box = $(".b05-crossmodal__box"); @@ -164,6 +208,7 @@ function createCrossModal(host: HTMLElement): CrossModal { root.addEventListener(type, (event) => event.stopPropagation()), ); host.append(root); + const refit = bindHeadDrag(root, $(".b05-crossmodal__head")); let current: CrossModalOpen | null = null; let turn = 0; @@ -272,6 +317,7 @@ function createCrossModal(host: HTMLElement): CrossModal { current = request; station.textContent = stationLabel(request.chainageM, request.intervalM); root.hidden = false; + refit(); syncButtons(); watchPipe(); void draw(request.chainageM); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index e53421e4c..07faa1632 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -352,7 +352,8 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind showStation(station?.key ?? null, station?.chainageM ?? at, structure, open); } const stopPick = subscribeSelection(params.projectId, (selection, from) => { - if (from !== PLAN_PICK_FROM) follow(selection); + // 평면 이름 = 눈금 누름(제가 이미 그림)만 거름 · 마커 고름 풂(값 없음)은 따라 비움(500-1) + if (from !== PLAN_PICK_FROM || selection.chainageM === null) follow(selection); }); attach(); // 들어올 때 이미 고른 것 — 판에 그 측점 횡단(494-1 · 안 그리면 고른 채 빈 판 · 다시 누르면 고름이 풀림) · 판은 안 폄 diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 38013b006..40edb8fbd 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -243,8 +243,13 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP canvas.setPointerCapture(event.pointerId); return; } - marks.deselect(); // 마커 아닌 곳 = 마커 고름 풂(오른쪽 횡단 판은 그대로) const pick = choice.pick; + // 교각점 · 손잡이 · 호(곡선 고름) = 마커 고름 유지(500-1) · 그 밖 마커 아닌 곳 = 풂(오른쪽 판도 비움) + const onCurve = + (!!pick && "node" in pick) || + choice.handle !== null || + (!(pick && "chainage" in pick) && arcAtScreen(state.curveInfo, toScreen, px, py) >= 0); + if (!onCurve) marks.deselect(); // 노드 가운데(6px)는 노드 — 헤어핀처럼 곡선이 몰린 데서도 노드를 집음(2026-09-07 사용자 지적 ④) · // 그 밖은 횡단 눈금 · 손잡이와 가까운 쪽(381-3 `pickAt` · 433-14 ③ 낮은 확대 R 12 손잡이). const hitNode = pick && "node" in pick ? pick.node : -1; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts index e93283042..9a59ceafc 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts @@ -9,7 +9,7 @@ * · 끌기 · 방향키(마커 고른 때 · 교각점 안 고른 때) = 노선 따라 정수 m · 1 m 씩 · 시점 · 종점 밖 안 감 · * 놓을 때 · 뗄 때 한 번 옮김(`move` = 알약 끌기와 같은 길 · 관 · 구조물 초안 · [저장]이 정본). * · 그림 · 집기 차례 = 맨 위 · 맨 앞(409 · 재기 다음) — `_Render` · `_Pointer` 가 부름. - * · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판 그대로 · + * · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판도 비움 500-1 · * 좌측 목록 강조 · 폼은 풂 409-2). * 목록 · 고름 · 옮김은 종단 보기가 `setPlanMarkSource` 로 건넴. 걸음 셈은 `logic/…_StructureMarks`. * ========================================================================== */ @@ -333,7 +333,7 @@ export function bindStructureMarks( return moving; }; - /** 고름 풂 — 공용 고름을 비움 · 오른쪽 횡단 판은 제 이름(`PLAN_PICK_FROM`)이라 안 비움. */ + /** 고름 풂 — 공용 고름을 비움 · 오른쪽 횡단 판도 값 없음이라 따라 비움(500-1). */ const deselect = (): boolean => { if (!planMarks(canvas)?.picked) return false; selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM }); diff --git a/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts b/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts index 1e51415c5..430cc6789 100644 --- a/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts +++ b/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts @@ -7,7 +7,7 @@ * ========================================================================== */ /** 원지반선을 눌렀다고 볼 위아래 여유(px). */ -export const GROUND_SLACK_PX = 8; +export const GROUND_SLACK_PX = 12; /** 포인터 아래 = 추가 측점 선(그 누가거리) · 다른 측점선 · 단추("other") · 빈 곳(null). */ export type CrossPickHit = { plusM: number } | "other" | null; diff --git a/resources/tester/helper_411_a_cross_structure.cjs b/resources/tester/helper_411_a_cross_structure.cjs index 224a624ce..b31414518 100644 --- a/resources/tester/helper_411_a_cross_structure.cjs +++ b/resources/tester/helper_411_a_cross_structure.cjs @@ -141,7 +141,7 @@ out.structureMoved = { ...shownNow(), at: preview.watched()?.chainageM ?? null } // ③ 관 고름(누가거리만) = 그 측점 횡단 pick(80.17, null, "b05"); out.pipePick = { ...shownNow(), structure: preview.watched()?.structure ?? null }; -// ④ 평면 마커 고름 풂(b05-plan) = 판 그대로 +// ④ 평면 마커 고름 풂(b05-plan) = 판도 비움(500-1) pick(null, null, "b05-plan"); out.planRelease = shownNow(); // ⑤ 일반측점 고름 = 그 측점 횡단 · 법선(structure 없음) diff --git a/resources/tester/test_409_mark_top_deselect.py b/resources/tester/test_409_mark_top_deselect.py index c6066095b..4171c874f 100644 --- a/resources/tester/test_409_mark_top_deselect.py +++ b/resources/tester/test_409_mark_top_deselect.py @@ -78,7 +78,10 @@ def test_맴돔_커서_마커_먼저() -> None: def test_오른쪽_판은_제_이름_알림을_거름() -> None: cross = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts") - assert "if (from !== PLAN_PICK_FROM) follow(selection);" in cross + # 500-1 — 눈금 누름(값 있음)만 거름 · 마커 고름 풂(값 없음)은 따라 비움 + assert ( + "if (from !== PLAN_PICK_FROM || selection.chainageM === null) follow(selection);" in cross + ) marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts") assert "selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });" in marks diff --git a/resources/tester/test_411_a_cross_structure.py b/resources/tester/test_411_a_cross_structure.py index f8abb0637..1cc0b1289 100644 --- a/resources/tester/test_411_a_cross_structure.py +++ b/resources/tester/test_411_a_cross_structure.py @@ -40,8 +40,12 @@ def test_관_고름_그_측점_횡단(got) -> None: assert got["pipePick"] == {"station": "80.17", "foot": "횡단 80.17", "structure": True} -def test_평면_마커_풂은_판_그대로(got) -> None: - assert got["planRelease"] == {"station": "80.17", "foot": "횡단 80.17"} +def test_평면_마커_풂도_판_비움(got) -> None: + # 500-1(요구 300) — 고름 풀림 = 오른쪽 판도 비움(옛 409 「판 그대로」 걷음) + assert got["planRelease"] == { + "station": "", + "foot": "측점 눈금을 누르면 그 측점 횡단이 뜹니다.", + } def test_일반측점_그대로(got) -> None: diff --git a/resources/tester/test_490_1_cross_pick.py b/resources/tester/test_490_1_cross_pick.py index 05d2c783d..05fb89e29 100644 --- a/resources/tester/test_490_1_cross_pick.py +++ b/resources/tester/test_490_1_cross_pick.py @@ -1,6 +1,6 @@ """490-1 B05 종단 — 임시 횡단이 열리는 자리 가까이 표시 = 누름과 같은 판정(`crossPickAt`). -판정 함수 = 옛 누름 판정(원지반선 위아래 8 px · 정수 m · 추가 측점 선 = 그 측점 · 측점선 · 단추 = null)과 +판정 함수 = 옛 누름 판정(원지반선 위아래 12 px · 500-1 8 → 12 · 정수 m · 추가 측점 선 = 그 측점 · 측점선 · 단추 = null)과 같은 값 · 허용 밖 null. TS 쪽 = `helper_490_1_cross_pick.cjs`(Node). 화면 조작은 ORCA 로 확인. """ @@ -39,14 +39,14 @@ def _old_pick(hit, chainage: float, pointer_y: float) -> int | None: a, b = valid[after - 1], valid[after] t = (chainage - a["chainage_m"]) / (b["chainage_m"] - a["chainage_m"] or 1) ground = a["elevation_m"] + (b["elevation_m"] - a["elevation_m"]) * t - return round(chainage) if abs(pointer_y - _ground_y(ground)) <= 8 else None + return round(chainage) if abs(pointer_y - _ground_y(ground)) <= 12 else None CASES = [ {"hit": None, "chainageM": 10.2, "pointerY": 69.8}, # 지반 y = 69.8 · 딱 위 - {"hit": None, "chainageM": 10.2, "pointerY": 77.8}, # 8 px 끝 - {"hit": None, "chainageM": 10.2, "pointerY": 78.3}, # 허용 밖 - {"hit": None, "chainageM": 10.2, "pointerY": 61.3}, # 위쪽 허용 밖 + {"hit": None, "chainageM": 10.2, "pointerY": 81.8}, # 12 px 끝(옛 8 px 밖) + {"hit": None, "chainageM": 10.2, "pointerY": 82.3}, # 허용 밖 + {"hit": None, "chainageM": 10.2, "pointerY": 57.3}, # 위쪽 허용 밖 {"hit": None, "chainageM": 50.0, "pointerY": 93.0}, # 빈 표고 건너 20→60 보간 {"hit": None, "chainageM": 0.0, "pointerY": 80.0}, # 첫 표본 = null(옛 판정 그대로) {"hit": None, "chainageM": 70.0, "pointerY": 100.0}, # 끝 뒤 @@ -72,7 +72,7 @@ def _run() -> dict: def test_판정_함수_옛_누름과_같은_값() -> None: result = _run() - assert result["slack"] == 8 + assert result["slack"] == 12 picks = [row["pick"] for row in result["out"]] assert picks == [_old_pick(c["hit"], c["chainageM"], c["pointerY"]) for c in CASES] assert picks == [10, 10, None, None, 50, None, None, 34, None] diff --git a/resources/tester/test_500_1_curve_keeps_pick.py b/resources/tester/test_500_1_curve_keeps_pick.py new file mode 100644 index 000000000..892c17b8d --- /dev/null +++ b/resources/tester/test_500_1_curve_keeps_pick.py @@ -0,0 +1,80 @@ +"""500-1 B05 평면 — 곡선(교각점 · 손잡이 · 호) 누름 = 측점 고름 유지 · 풀림 = 오른쪽 판도 비움 · 임시 횡단 창 단추 = 제목 줄 · 제목 줄 끌기. + +./venv/Scripts/python.exe -m pytest resources/tester/test_500_1_curve_keeps_pick.py -q +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(*parts: str) -> str: + return ROOT.joinpath(*parts).read_text(encoding="utf-8") + + +def _down() -> str: + """평면 누름 — 마커 갈래 뒤 ~ 교각점 갈래 앞.""" + pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts") + start = pointer.index("if (choice.mark && marks.press(px, py)) {") + return pointer[start : pointer.index("if (hitNode >= 0) {", start)] + + +# 누름별 — 고름 유지(풂 건너뜀) 조건 한 줄씩 +KEEP = { + "교각점": '(!!pick && "node" in pick)', + "손잡이": "choice.handle !== null", + "호(눈금 아님)": '(!(pick && "chainage" in pick) && arcAtScreen(state.curveInfo, toScreen, px, py) >= 0)', +} + + +def test_곡선_누름은_고름_유지() -> None: + down = _down() + for name, cond in KEEP.items(): + assert cond in down, name + assert "if (!onCurve) marks.deselect();" in down # 그 밖(빈 땅 · 등고선 · 눈금) = 풂 + assert down.count("marks.deselect()") == 1 # 무조건 풂 걷힘 + + +def test_차례_마커_다음_교각점_앞() -> None: + down = _down() + assert down.index("canvas.setPointerCapture(event.pointerId);") < down.index("const onCurve") + assert down.index("const onCurve") < down.index("if (!onCurve) marks.deselect();") + + +def test_풀림은_오른쪽_판도_비움() -> None: + cross = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts") + line = "if (from !== PLAN_PICK_FROM || selection.chainageM === null) follow(selection);" + assert line in cross + follow = cross[cross.index("function follow(") : cross.index(line)] + assert "watching = null;" in follow and "current.show(null, interval());" in follow + # 풂(값 없음 · 평면 이름)을 내는 곳 = 마커 풂 하나 · Esc 도 같은 길 + marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts") + assert marks.count("selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });") == 1 + assert "!activeMoving(canvas) && deselect()" in marks + + +def test_임시_횡단_단추_제목_줄_오른쪽() -> None: + modal = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.ts") + head = modal[ + modal.index('
') : modal.index( + '
' + ) + ] + for act in ("add", "remove", "close"): + assert f'data-act="{act}"' in head + assert head.index("임시 횡단") < head.index("b05-crossmodal__actions") + css = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.css") + actions = css[css.index(".b05-crossmodal__actions {") :] + assert "margin-left: auto;" in actions[: actions.index("}")] + + +def test_제목_줄_끌기_화면_안() -> None: + modal = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.ts") + assert 'bindHeadDrag(root, $(".b05-crossmodal__head"))' in modal + drag = modal[modal.index("function bindHeadDrag(") : modal.index("interface CrossModal {")] + assert '.closest("button")' in drag # 단추 누름은 단추 몫 + assert "innerWidth" in drag and "innerHeight" in drag # 화면 밖 안 나감 + assert "root.parentElement!.getBoundingClientRect()" in drag # 종단 칸 안 + assert "refit();" in modal # 다시 열 때 가둠 From 21e0f10df9257f9054d3efbc87df1bcb8c319020 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 20 Oct 2026 19:54:16 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(496-1=20B05=C2=B7B06=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=EC=A4=84):=20[=ED=99=95=EC=A0=95]=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EC=B0=A8=EB=A1=80=20[=3F]=20[<]=20[>]=20[?= =?UTF-8?q?=EC=A0=80=EC=9E=A5]=20[=EC=B4=88=EA=B8=B0=ED=99=94]=20=C2=B7=20?= =?UTF-8?q?B06=20[>]=20=3D=20=ED=99=95=EC=A0=95=20=EB=92=A4=20B07?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 단추 꼴 danger-ghost(테두리 · 글 빨강 · 올리면 채움) · [초기화]만 - B05 [<] 흐림 · 주소 바뀔 때마다 [<] [>] 다시 맞춤 - 단계 판 B07~B09 · B06 [>] = 확정 뒤 그 주소 · 실패 · 자료 서기 전 = 머묾 - 노선 바뀐 채 확정 = 저장 뒤 물음 [머묾] / [B07 로](새 노선으로 확정 뒤 이동) - 막힘 = NOT_STARTED 만(STALE 은 색만) · B05·B06 화면 2 · 3 · 4 칸 늘 켬 · [저장] 뒤 단계 색 다시 읽음 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_dock.ts | 18 +-- A00_Common/design/design_page.ts | 15 ++- A00_Common/design/design_save.ts | 11 +- A00_Common/design/design_shared.ts | 56 +++++++-- B06_Section/B06_Section_UI_Standard_Panel.ts | 2 +- resources/tester/helper_280_2_step_nav.cjs | 64 ++++++++-- .../tester/helper_design_view_parity.cjs | 3 +- resources/tester/test_280_2_step_nav.py | 42 +++++-- resources/tester/test_b06_save_channels.py | 4 +- resources/tester/test_design_view_parity.py | 15 +-- ui_template/ui_template_elements.ts | 2 +- ui_template/ui_template_elements_styles.ts | 11 ++ ui_template/ui_template_help.ts | 31 +++-- ui_template/ui_template_locale_b2.ts | 21 +++- ui_template/ui_template_workflow_layout.ts | 114 +++++++++++------- 15 files changed, 290 insertions(+), 119 deletions(-) diff --git a/A00_Common/design/design_dock.ts b/A00_Common/design/design_dock.ts index 9b2c1a00a..7959a910c 100644 --- a/A00_Common/design/design_dock.ts +++ b/A00_Common/design/design_dock.ts @@ -2,8 +2,8 @@ * design_dock.ts * B05 · B06 한 페이지 두 보기의 **좌측 패널 맨 아래 고정 영역**(계획서 24-1 · 24-2b). * - * 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 셋은 두 보기가 **같다**: - * [초기화] [저장] [확정](보기 오가기는 공용 [<] [>] · 요구 280-2). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은 + * 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 둘은 두 보기가 **같다**: + * [저장] [초기화](앞은 공용 [?] [<] [>] · B06 [>] = 확정 뒤 B07 · 요구 280-2 · 298-2). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은 * 단추를 만들고 켜고 끄기만 한다(저장 흐름은 `design_save.ts` 몫). * ========================================================================== */ @@ -24,12 +24,11 @@ export interface DesignDockAction { export interface DesignDockActions { reset: DesignDockAction; save: DesignDockAction; - confirm: DesignDockAction; } export interface DesignDock { root: HTMLElement; - /** 단추 셋의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */ + /** 단추 둘의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */ setActions: (actions: Partial) => void; /** 보기가 바뀌면 단추 줄 class 가 따라 바뀐다. */ setView: (view: DesignView) => void; @@ -72,7 +71,7 @@ export function createDesignDock( }; const resetButton = createButton({ label: label("Common_Btn_Reset"), - variant: "danger", + variant: "danger-ghost", onClick: press("reset"), }); const saveButton = createButton({ @@ -80,18 +79,11 @@ export function createDesignDock( variant: "ghost", onClick: press("save"), }); - const confirmButton = createButton({ - label: label("B06_Profile_Btn_Confirm"), - variant: "filled", - onClick: press("confirm"), - }); resetButton.dataset.dockAction = "reset"; saveButton.dataset.dockAction = "save"; - confirmButton.dataset.dockAction = "confirm"; const buttons = { reset: resetButton, save: saveButton, - confirm: confirmButton, }; const sync = (): void => { @@ -104,7 +96,7 @@ export function createDesignDock( row.className = current === "profile" ? "b05-route__actions" : "b06-profile__actions"; }; - row.append(resetButton, saveButton, confirmButton); + row.append(saveButton, resetButton); root.append(...above, divider, row, busy); sync(); diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 1d54d57e2..02d492400 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -21,6 +21,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createWorkflowLayout, type WorkflowLayoutHandle } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../b_page_scaffold"; import { + clearWorkflowStateCache, fetchWorkflowState, goToWorkflowStage, WORKFLOW_STEP_ROUTES, @@ -28,7 +29,7 @@ import { } from "../b_workflow_nav"; import { currentRoute, navigateTo, type MountedPage } from "../router"; import type { DesignView } from "./design_dock"; -import { saveSharedDesign, type DesignPageShared } from "./design_shared"; +import { confirmAndGo, saveSharedDesign, type DesignPageShared } from "./design_shared"; import { guardDesignLeave } from "./design_leave"; import { usePipeIds } from "./design_pipe_ids"; import { createFacilityLane } from "./design_facility_lane"; @@ -152,6 +153,12 @@ export function renderDesignPage(root: HTMLElement): MountedPage { lane: createFacilityLane(projectId, () => shared.profile), revealLeft: () => layout?.setOptionsOpen(true), ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready, + refreshStages: () => { + clearWorkflowStateCache(); + void fetchWorkflowState(projectId) + .then((state) => layout?.setStages(state.stages)) + .catch(() => undefined); + }, }; guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4) let busy = false; // 「계산 중」 점 — 좌측 패널이 늦게 서도 지금 상태로(386-2) @@ -177,12 +184,14 @@ export function renderDesignPage(root: HTMLElement): MountedPage { leftPanel: left, mainContent: main, stages: state?.stages, - currentStage: state?.current_stage, + // 종단 · 횡단 · 상세설계 칸은 늘 켬 — B07 로 가는 길이 곧 확정(496-1 · 운영 잠금 흠) + currentStage: [2, 3, 4], routes: WORKFLOW_STEP_ROUTES, - // 종단설계 · 횡단설계 두 칸은 같은 페이지 — 주소만 바꿔 보기만 바꾼다. + // 종단설계 · 횡단설계 두 칸은 같은 페이지 — 주소만 바꿔 보기만 바꾼다. B07~B09 = 확정 뒤 이동(496-1). onStepClick: (stepIndex) => { const route = WORKFLOW_STEP_ROUTES[stepIndex]; if (designViewOf(route)) navigateTo(route); + else if (stepIndex >= 4) confirmAndGo(shared, route); else goToWorkflowStage(projectId, route); }, }); diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index eda24c8cf..0317095d6 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -65,6 +65,8 @@ export interface DesignSaveContext { done?: (confirm: boolean) => Promise | void; /** 멈춘 저장을 알린다. */ failed: (error: unknown, confirm: boolean) => void; + /** 노선 모양 바뀐 채 확정 — 노선 · 고침은 저장됨 · 페이지 다시 세우기 앞에 물음(요구 300 B2). */ + routeMoved?: () => Promise; } export type PartOverride = ( @@ -222,9 +224,12 @@ export async function saveDesign( } const saved = (result.steps.route ?? {}) as unknown as RouteReplanResponse; noticeRouteSaved(saved, Number(result.steps.carry?.outside ?? 0)); - // 새 노선 — [확정]은 새 노선을 본 뒤 다시(단계 닫기는 새 횡단 본문이 있어야 함) · 페이지 다시 세움. - if (confirm) showToast("노선을 저장했습니다 — 새 노선을 확인한 뒤 다시 [확정]하세요.", "info"); - else await ctx.done?.(false); + // 새 노선 — 확정은 새 횡단 본문이 있어야 함 → 물음(머묾 / 새 노선으로 확정 뒤 이동) · 페이지 다시 세움. + if (confirm) { + hideLoadingOverlay(); // 물음 동안 가림막을 걷음 + await ctx.routeMoved?.(); + showLoadingOverlay(); + } else await ctx.done?.(false); await rebuildAfterRouteSave(ctx.projectId); // 옛 노선 칸 · 초안 전 hold = 서버가 새 노선에 넘겼음 — 쓰레기만 비움(넘김 규칙 그대로 · 세션 넘침 388-3) if (saved.route_id !== routeId) clearRouteDrafts(ctx.projectId, routeId); diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index cef8daf8e..c808a2c5d 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -7,14 +7,19 @@ * `bindDesignView` 로 건 동작으로 간다. 값이 바뀐 알림(관 목록 · 횡단 재계산)은 숨은 보기도 * 받는다 — 다시 보일 때 같은 값을 들고 있게. * 시설 줄(알약 · 비정규 측점 · 관 id 장부)은 페이지 한 벌(`design_facility_lane`) — 관 알림 · 노선 따라감을 여기서 넘김. - * [저장] · [확정]은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로 보낸다. 종단 보기의 - * [확정]은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`). + * [저장] · 확정(B06 [>] · 단계 판 B07~B09 · 496-1)은 두 보기 몫을 모아 저장 한 줄기(`saveDesign`) 한 번으로 + * 보낸다. 종단 보기의 확정은 횡단 보기를 숨긴 채 세워 횡단 자료를 같은 페이지에서 얻는다(`ensureView`). * ========================================================================== */ -import { showToast } from "@ui/ui_template_elements"; +import type { RoutePath } from "@config/config_frontend"; +import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { splitSidebarActions } from "@ui/ui_template_overlay"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav"; +import { + clearWorkflowStateCache, + goToWorkflowStage, + WORKFLOW_STEP_ROUTES, +} from "../b_workflow_nav"; import { readState, writeState } from "../b_page_state"; import { currentRoute, remountRoute } from "../router"; import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch"; @@ -116,6 +121,10 @@ export interface DesignPageShared { revealLeft: () => void; /** 그 보기를 (숨긴 채라도) 세우고 자료가 설 때까지 기다린다. */ ensureView: (view: DesignView) => Promise; + /** 자료가 섰다(`designReady`) — 그 전 확정 이동은 막는다. */ + ready?: boolean; + /** [저장] 뒤 진행 단계 판 색을 서버 단계 상태로 다시 맞춘다(496-1). */ + refreshStages?: () => void; } const bound = (shared: DesignPageShared): DesignViewBinding => shared.bindings[shared.view()] ?? {}; @@ -342,7 +351,6 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel { }, // 자료가 서기 전에는 잠근다 — 보기가 `designReady` 로 연다. save: { run: () => saveSharedDesign(shared, false), enabled: false }, - confirm: { run: () => saveSharedDesign(shared, true), enabled: false }, }, }, shared.view(), @@ -355,16 +363,29 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel { return panel; } -/** 자료가 섰다 — [저장] · [확정]을 연다(두 보기 어느 쪽이 먼저든 한 번이면 된다). */ +/** 노선 바뀐 채 확정 → [B07 로] 고름 — 다시 세운 페이지가 자료를 세우면 새 노선으로 확정 뒤 그 주소(요구 300 B2). */ +const confirmOnReady = new Map(); + +/** 자료가 섰다 — [저장] · 확정 이동을 연다(두 보기 어느 쪽이 먼저든 한 번이면 된다). */ export function designReady(shared: DesignPageShared): void { + shared.ready = true; shared.panel?.dock.setActions({ save: { run: () => saveSharedDesign(shared, false), enabled: true, title: L("B06_Profile_Btn_Save_Tip"), }, - confirm: { run: () => saveSharedDesign(shared, true), enabled: true }, }); + const target = confirmOnReady.get(shared.projectId); + if (!target) return; + confirmOnReady.delete(shared.projectId); + void saveSharedDesign(shared, true, target); +} + +/** B06 [>] · 단계 판 B07~B09(496-1) — 확정 뒤 그 주소 · 실패 · 자료 서기 전 = 머묾. */ +export function confirmAndGo(shared: DesignPageShared, route: RoutePath): void { + if (!shared.ready) return void showToast(L("Design_Next_NotReady"), "info"); + void saveSharedDesign(shared, true, route); } /** @@ -384,10 +405,14 @@ async function routeChanged(shared: DesignPageShared): Promise { } /** - * [저장] · [확정] 한 번 — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가, + * [저장] · 확정 한 번(확정 = `target` 으로 감 · 기본 B07) — 관(배수유역도) · 계획고는 한 벌 종단도가, 구조물 · 상단측은 종단 보기가, * 저장 앞 재계산 · 암거 옵션 · 횡단 본문은 횡단 보기가 낸다. 차례는 `saveDesign` 한 곳. */ -export async function saveSharedDesign(shared: DesignPageShared, confirm: boolean): Promise { +export async function saveSharedDesign( + shared: DesignPageShared, + confirm: boolean, + target: RoutePath = WORKFLOW_STEP_ROUTES[4], +): Promise { if (await routeChanged(shared)) return; // [저장] · [확정] 본문 = 횡단 보기 한 벌(표준 횡단면 · 전 측점 면적 · 카드 고침) — 들어온 화면 무관 · 숨긴 채라도 // 세움(B05 만 연 캐시 패치 길 지움 · 386-6) @@ -432,7 +457,7 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea // 서버가 정본을 다시 씀 — 다음 페이지(B07)가 브라우저 셈 · 옛 세션 사본 말고 정본을 받게(470 N1) invalidateSectionDetail(shared.projectId); showToast(L("B06_Profile_Confirm_Success"), "success"); - goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]); + goToWorkflowStage(shared.projectId, target); return; } // 서버가 정본(계획선 · 횡단 · 면적)을 다시 썼다 — 공유 캐시를 한 번 비우고 두 보기가 새 상세 한 객체를 @@ -441,6 +466,17 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea await profileSave?.done?.(false); await crossSave?.done?.(false); showToast(L("B06_Profile_Save_Success"), "success"); + shared.refreshStages?.(); + }, + // 노선 모양 바뀐 채 확정 — 새 노선은 저장됨 · 머묾 / 새 노선으로 확정 뒤 이동을 고름(요구 300 B2) + routeMoved: async () => { + clearWorkflowStateCache(); + const go = await showConfirmDialog( + L("Design_Route_Moved"), + L("Design_Route_Moved_Go"), + L("Design_Route_Moved_Stay"), + ); + if (go) confirmOnReady.set(shared.projectId, target); }, failed: (error, isConfirm) => { const key = isConfirm ? "B06_Profile_Confirm_Failed" : "B06_Profile_Save_Failed"; diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index 11a5f15eb..d0c71d585 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -331,7 +331,7 @@ export function createStandardPanel( pending.className = "b06-std__pending"; pending.textContent = "고친 값이 측점에 아직 안 실렸습니다 — [전체 측점 반영]을 누르면 바로 따라오고, " + - "[저장]·[확정] 때도 전 측점이 이 값으로 다시 섭니다. 그전에 카드를 만지면 그 측점만 먼저 바뀝니다."; + "[저장]·[>](확정) 때도 전 측점이 이 값으로 다시 섭니다. 그전에 카드를 만지면 그 측점만 먼저 바뀝니다."; const showPending = (): void => { const now = JSON.stringify(state); // 값이 실렸는지 눈으로도 캘 수 있게 남긴다 — 화면 실측에서 판정 근거가 된다. diff --git a/resources/tester/helper_280_2_step_nav.cjs b/resources/tester/helper_280_2_step_nav.cjs index b3b98a334..003add5f3 100644 --- a/resources/tester/helper_280_2_step_nav.cjs +++ b/resources/tester/helper_280_2_step_nav.cjs @@ -16,6 +16,7 @@ class FakeNode { this.dataset = {}; this.listeners = {}; this.disabled = false; + this.isConnected = true; this.title = ""; this._text = ""; this._classes = new Set(); @@ -87,6 +88,16 @@ class CustomEvent { } globalThis.document = { createElement: (tag) => new FakeNode(tag) }; globalThis.location = { hash: "" }; +// 주소 바뀜 듣개 — B05 ↔ B06 은 페이지를 다시 안 세우고 [<] [>] 켬 · 끔만 다시 맞춤(496-1) +const hashListeners = new Set(); +globalThis.window = { + addEventListener: (type, fn) => type === "hashchange" && hashListeners.add(fn), + removeEventListener: (type, fn) => type === "hashchange" && hashListeners.delete(fn), +}; +const goHash = (route) => { + location.hash = `#/${route}`; + [...hashListeners].forEach((fn) => fn()); +}; globalThis.CustomEvent = CustomEvent; globalThis.HTMLButtonElement = FakeNode; @@ -190,9 +201,9 @@ for (const route of ALL) rows[route] = { helpRow: rowOf(route, true), plain: row // ② [<] [>] = 이웃 단계 누름 const STEP_ROUTES = ALL.slice(0, 7); -function page(route, { stages, dev = false, module = layout } = {}) { +function page(route, { stages, dev = false, module = layout, current } = {}) { globalThis.__DEV = dev; - location.hash = `#/${route}`; + goHash(route); const calls = []; const handle = module.createWorkflowLayout({ title: route, @@ -201,18 +212,23 @@ function page(route, { stages, dev = false, module = layout } = {}) { mainContent: new FakeNode("div"), routes: STEP_ROUTES, stages, - currentStage: STEP_ROUTES.indexOf(route), + currentStage: current ?? STEP_ROUTES.indexOf(route), onStepClick: (index, to) => calls.push(`${index}:${to}`), }); - const press = (step) => { - toasts.length = 0; - const button = handle.root + const navButton = (step) => + handle.root .querySelectorAll(".ui-step-nav-btn") .find((b) => b.dataset.stepNav === String(step)); - button.click(); + const press = (step) => { + toasts.length = 0; + navButton(step).click(); return { calls: calls.splice(0), toasts: [...toasts] }; }; - return { handle, press }; + const stepButton = (index) => + handle.root.querySelectorAll(".ui-workflow-layout__step:not(.ui-workflow-layout__step--home)")[ + index + ]; + return { handle, press, navButton, stepButton }; } (async () => { @@ -220,11 +236,16 @@ function page(route, { stages, dev = false, module = layout } = {}) { const b05 = page("b05-profile"); nav.b05Prev = b05.press(-1); nav.b05Next = b05.press(1); - // B05 → B06 = 같은 페이지 — 켜진 칸만 갈아 끼움(design_page 의 setActiveStep) - location.hash = "#/b06-section"; + nav.prevDisabled = { b05: b05.navButton(-1).disabled }; + // B05 → B06 = 같은 페이지 — 켜진 칸만 갈아 끼움(design_page 의 setActiveStep) · [<] 는 주소 따라 켬 + goHash("b06-section"); b05.handle.setActiveStep(3); + nav.prevDisabled.b06 = b05.navButton(-1).disabled; nav.b06Prev = b05.press(-1); nav.b06Next = b05.press(1); + goHash("b05-profile"); + b05.handle.setActiveStep(2); + nav.prevDisabled.back = b05.navButton(-1).disabled; const b09 = page("b09-estimation"); nav.b09Prev = b09.press(-1); nav.b09Next = b09.press(1); @@ -236,11 +257,32 @@ function page(route, { stages, dev = false, module = layout } = {}) { })); const blocked = page("b06-section", { stages }); nav.blockedNext = blocked.press(1); + // [저장] 뒤 서버 단계로 다시 칠함 — 시작함(IN_PROGRESS)이면 켬 + blocked.handle.setStages( + stages.map((s) => ({ ...s, state: s.stage_no === 4 ? "IN_PROGRESS" : s.state })), + ); + nav.repaintNext = blocked.press(1); + nav.repaintClass = blocked.stepButton(4).className; + // STALE = 색만 · 막지 않음(요구 300 B1) + const stale = page("b06-section", { + stages: stages.map((s) => ({ ...s, state: s.stage_no >= 4 ? "STALE" : s.state })), + }); + nav.staleNext = stale.press(1); + nav.staleClass = stale.stepButton(4).className; + // B05 · B06 화면 = 2 · 3 · 4 늘 켬(운영 · 체인 직후 NOT_STARTED 라도) · B08 은 막힘 + const chain = page("b06-section", { stages, current: [2, 3, 4] }); + nav.listNext = chain.press(1); + nav.listB08 = chain.stepButton(5).disabled; // 일반 사용자 — B05 [<](B04) = 관리자 전용 알림(역할은 모듈이 한 번 물어 기억 → 새로 읽음) role = "USER"; const fresh = loadTs(path.join(ROOT, "ui_template", "ui_template_workflow_layout.ts")); - const user = page("b05-profile", { dev: true, module: fresh }); + const user = page("b06-section", { dev: true, module: fresh }); await new Promise((r) => setTimeout(r, 0)); + // B06 [<] = B05(같은 페이지) · B04 단계 단추 = 관리자 전용 잠금(다시 칠해도 남음) nav.userPrev = user.press(-1); + const lockOf = (b) => ({ disabled: b.disabled, title: b.title }); + nav.userB04 = lockOf(user.stepButton(1)); + user.handle.setStages(stages); + nav.userB04Repaint = lockOf(user.stepButton(1)); process.stdout.write(JSON.stringify({ rows, nav })); })(); diff --git a/resources/tester/helper_design_view_parity.cjs b/resources/tester/helper_design_view_parity.cjs index 3f05a78a4..e8a83b677 100644 --- a/resources/tester/helper_design_view_parity.cjs +++ b/resources/tester/helper_design_view_parity.cjs @@ -123,7 +123,6 @@ function html(node) { const LOCALES = { Common_Btn_Reset: ["초기화", "Reset"], B06_Profile_Btn_Save: ["저장", "Save"], - B06_Profile_Btn_Confirm: ["확정", "Confirm"], Design_Busy: ["계산 중", "Calculating"], // 「계산 중」 점(386-2) }; const STUBS = { @@ -203,7 +202,6 @@ const built = panel.createDesignPanel( dock: { reset: { run: run("reset") }, save: { run: run("save") }, - confirm: { run: run("confirm") }, }, }, "profile", @@ -232,6 +230,7 @@ function snapshot() { dockIsLast: built.root.children[built.root.children.length - 1] === dock, dockLabels: buttons.map((b) => b.textContent), dockActions: buttons.map((b) => b.dataset.dockAction), + dockClasses: buttons.map((b) => b.className), dockDisabled: buttons.map((b) => b.disabled), }; } diff --git a/resources/tester/test_280_2_step_nav.py b/resources/tester/test_280_2_step_nav.py index 5d95793fe..7693b2b47 100644 --- a/resources/tester/test_280_2_step_nav.py +++ b/resources/tester/test_280_2_step_nav.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """요구 280-2 · PLAN 481-1 — 좌측 아래 [?] [<] [>] 페이지 이동. -① 줄 차례 = [?] [<] [>] + 그 페이지 단추 · 다섯 주소만(b03 · b04 · b09-print · m01 · m02 = [?] 만) · B09 [>] 흐림 -② [<] [>] = 이웃 단계 단추 누름(`onStepClick` 한 길) · 막힌 단계 = 누르지 않고 그 단추 title 알림 -③ B05 ↔ B06 = 같은 페이지 주소만(다시 안 실음) · B05 · B06 도크 = [초기화] [저장] [확정] 셋 +① 줄 차례 = [?] [<] [>] + 그 페이지 단추 · 다섯 주소만(b03 · b04 · b09-print · m01 · m02 = [?] 만) · B05 [<] · B09 [>] 흐림 +② [<] [>] = 이웃 단계 단추 누름(`onStepClick` 한 길) · 막힌 단계(NOT_STARTED 만) = 누르지 않고 그 단추 title 알림 +③ B05 ↔ B06 = 같은 페이지 주소만(다시 안 실음) · 주소 바뀌면 [<] 다시 맞춤 · 도크 = [저장] [초기화] 둘(496-1) """ import json @@ -40,15 +40,15 @@ def test_다섯_주소만_물음_앞뒤_차례(results, shape): assert results["rows"][route][shape]["labels"] == ["?", "저장"], route -def test_B09_다음만_흐림(results): +def test_B05_앞_B09_다음만_흐림(results): for route in FIVE: - expected = [False, False, route == "b09-estimation", False] + expected = [False, route == "b05-profile", route == "b09-estimation", False] assert results["rows"][route]["helpRow"]["disabled"] == expected, route def test_앞뒤는_이웃_단계_누름(results): nav = results["nav"] - assert nav["b05Prev"] == {"calls": ["1:b04-preprocess"], "toasts": []} + assert nav["b05Prev"] == {"calls": [], "toasts": []} # 흐림 — 누름 안 감(496-1) assert nav["b05Next"] == {"calls": ["3:b06-section"], "toasts": []} # B05 → B06 은 같은 페이지 — setActiveStep 으로 켜진 칸이 바뀐 뒤 이웃 assert nav["b06Prev"] == {"calls": ["2:b05-profile"], "toasts": []} @@ -57,10 +57,32 @@ def test_앞뒤는_이웃_단계_누름(results): assert nav["b09Next"] == {"calls": [], "toasts": []} +def test_B05_앞_흐림은_주소_따라_다시_맞춤(results): + assert results["nav"]["prevDisabled"] == {"b05": True, "b06": False, "back": True} + + def test_막힌_단계는_알림만(results): nav = results["nav"] assert nav["blockedNext"] == {"calls": [], "toasts": ["WF_State_NotStarted"]} - assert nav["userPrev"] == {"calls": [], "toasts": ["시스템 관리자 전용 단계입니다."]} + assert nav["userPrev"] == {"calls": ["2:b05-profile"], "toasts": []} + lock = {"disabled": True, "title": "시스템 관리자 전용 단계입니다."} + assert nav["userB04"] == lock and nav["userB04Repaint"] == lock + + +def test_STALE_은_색만_막지_않음(results): + nav = results["nav"] + assert nav["staleNext"] == {"calls": ["4:b07-design-detail"], "toasts": []} + assert "state-stale" in nav["staleClass"].split() + + +def test_다시_칠하기와_켤_칸_목록(results): + nav = results["nav"] + assert nav["repaintNext"] == {"calls": ["4:b07-design-detail"], "toasts": []} + assert "state-in_progress" in nav["repaintClass"].split() + assert "state-not_started" not in nav["repaintClass"].split() + # B05 · B06 화면 = 2 · 3 · 4 늘 켬 — 운영 · 체인 직후 B07 NOT_STARTED 라도 [>] 가 감 · B08 은 막힘 + assert nav["listNext"] == {"calls": ["4:b07-design-detail"], "toasts": []} + assert nav["listB08"] is True def test_B05_B06_사이는_주소만_다시_안_실음(): @@ -70,6 +92,10 @@ def test_B05_B06_사이는_주소만_다시_안_실음(): shared = (ROOT / "A00_Common" / "design" / "design_shared.ts").read_text(encoding="utf-8") for text in (dock, shared): assert "switchView" not in text - assert "row.append(resetButton, saveButton, confirmButton);" in dock + assert "row.append(saveButton, resetButton);" in dock + assert "confirmButton" not in dock and 'variant: "danger-ghost"' in dock + assert "currentStage: [2, 3, 4]" in page + assert "else if (stepIndex >= 4) confirmAndGo(shared, route);" in page locale = (ROOT / "ui_template" / "ui_template_locale_b2.ts").read_text(encoding="utf-8") assert "Design_Btn_ToCross" not in locale and "Design_Btn_ToProfile" not in locale + assert "B06_Profile_Btn_Confirm" not in locale diff --git a/resources/tester/test_b06_save_channels.py b/resources/tester/test_b06_save_channels.py index 8683f8af3..6fa959d27 100644 --- a/resources/tester/test_b06_save_channels.py +++ b/resources/tester/test_b06_save_channels.py @@ -93,7 +93,9 @@ def test_두_페이지가_저장_한_줄기를_탄다(): actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text(encoding="utf-8") persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") assert shared.count("await saveDesign(") == 1, "저장 한 줄기를 한 자리에서만 부름" - assert "saveSharedDesign(shared, true)" in shared, "[확정]이 한 줄기를 안 탐" + page = (ROOT / "A00_Common" / "design" / "design_page.ts").read_text(encoding="utf-8") + assert "saveSharedDesign(shared, true, route)" in shared, "확정(B06 [>])이 한 줄기를 안 탐" + assert "confirmAndGo(shared, route)" in page, "B06 [>] · 단계 판 B07~B09 가 확정을 안 탐(496-1)" assert 'await shared.ensureView("cross")' in shared, "종단 보기 [확정]이 횡단 자료를 못 얻음" for name, text in (("종단 보기", actions), ("횡단 보기", persist), ("한 벌", shared)): for token in ("flushPendingPipes(", "flushPendingStructures(", "flushUphillOverrides("): diff --git a/resources/tester/test_design_view_parity.py b/resources/tester/test_design_view_parity.py index f1518bd4f..e13be7c44 100644 --- a/resources/tester/test_design_view_parity.py +++ b/resources/tester/test_design_view_parity.py @@ -87,18 +87,19 @@ def test_보기마다_제_칸만_보인다(results): assert results["sectionIds"]["cross"] == ["structures", "cross-view", "standard-cross"] -def test_아래_단추는_세_개_보기_전환_없음(results): - """[노선 보기] · [횡단 보기] 지움 — 보기 오가기는 공용 [<] [>](요구 280-2)""" +def test_아래_단추는_둘_보기_전환_없음(results): + """[노선 보기] · [횡단 보기] 지움 — 보기 오가기는 공용 [<] [>](요구 280-2) · [확정] 지움 = B06 [>](496-1)""" views = results["views"] for name in ("profile", "cross", "back"): - assert views[name]["dockActions"] == ["reset", "save", "confirm"] - assert views[name]["dockLabels"] == ["초기화", "저장", "확정"] - # [확정]은 종단 보기에서도 켜진다(한 벌 저장이 횡단 자료를 같은 페이지에서 얻음). - assert views[name]["dockDisabled"] == [False, False, False] + assert views[name]["dockActions"] == ["save", "reset"] + assert views[name]["dockLabels"] == ["저장", "초기화"] + assert views[name]["dockDisabled"] == [False, False] + # [초기화] = 테두리 · 글만 빨강(danger-ghost) · [저장] = ghost + assert views[name]["dockClasses"] == ["ui-btn ui-btn--ghost", "ui-btn ui-btn--danger-ghost"] def test_단추는_한_벌_동작을_부름(results): - names = ["reset", "save", "confirm"] + names = ["save", "reset"] assert results["calls"] == [ *(f"profile:{n}" for n in names), *(f"cross:{n}" for n in names), diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 6ed57ae36..6911020ba 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -23,7 +23,7 @@ export { el } from "./ui_template_elements_base"; * 1. 버튼 (Button) — design.md: Filled Brand / Ghost Outlined / Pill Nav * -------------------------------------------------------------------------- */ -export type ButtonVariant = "filled" | "ghost" | "pill" | "danger" | "glass"; +export type ButtonVariant = "filled" | "ghost" | "pill" | "danger" | "danger-ghost" | "glass"; export type ButtonSize = "md" | "sm"; export interface ButtonOptions { diff --git a/ui_template/ui_template_elements_styles.ts b/ui_template/ui_template_elements_styles.ts index ec674b701..7ac8d386d 100644 --- a/ui_template/ui_template_elements_styles.ts +++ b/ui_template/ui_template_elements_styles.ts @@ -67,6 +67,17 @@ const BASE_CSS = ` color: var(--color-canvas); } +/* [초기화] 꼴 — 테두리 · 글만 빨강 · 올리면 채움 */ +.ui-btn--danger-ghost { + background-color: transparent; + border-color: var(--color-danger); + color: var(--color-danger); +} +.ui-btn--danger-ghost:hover:not(:disabled) { + background-color: var(--color-danger); + color: var(--color-canvas); +} + /* 3D 뷰포트 오버레이용: 배경 위에 떠 있어 반투명 + 블러 필요 */ .ui-btn--glass { border-color: color-mix(in srgb, var(--color-border) 65%, transparent); diff --git a/ui_template/ui_template_help.ts b/ui_template/ui_template_help.ts index c7ca4d3f6..c8d7f13ec 100644 --- a/ui_template/ui_template_help.ts +++ b/ui_template/ui_template_help.ts @@ -173,7 +173,7 @@ export function createHelpButton(): HTMLButtonElement { return button; } -function createStepNavButton(step: -1 | 1, disabled: boolean): HTMLButtonElement { +function createStepNavButton(step: -1 | 1): HTMLButtonElement { const button = createButton({ label: step < 0 ? STEP_TEXT.prev : STEP_TEXT.next, variant: "ghost", @@ -183,18 +183,33 @@ function createStepNavButton(step: -1 | 1, disabled: boolean): HTMLButtonElement button.classList.add("ui-step-nav-btn"); button.dataset.stepNav = String(step); button.title = button.ariaLabel = step < 0 ? STEP_TEXT.prevTip : STEP_TEXT.nextTip; - button.disabled = disabled; return button; } +/** 지금 주소의 [<] [>] 차례 — 목록 밖이면 -1 */ +function stepNavIndex(): number { + const route = location.hash.replace(/^#\/?/, "").split("?")[0]; + return (STEP_NAV_ROUTES as readonly string[]).indexOf(route); +} + /** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */ export function placeHelpButton(actions: HTMLElement): void { if (actions.querySelector(".ui-help-btn")) return; const row = actions.querySelector("[data-help-row]") ?? actions; - const route = location.hash.replace(/^#\/?/, "").split("?")[0]; - const index = (STEP_NAV_ROUTES as readonly string[]).indexOf(route); - if (index < 0) return void row.prepend(createHelpButton()); - // B09 = 마지막 단계 — [>] 흐림 - const last = index === STEP_NAV_ROUTES.length - 1; - row.prepend(createHelpButton(), createStepNavButton(-1, false), createStepNavButton(1, last)); + if (stepNavIndex() < 0) return void row.prepend(createHelpButton()); + const prev = createStepNavButton(-1); + const next = createStepNavButton(1); + // 첫 단계(B05) [<] · 마지막 단계(B09) [>] 흐림 — B05 ↔ B06 은 페이지를 다시 안 세우므로 주소마다 다시 맞춤 + const sync = (): void => { + const index = stepNavIndex(); + prev.disabled = index <= 0; + next.disabled = index === STEP_NAV_ROUTES.length - 1; + }; + const onHash = (): void => { + if (prev.isConnected) sync(); + else window.removeEventListener("hashchange", onHash); // 화면에서 빠짐 — 풂 + }; + sync(); + window.addEventListener("hashchange", onHash); + row.prepend(createHelpButton(), prev, next); } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index fbeddc50c..6756de0d4 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -191,14 +191,12 @@ export const ui_locales_b2 = { B06_Profile_Field_Smooth: ["지표면 스무딩", "Smooth surface"], B06_Profile_Smooth_On: ["사용", "On"], B06_Profile_Smooth_Off: ["미사용", "Off"], - /* 2026-08-08 재정의 — 확정은 종+횡 통합(stage 2·3 함께 완료), 종단 이동은 저장 없이 B05로. */ - B06_Profile_Btn_Confirm: ["확정", "Confirm"], B06_Profile_Btn_GoProfile: ["종단 이동", "Go to Profile"], /* 저장 — 확정과 같은 내용을 저장하되 경로 상태·워크플로 단계는 건드리지 않는다. */ B06_Profile_Btn_Save: ["저장", "Save"], B06_Profile_Btn_Save_Tip: [ - "지금까지 편집한 내용을 확정하지 않고 저장합니다. 페이지는 그대로 있습니다.", - "Saves the current edits without confirming. You stay on this page.", + "지금까지 편집한 내용을 저장합니다. 페이지는 그대로 있습니다 — 확정은 B06 [>] 로 B07 에 넘어갈 때.", + "Saves the current edits. You stay on this page — B06 [>] confirms and moves to B07.", ], B06_Profile_Save_Success: ["편집 내용을 저장했습니다.", "Changes saved."], B06_Profile_Save_Failed: ["저장에 실패했습니다.", "Failed to save."], @@ -405,8 +403,8 @@ export const ui_locales_b2 = { B06_Profile_View_ZoomOut: ["축소", "Zoom out"], B06_Profile_View_ZoomFit: ["원래 크기", "Fit"], B06_MassHaul_Balloon_Reset_Tip: [ - "끌어 옮긴 유토곡선 도형을 자동 배치로 되돌립니다. 횡단을 확정하면 저장된 위치도 함께 지워집니다.", - "Restores dragged mass-haul labels to automatic placement. Confirming the sections also clears the stored positions.", + "끌어 옮긴 유토곡선 도형을 자동 배치로 되돌립니다. B06 [>] 로 B07 에 넘어가면(확정) 저장된 위치도 함께 지워집니다.", + "Restores dragged mass-haul labels to automatic placement. Moving to B07 with B06 [>] (confirm) also clears the stored positions.", ], /* --- B06 측점 표준횡단 설계 지정 --- */ @@ -1056,4 +1054,15 @@ export const ui_locales_b2 = { "Calculation unstable — inputs and results did not settle in three rounds", ], Design_Busy: ["계산 중", "Calculating"], + /* B06 [>] = 확정 뒤 B07(496-1) */ + Design_Next_NotReady: [ + "자료를 불러오는 중입니다 — 다 뜬 뒤 다시 누르세요.", + "Still loading — press again when the page is ready.", + ], + Design_Route_Moved: [ + "노선이 바뀌었음 — 새 노선과 고친 값은 저장됨. 새 노선을 보고 머물거나, 새 노선으로 확정한 뒤 B07 로 넘어감.", + "The route changed — the new route and edits are saved. Stay to review it, or confirm with the new route and go to B07.", + ], + Design_Route_Moved_Go: ["B07 로", "Go to B07"], + Design_Route_Moved_Stay: ["머묾", "Stay"], } as const satisfies Record; diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index a8bab71f7..21506dae8 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -27,7 +27,8 @@ export interface WorkflowLayoutOptions { leftPanel?: HTMLElement; mainContent: HTMLElement; stages?: WorkflowStage[]; - currentStage?: number; + /** 막힌(NOT_STARTED) 단계라도 켤 칸 — 수 하나 또는 목록(B05 · B06 = 2 · 3 · 4 · 496-1) */ + currentStage?: number | readonly number[]; routes?: readonly string[]; onStepClick?: (stepIndex: number, route: string) => void; } @@ -39,11 +40,13 @@ export interface WorkflowLayoutHandle { /** 한 페이지 두 보기(B05 · B06) — 보기가 바뀌면 제목 · 켜진 단계만 갈아 끼운다. */ setTitle: (title: string) => void; setActiveStep: (stepIndex: number) => void; + /** [저장] · [>] 뒤 서버 단계 상태로 색 · 켬을 다시 맞춘다(496-1). */ + setStages: (stages: WorkflowStage[]) => void; } export interface StepBarOptions { stages?: WorkflowStage[]; - currentStage?: number; + currentStage?: number | readonly number[]; routes?: readonly string[]; compact?: boolean; orientation?: "horizontal" | "vertical"; @@ -53,6 +56,8 @@ export interface StepBarOptions { homeButton?: boolean; } +type StepBarElement = HTMLElement & { repaintSteps?: (stages: WorkflowStage[]) => void }; + export const WORKFLOW_STEP_ICONS = ["📁", "🗺️", "🛣️", "📐", "∑", "🏗️", "📄"] as const; export function createStepBar( @@ -62,6 +67,9 @@ export function createStepBar( ): HTMLElement { const bar = document.createElement("div"); bar.className = "ui-workflow-layout__steps"; + // 단계 단추 다시 칠하기(`setStages`) — 단추 차례대로 + const painters: Array<(stages: WorkflowStage[]) => void> = []; + (bar as StepBarElement).repaintSteps = (stages) => painters.forEach((paint) => paint(stages)); if (options?.compact) bar.classList.add("is-compact"); if (options?.orientation === "vertical") bar.classList.add("is-vertical"); // 진행단계 오버레이(세로형) 최상단에 대시보드로 돌아가는 버튼을 둔다(2026-08-04 사용자 @@ -101,59 +109,74 @@ export function createStepBar( button.textContent = step; } - const stage = - options?.stages?.find((item) => item.stage_no === index) ?? options?.stages?.[index]; - const hasStages = Boolean(options?.stages?.length); - const isBlockedState = stage?.state === "NOT_STARTED" || stage?.state === "STALE"; - // ⚠ 개발 전용 — 막힌 단계도 눌러서 들어간다 (2026-09-09 사용자 지시). - // B08 의 「확정 없이 다음으로」 단추가 **B08 화면 안에** 있어서, B07 에서 B08 로 못 - // 넘어가면 그 단추에 닿을 길이 없었다(닫힌 문 안에 열쇠가 있는 꼴). - // ⚠ **잠금을 푸는 것이 아니라 가기만 한다** — 값이 없어 「미확보」로 뜨는 것이 정상이고, - // 실제 우회는 B08 의 그 단추가 서버(`common_util_dev_unlock`)로 한다. - // ⚠ **서버는 그대로 막혀 있다** — 운영에서는 `ENVIRONMENT` 로 거절한다. - const devBypass = import.meta.env.DEV; - const isEnabled = - devBypass || !hasStages || !isBlockedState || stage?.stage_no === options?.currentStage; - button.classList.toggle("is-enabled", isEnabled); - button.disabled = !isEnabled; + const currents = ([] as number[]).concat(options?.currentStage ?? []); + let adminOnly = false; // B04 = 시스템 관리자만 + const paint = (stages: WorkflowStage[] | undefined): void => { + const stage = stages?.find((item) => item.stage_no === index) ?? stages?.[index]; + const hasStages = Boolean(stages?.length); + // 막힘 = 시작 전만 — STALE(앞 단계 바뀜)은 색만 「다시 확인」 · 막지 않음(요구 300 B1) + const isBlockedState = stage?.state === "NOT_STARTED"; + // ⚠ 개발 전용 — 막힌 단계도 눌러서 들어간다 (2026-09-09 사용자 지시). + // B08 의 「확정 없이 다음으로」 단추가 **B08 화면 안에** 있어서, B07 에서 B08 로 못 + // 넘어가면 그 단추에 닿을 길이 없었다(닫힌 문 안에 열쇠가 있는 꼴). + // ⚠ **잠금을 푸는 것이 아니라 가기만 한다** — 값이 없어 「미확보」로 뜨는 것이 정상이고, + // 실제 우회는 B08 의 그 단추가 서버(`common_util_dev_unlock`)로 한다. + // ⚠ **서버는 그대로 막혀 있다** — 운영에서는 `ENVIRONMENT` 로 거절한다. + const devBypass = import.meta.env.DEV; + const isEnabled = + !adminOnly && + (devBypass || !hasStages || !isBlockedState || currents.includes(stage?.stage_no ?? -1)); + button.classList.toggle("is-enabled", isEnabled); + button.disabled = !isEnabled; + button.title = ""; + + // state 기반 스타일링 (표시 전용) + button.className + .split(/\s+/) + .filter((name) => name.startsWith("state-")) + .forEach((name) => button.classList.remove(name)); + button.classList.remove("is-dev-bypass"); + if (stage) { + const state = stage.state; + button.classList.add(`state-${state.toLowerCase()}`); + if (state === "STALE") { + button.title = t("WF_State_Stale"); + } else if (state === "FAILED") { + button.title = t("WF_State_Failed"); + } else if (state === "COMPLETE") { + button.title = t("WF_State_Complete"); + } else if (state === "IN_PROGRESS") { + button.title = t("WF_State_InProgress"); + } else { + button.title = t("WF_State_NotStarted"); + } + } + + // 개발 우회로 열어 둔 단계는 **그렇다고 말해 준다** — 조용히 열면 다음 사람이 + // 「왜 값이 없나」로 헤맨다(B08 의 안내 줄과 같은 뜻). + if (devBypass && isBlockedState && !adminOnly) { + button.classList.add("is-dev-bypass"); + button.title = `${button.title} (개발: 확정 전이라 값이 비어 보일 수 있습니다)`; + } + if (adminOnly) button.title = "시스템 관리자 전용 단계입니다."; + }; + let painted = options?.stages; + paint(painted); + painters.push((stages) => paint((painted = stages))); if (options?.routes?.[index] === ROUTES.B04_PREPROCESS) { void getDashboardRole() .then((role) => { if (role !== "SYSTEM_ADMIN") { - button.classList.remove("is-enabled"); - button.disabled = true; - button.title = "시스템 관리자 전용 단계입니다."; + adminOnly = true; + paint(painted); } }) .catch(() => undefined); } - // state 기반 스타일링 (표시 전용) - if (stage) { - const state = stage.state; - button.classList.add(`state-${state.toLowerCase()}`); - if (state === "STALE") { - button.title = t("WF_State_Stale"); - } else if (state === "FAILED") { - button.title = t("WF_State_Failed"); - } else if (state === "COMPLETE") { - button.title = t("WF_State_Complete"); - } else if (state === "IN_PROGRESS") { - button.title = t("WF_State_InProgress"); - } else { - button.title = t("WF_State_NotStarted"); - } - } - - // 개발 우회로 열어 둔 단계는 **그렇다고 말해 준다** — 조용히 열면 다음 사람이 - // 「왜 값이 없나」로 헤맨다(B08 의 안내 줄과 같은 뜻). - if (devBypass && isBlockedState) { - button.classList.add("is-dev-bypass"); - button.title = `${button.title} (개발: 확정 전이라 값이 비어 보일 수 있습니다)`; - } - - if (isEnabled && options?.routes?.[index]) { + // 꺼진 단추는 누름이 안 감 — 켬 · 끔이 뒤에 바뀌어도 듣개 하나 + if (options?.routes?.[index]) { button.addEventListener("click", () => { options.onStepClick?.(index, options.routes![index]); }); @@ -225,5 +248,6 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa }, setActiveStep: (stepIndex) => steps.forEach((step, index) => step.classList.toggle("is-active", index === stepIndex)), + setStages: (stages) => (progressContent as StepBarElement).repaintSteps?.(stages), }; } From dcb7c68685e97b2e0e6d67921d59237179a0dc65 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 20 Oct 2026 19:54:51 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(496-2=20=EB=8B=A8=EA=B3=84=20=EC=83=81?= =?UTF-8?q?=ED=83=9C):=20[=EC=A0=80=EC=9E=A5]=20=C2=B7=20[>]=20=EA=B0=80?= =?UTF-8?q?=20=EB=B0=94=EA=BE=B8=EB=8A=94=20=EC=A7=84=ED=96=89=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=ED=95=9C=20=EA=B3=B3=20=E2=80=94=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20298-2=20=C2=B7=20300=20B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 mark_stage — save = 그 단계 진행 중 · 뒤 완료 · 진행 중 → 낡음 / next = 그 단계 완료 · 다음 미시작 · 낡음 → 진행 중 - B05 · B06 한 본문 [저장] = 단계 2 · 3 save(빈 본문 · [>] 확정 제외) · B06 [>] = 2 완료 · 3 next - B07 장 [저장] = 4 save · B07 [>] = 4 next - B08 [저장] 넷(옵션 · 자재 · 수문 · 이정) = 5 save · 새 POST quantity/complete = 5 next - B09 [저장] = 6 save · 새 POST estimation/complete = 6 next - 자동설계 체인(mark_stage_complete=False) 그대로 - 새 시험 test_298_2_stage_state · 이웃 시험 셋 기대값 맞춤 Co-Authored-By: Claude Opus 5.5 --- B06_Section/B06_Section_Router_Confirm.py | 4 +- B06_Section/B06_Section_Router_DesignSave.py | 29 +- B07_DesignDetail/B07_DesignDetail_Router.py | 9 +- B08_Quantity/B08_Quantity_Router_Hydro.py | 2 + B08_Quantity/B08_Quantity_Router_Material.py | 2 + B08_Quantity/B08_Quantity_Router_Milestone.py | 2 + B08_Quantity/B08_Quantity_Router_Options.py | 14 +- B09_Estimation/B09_Estimation_Router.py | 12 + common_util/common_util_workflow_state.py | 46 +++ resources/tester/test_280_5_b07_settings.py | 8 +- resources/tester/test_298_2_stage_state.py | 263 ++++++++++++++++++ resources/tester/test_472_6_design_save.py | 2 + .../tester/test_b07_confirm_keeps_design.py | 3 +- 13 files changed, 375 insertions(+), 21 deletions(-) create mode 100644 resources/tester/test_298_2_stage_state.py diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index 7f78d29ab..d9a376803 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -48,7 +48,7 @@ from B06_Section.B06_Section_Schema import ( ) from B06_Section.B06_Section_Server_Calc_Prebuild import _AREA_KEYS from common_util.common_util_storage import resolve_stored_project_path -from common_util.common_util_workflow_state import complete_stage, start_stage +from common_util.common_util_workflow_state import complete_stage, mark_stage, start_stage from config.config_db import get_db_pool logger = logging.getLogger(__name__) @@ -443,7 +443,7 @@ async def confirm_sections( # 경로 상태(DRAFT)가 남지 않도록 여기서 함께 CONFIRMED로 닫는다. await confirm_route_status(connection, route_id) await complete_stage(cursor, str(project_id), 2) - await complete_stage(cursor, str(project_id), 3) + await mark_stage(str(project_id), 3, "next", cursor) # 4 진행 중(B1) else: await start_stage(cursor, str(project_id), 3) await connection.commit() diff --git a/B06_Section/B06_Section_Router_DesignSave.py b/B06_Section/B06_Section_Router_DesignSave.py index e29b95a64..9b669d2f3 100644 --- a/B06_Section/B06_Section_Router_DesignSave.py +++ b/B06_Section/B06_Section_Router_DesignSave.py @@ -186,15 +186,32 @@ async def _run_steps( save = confirm_sections if request.confirm else save_sections crossing = save(project_id, request.route_id, request.cross) else: - return out - cross = await step("cross", crossing) - if cross.get("recomputed") is False: - raise StepFailed("cross", "서버 재계산 실패") - if cross.get("stations_synced") is False: - raise StepFailed("cross", "구조물 측점 맞춤 실패") + crossing = None + if crossing is not None: + cross = await step("cross", crossing) + if cross.get("recomputed") is False: + raise StepFailed("cross", "서버 재계산 실패") + if cross.get("stations_synced") is False: + raise StepFailed("cross", "구조물 측점 맞춤 실패") + # [>](확정) = confirm_sections 가 단계를 닫음 · 그 밖 [저장] = 2 · 3 진행 중 · 뒤 STALE(B1) + sent = any(value is not None for key, value in request if key not in ("route_id", "confirm")) + if sent and not (request.confirm and not moved and request.cross is not None): + try: + await _save_stages(str(project_id)) + except Exception as error: + logger.exception("한 본문 저장 단계 상태 실패: project_id=%s", project_id) + raise StepFailed("stages", str(error) or type(error).__name__) from None return out +async def _save_stages(project_id: str) -> None: + """B05 · B06 한 본문 [저장] — 단계 2 · 3(실패면 한 본문 되돌림이 단계 행도 되살림).""" + from common_util.common_util_workflow_state import mark_stage + + await mark_stage(project_id, 2, "save") + await mark_stage(project_id, 3, "save") + + @router.post("/{project_id}/design/save") async def save_design( project_id: UUID, diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 2f5060c34..cfb99a5fa 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -64,7 +64,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import ( from common_util.common_util_dev_unlock import is_dev_environment, unlock_status from common_util.common_util_drainage_context import load_drainage_context from common_util.common_util_storage import read_stored_asset, resolve_stored_project_path -from common_util.common_util_workflow_state import complete_stage, start_stage +from common_util.common_util_workflow_state import mark_stage, start_stage from config.config_db import get_db_pool logger = logging.getLogger(__name__) @@ -470,10 +470,7 @@ async def confirm_design_drawing( patch={"status": "confirmed"}, ) async with connection.cursor() as cursor: - if all_confirmed: - await complete_stage(cursor, str(project_id), 4) - else: - await start_stage(cursor, str(project_id), 4) + await mark_stage(str(project_id), 4, "save", cursor) # [저장](B1) await connection.commit() except Exception: await connection.rollback() @@ -572,7 +569,7 @@ async def complete_design_stage(project_id: UUID) -> Any: await connection.begin() try: async with connection.cursor() as cursor: - await complete_stage(cursor, str(project_id), _B07_STAGE_NO) + await mark_stage(str(project_id), _B07_STAGE_NO, "next", cursor) # [>](B1) await connection.commit() except Exception: await connection.rollback() diff --git a/B08_Quantity/B08_Quantity_Router_Hydro.py b/B08_Quantity/B08_Quantity_Router_Hydro.py index dbfb95982..1f35a8477 100644 --- a/B08_Quantity/B08_Quantity_Router_Hydro.py +++ b/B08_Quantity/B08_Quantity_Router_Hydro.py @@ -37,6 +37,7 @@ from B08_Quantity.B08_Quantity_Hydro_Site import ( ) from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_workflow_state import mark_stage from config.config_db import get_db_pool router = APIRouter(prefix="/api/projects", tags=["B08 Hydro"]) @@ -126,4 +127,5 @@ async def hydro_save(project_id: UUID, body: HydroSave) -> dict[str, Any]: raise HTTPException(status_code=400, detail=f"{key}: 할증은 {FACTORS} 중 하나") saved = {key: inputs.model_dump() for key, inputs in body.stations.items()} write_saved(root, saved) + await mark_stage(str(project_id), 5, "save") # [저장](B1) return {"saved": saved} diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index e0bb13ba7..aba4600f6 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -37,6 +37,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Summary import _combo, structure_summary from common_util.common_util_project_settings import quantity_settings, save_section from common_util.common_util_summary_item import GROUP_JOIN +from common_util.common_util_workflow_state import mark_stage router = APIRouter(prefix="/api/projects", tags=["B08 Material"]) @@ -251,4 +252,5 @@ async def save_material_sheets( root = await _project_root(project_id) overrides = clean_overrides(body.get("overrides")) save_section(root, "quantity", {SETTINGS_KEY: overrides}, replace_keys=(SETTINGS_KEY,)) + await mark_stage(str(project_id), 5, "save") # [저장](B1) return await _sheets(project_id, root, overrides) diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 1d6ec27d5..da687e23b 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -86,6 +86,7 @@ from common_util.common_util_route_geometry import ( planned_route_working_path, read_planned_route_csv, ) +from common_util.common_util_workflow_state import mark_stage router = APIRouter(prefix="/api/projects", tags=["B08 Milestone"]) PATH = "/{project_id}/quantity/milestone" @@ -421,6 +422,7 @@ async def milestone_save( ) -> dict[str, Any]: root = await _project_root(project_id) out = await asyncio.to_thread(save_data, root, body) + await mark_stage(str(project_id), 5, "save") # [저장](B1) if not os.environ.get( "PYTEST_CURRENT_TEST" ): # 남의 시험 [저장] = 실제 카카오 · 브라우저 안 부름 diff --git a/B08_Quantity/B08_Quantity_Router_Options.py b/B08_Quantity/B08_Quantity_Router_Options.py index ff4aa21b3..de4c9c179 100644 --- a/B08_Quantity/B08_Quantity_Router_Options.py +++ b/B08_Quantity/B08_Quantity_Router_Options.py @@ -2,6 +2,7 @@ GET /api/projects/{id}/quantity/options 지금 값 PUT /api/projects/{id}/quantity/options [저장] — 받은 칸만 정본 자리에 씀 + POST /api/projects/{id}/quantity/complete [>] — 단계 5 완료 · 6 진행 중(요구 300 B1) 1본 길이 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` 한 벌(98 `one_pipe_length` 이 읽는 자리 · M02 표 식 `[$파형강관_1본_m]` 도 같은 값) · 프로젝트 사본이 없으면 시스템 층 문서를 바탕으로 깔고 씀 · @@ -26,6 +27,7 @@ from B08_Quantity.B08_Quantity_Material_Calc import PANEL_KEY, panel_surcharge from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Summary import ONE_LENGTH_VAR, TABLE_NAME, one_pipe_length from common_util.common_util_project_settings import quantity_settings, save_section, soft_rock_pct +from common_util.common_util_workflow_state import mark_stage from M02_MasterTemplete import M02_Template_Layers as layers router = APIRouter(prefix="/api/projects", tags=["B08 Options"]) @@ -96,8 +98,18 @@ async def options_read(project_id: UUID) -> dict[str, Any]: async def options_save(project_id: UUID, body: OptionsSave) -> dict[str, Any]: root = await _project_root(project_id) try: - return await asyncio.to_thread(save_options, root, body) + out = await asyncio.to_thread(save_options, root, body) except layers.StaleTemplate as error: raise HTTPException( status_code=409, detail="구조물집계표가 그새 바뀌었습니다 — 다시 열고 저장하세요." ) from error + await mark_stage(str(project_id), 5, "save") # [저장](B1) + return out + + +@router.post("/{project_id}/quantity/complete") +async def quantity_complete(project_id: UUID) -> dict[str, Any]: + """B08 [>] — 단계 5 완료 · 6 진행 중(요구 300 B1 · 본문 없음).""" + await _project_root(project_id) # 없는 프로젝트는 404 + await mark_stage(str(project_id), 5, "next") + return {"status": "success", "project_id": str(project_id)} diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 0da23dee3..a3e8ef66a 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -4,6 +4,8 @@ 입력(굳힌 단가 · 수요 · B08 수량) + 사용자 값 → 표 전부 PUT /api/projects/{id}/estimation {user} [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈 + POST /api/projects/{id}/estimation/complete + [>] — 단계 6 완료 · 프로젝트 DONE(요구 300 B1 · 본문 없음) POST /api/projects/{id}/estimation/prices {user?} [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · 지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 @@ -61,6 +63,7 @@ from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials from common_util.common_util_auth import verify_session +from common_util.common_util_workflow_state import mark_stage from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete.M02_Item_Snapshot import importer @@ -216,9 +219,18 @@ async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> book = await asyncio.to_thread(store.read_book, root) book = await asyncio.to_thread(prices.extend_book, book, keys) await asyncio.to_thread(store.write_book, root, book) + await mark_stage(str(project_id), 6, "save") # [저장](B1) return await payload(project_id, root) +@router.post("/{project_id}/estimation/complete") +async def estimation_complete(project_id: UUID) -> dict[str, Any]: + """B09 [>] — 단계 6 완료(요구 300 B1).""" + await _project_root(project_id) # 없는 프로젝트는 404 + await mark_stage(str(project_id), 6, "next") + return {"status": "success", "project_id": str(project_id)} + + @router.post("/{project_id}/estimation/prices") async def refresh_prices( project_id: UUID, body: dict[str, Any] | None = Body(None) diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index c5c85c27b..412d06e3e 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -148,6 +148,52 @@ async def complete_stage(cursor: aiomysql.DictCursor, project_id: str, stage_no: ) +async def mark_stage( + project_id: str, + stage_no: int, + action: str, + cursor: aiomysql.DictCursor | None = None, +) -> None: + """사용자 단추가 바꾸는 단계 상태 한 곳(요구 300 B1) — 커서 없으면 제 트랜잭션. + + `save`([저장] 성공) = 그 단계 IN_PROGRESS · 뒤 단계 중 COMPLETE · IN_PROGRESS → STALE. + `next`([>] 성공) = 그 단계 COMPLETE · 다음 단계 NOT_STARTED · STALE → IN_PROGRESS. + [<] = 부르지 않음(상태 안 바꿈). + """ + if action not in ("save", "next"): + raise ValueError(f"단계 동작은 save · next 중 하나: {action}") + if cursor is None: + from config import config_db + + if config_db.db_pool is None: # DB 없이 파일 창구만 부르는 시험 = 단계 표 없음 + return + async with config_db.get_db_pool().acquire() as connection: + await connection.begin() + try: + async with connection.cursor() as own: + await mark_stage(project_id, stage_no, action, own) + await connection.commit() + except Exception: + await connection.rollback() + raise + return + if action == "save": + await start_stage(cursor, project_id, stage_no) + await cursor.execute( + "UPDATE project_workflow_stages SET state = 'STALE' " + "WHERE project_id = %s AND stage_no > %s AND state = 'IN_PROGRESS'", + (project_id, stage_no), + ) + return + await complete_stage(cursor, project_id, stage_no) + await cursor.execute( + "UPDATE project_workflow_stages SET state = 'IN_PROGRESS', progress_percent = 0, " + "message = NULL, started_at = %s, completed_at = NULL " + "WHERE project_id = %s AND stage_no = %s AND state IN ('NOT_STARTED', 'STALE')", + (datetime.utcnow(), project_id, stage_no + 1), + ) + + async def update_stage_progress( cursor: aiomysql.DictCursor, project_id: str, stage_no: int, progress: int ) -> None: diff --git a/resources/tester/test_280_5_b07_settings.py b/resources/tester/test_280_5_b07_settings.py index 69ba47d16..e12fced54 100644 --- a/resources/tester/test_280_5_b07_settings.py +++ b/resources/tester/test_280_5_b07_settings.py @@ -199,18 +199,18 @@ def test_서버_단계_완료(monkeypatch, tmp_path: Path) -> None: 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)) + async def complete(project_id: str, stage_no: int, action: str, _cursor: object) -> None: + stages.append((project_id, stage_no, action)) monkeypatch.setattr(router, "_confirmed_source", source) - monkeypatch.setattr(router, "complete_stage", complete) + monkeypatch.setattr(router, "mark_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)] + assert stages == [(str(project), 4, "next")] # 298-2 [>] = 4 완료 · 5 진행 중 async def blocked(_project_id: UUID) -> tuple[int, Path, Path, bool]: raise PermissionError("B06 종·횡단 확정 후 상세 설계를 진행할 수 있습니다.") diff --git a/resources/tester/test_298_2_stage_state.py b/resources/tester/test_298_2_stage_state.py new file mode 100644 index 000000000..309acd0e3 --- /dev/null +++ b/resources/tester/test_298_2_stage_state.py @@ -0,0 +1,263 @@ +"""요구 298-2 · 300 B1 — 단추가 바꾸는 진행 단계 상태(PLAN 496-2). + +[저장] = 그 단계 IN_PROGRESS · 뒤 COMPLETE · IN_PROGRESS → STALE · [>] = 그 단계 COMPLETE · +다음 NOT_STARTED · STALE → IN_PROGRESS · [<] = 안 바꿈. 단계 표는 메모리 흉내 커서. +""" + +from __future__ import annotations + +import asyncio +import re +from contextlib import asynccontextmanager +from pathlib import Path +from uuid import uuid4 + +import pytest + +from common_util import common_util_workflow_state as wf + +N, P, C, S = "NOT_STARTED", "IN_PROGRESS", "COMPLETE", "STALE" + + +class _Table: + """`project_workflow_stages` 칸 0~6 의 state 만 — 단계 SQL 을 글로 풀어 셈.""" + + def __init__(self, states: list[str]): + self.states = list(states) + + def run(self, sql: str, params: tuple) -> None: + if "project_workflow_stages" not in sql or not sql.lstrip().startswith("UPDATE"): + return + to = re.search(r"SET state = '(\w+)'", sql).group(1) + no = int(params[-1]) + only = re.search(r"AND state = '(\w+)'|AND state IN \(([^)]*)\)", sql) + allowed = None + if only: + allowed = re.findall(r"\w+", only.group(1) or only.group(2)) + for i, state in enumerate(self.states): + hit = i > no if "stage_no >" in sql else i == no + if hit and (allowed is None or state in allowed): + self.states[i] = to + + +class _Cursor: + def __init__(self, table: _Table): + self.table = table + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def execute(self, sql, params=()): + self.table.run(sql, params) + + +class _Connection: + def __init__(self, table): + self.table = table + self.commits = 0 + + def cursor(self, *_a, **_k): + return _Cursor(self.table) + + async def begin(self): + pass + + async def commit(self): + self.commits += 1 + + async def rollback(self): + pass + + +class _Pool: + def __init__(self, table): + self.table = table + + @asynccontextmanager + async def acquire(self): + yield _Connection(self.table) + + +def _mark(states, stage_no, action): + table = _Table(states) + asyncio.run(wf.mark_stage("p", stage_no, action, _Cursor(table))) + return table.states + + +@pytest.mark.parametrize( + ("before", "stage_no", "after"), + [ + # B06 [>] — 3 닫고 B07 진행 중 + ([C, C, C, P, N, N, N], 3, [C, C, C, C, P, N, N]), + # 뒤 단계가 낡음이면 진행 중으로 + ([C, C, C, P, S, S, N], 3, [C, C, C, C, P, S, N]), + # 다음이 이미 끝났으면 그대로 + ([C, C, C, P, C, C, N], 3, [C, C, C, C, C, C, N]), + # B09 [>] — 마지막 칸 · 다음 없음 + ([C, C, C, C, C, C, P], 6, [C, C, C, C, C, C, C]), + ], +) +def test_next(before, stage_no, after): + assert _mark(before, stage_no, "next") == after + + +@pytest.mark.parametrize( + ("before", "stage_no", "after"), + [ + # B07 [저장] — B08 끝남 · B09 진행 중 → 둘 다 낡음 + ([C, C, C, C, C, C, P], 4, [C, C, C, C, P, S, S]), + # 처음 저장 — 뒤 NOT_STARTED 는 그대로 + ([C, C, C, C, N, N, N], 4, [C, C, C, C, P, N, N]), + # 끝난 단계 다시 저장 = 진행 중 + ([C, C, C, C, C, C, C], 6, [C, C, C, C, C, C, P]), + ], +) +def test_save(before, stage_no, after): + assert _mark(before, stage_no, "save") == after + + +def test_bad_action(): + with pytest.raises(ValueError): + _mark([N] * 7, 2, "prev") + + +def test_own_transaction(monkeypatch): + from config import config_db + + table = _Table([C, C, C, C, C, N, N]) + monkeypatch.setattr(config_db, "db_pool", None) + asyncio.run(wf.mark_stage("p", 5, "next")) # 풀 없음(파일 창구 시험) = 건너뜀 + assert table.states == [C, C, C, C, C, N, N] + monkeypatch.setattr(config_db, "db_pool", _Pool(table)) + asyncio.run(wf.mark_stage("p", 5, "next")) + assert table.states == [C, C, C, C, C, C, P] + + +def _design_save(monkeypatch, table, **body): + from B04_PreProcess import B04_PreProcess_Router_Basins as basins + from B06_Section import B06_Section_Router_Confirm as confirm + from B06_Section import B06_Section_Router_DesignSave as design + from config import config_db + + async def done(*_a, **_k): + return {} + + async def confirmed(project_id, *_a, **_k): # confirm_sections 의 단계 몫만 + await wf.complete_stage(_Cursor(table), str(project_id), 2) + await wf.mark_stage(str(project_id), 3, "next", _Cursor(table)) + return {} + + monkeypatch.setattr(basins, "put_pipe_points", done) + monkeypatch.setattr(confirm, "save_sections", done) + monkeypatch.setattr(confirm, "confirm_sections", confirmed) + monkeypatch.setattr(config_db, "db_pool", _Pool(table)) + request = design.DesignSaveRequest(route_id=1, **body) + return asyncio.run(design._run_steps(uuid4(), request, None)) + + +def test_b05_b06_save_any_step(monkeypatch): + """관만 고친 [저장]도 2 · 3 진행 중 · B07 · B08 · B09 낡음.""" + table = _Table([C, C, C, C, C, P, N]) + _design_save(monkeypatch, table, pipes={}) + assert table.states == [C, C, P, P, S, S, N] + + +def test_b05_b06_save_empty(monkeypatch): + """빈 본문 = 저장한 것 없음 · 단계 그대로.""" + table = _Table([C, C, C, C, C, P, N]) + assert _design_save(monkeypatch, table) == {} + assert table.states == [C, C, C, C, C, P, N] + + +def test_b05_b06_save_cross(monkeypatch): + table = _Table([C, C, C, C, N, N, N]) + _design_save(monkeypatch, table, cross={}) + assert table.states == [C, C, P, P, N, N, N] + + +def test_b06_next_confirm(monkeypatch): + """[>](confirm) = 2 · 3 COMPLETE · 4 진행 중 · [저장] 몫 안 탐.""" + table = _Table([C, C, P, P, S, N, N]) + _design_save(monkeypatch, table, confirm=True, cross={}) + assert table.states == [C, C, C, C, P, N, N] + + +def test_b07_next(monkeypatch): + from B07_DesignDetail import B07_DesignDetail_Router as b07 + + async def source(_pid): + return 1, Path("."), "l", False + + table = _Table([C, C, C, C, P, S, N]) + monkeypatch.setattr(b07, "_confirmed_source", source) + monkeypatch.setattr(b07, "get_db_pool", lambda: _Pool(table)) + assert asyncio.run(b07.complete_design_stage(uuid4()))["status"] == "success" + assert table.states == [C, C, C, C, C, P, N] + + +def test_b07_save_calls_save(): + """B07 장 [저장](confirm PUT) = 단계 4 save 한 줄 · 옛 all_confirmed 갈래 없음.""" + text = (Path(__file__).parents[2] / "B07_DesignDetail/B07_DesignDetail_Router.py").read_text( + encoding="utf-8" + ) + assert 'mark_stage(str(project_id), 4, "save", cursor)' in text + assert "complete_stage(" not in text + + +@pytest.mark.parametrize( + ("module", "name", "stage_no"), + [ + ("B08_Quantity.B08_Quantity_Router_Options", "quantity_complete", 5), + ("B09_Estimation.B09_Estimation_Router", "estimation_complete", 6), + ], +) +def test_b08_b09_next(monkeypatch, module, name, stage_no): + import importlib + + mod = importlib.import_module(module) + calls = [] + + async def root(_pid): + return Path(".") + + async def mark(project_id, no, action, cursor=None): + calls.append((no, action)) + + monkeypatch.setattr(mod, "_project_root", root) + monkeypatch.setattr(mod, "mark_stage", mark) + assert asyncio.run(getattr(mod, name)(uuid4()))["status"] == "success" + assert calls == [(stage_no, "next")] + + +def test_b08_hydro_save(monkeypatch): + from B08_Quantity import B08_Quantity_Router_Hydro as hydro + + calls = [] + + async def root(_pid): + return Path(".") + + async def mark(project_id, no, action, cursor=None): + calls.append((no, action)) + + monkeypatch.setattr(hydro, "_project_root", root) + monkeypatch.setattr(hydro, "write_saved", lambda *_a: None) + monkeypatch.setattr(hydro, "mark_stage", mark) + asyncio.run(hydro.hydro_save(uuid4(), hydro.HydroSave(stations={}))) + assert calls == [(5, "save")] + + +def test_save_routers_mark_stage(): + """B08 [저장] 넷 · B09 [저장] = 그 단계 save.""" + root = Path(__file__).parents[2] + for rel, stage_no in [ + ("B08_Quantity/B08_Quantity_Router_Options.py", 5), + ("B08_Quantity/B08_Quantity_Router_Material.py", 5), + ("B08_Quantity/B08_Quantity_Router_Milestone.py", 5), + ("B09_Estimation/B09_Estimation_Router.py", 6), + ]: + text = (root / rel).read_text(encoding="utf-8") + assert f'mark_stage(str(project_id), {stage_no}, "save")' in text, rel diff --git a/resources/tester/test_472_6_design_save.py b/resources/tester/test_472_6_design_save.py index 63c3634d8..63355dce9 100644 --- a/resources/tester/test_472_6_design_save.py +++ b/resources/tester/test_472_6_design_save.py @@ -212,6 +212,7 @@ def test_중간_차례_실패면_DB_storage_그대로(monkeypatch) -> None: def test_정상_한_본문은_옛_차례와_같음() -> None: from B05_Profile.B05_Profile_Router_Lifecycle import save_page_settings from B06_Section.B06_Section_Router_Confirm import save_sections + from B06_Section.B06_Section_Router_DesignSave import _save_stages from B06_Section.B06_Section_Router_Stations import put_plus_stations from config.config_db import close_db_pool, get_db_pool, init_db_pool @@ -227,6 +228,7 @@ def test_정상_한_본문은_옛_차례와_같음() -> None: await save_page_settings(UUID(PROJECT), request.settings) await put_plus_stations(UUID(PROJECT), request.plus_stations) await save_sections(UUID(PROJECT), request.route_id, request.cross) + await _save_stages(PROJECT) # 298-2 [저장] = 단계 2 · 3 진행 중(B1) return rows, files finally: await close_db_pool() diff --git a/resources/tester/test_b07_confirm_keeps_design.py b/resources/tester/test_b07_confirm_keeps_design.py index f33273808..91922f7f8 100644 --- a/resources/tester/test_b07_confirm_keeps_design.py +++ b/resources/tester/test_b07_confirm_keeps_design.py @@ -80,8 +80,7 @@ def test_확정은_상태만_올리고_설계값을_안_덮는다(monkeypatch, t monkeypatch.setattr(router, "_store_confirmed_drawing", lambda *_: False) monkeypatch.setattr(router, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire)) monkeypatch.setattr(router, "merge_cross_section_design_by_round", merge) - monkeypatch.setattr(router, "start_stage", stage) - monkeypatch.setattr(router, "complete_stage", stage) + monkeypatch.setattr(router, "mark_stage", stage) # 옛 길 — 입력 셋으로 다시 계산한 값(암 절토경사가 빠져 절토가 줄어든 모양)을 흉내 낸다. monkeypatch.setattr( router,