From 676b46eb9e1135cd12d7b21fa1c64ec08f4e2429 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 28 Oct 2026 18:07:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(B=20=ED=8E=98=EC=9D=B4=EC=A7=80):=20?= =?UTF-8?q?=EB=96=A0=EB=82=A8=20=EB=AC=BC=EC=9D=8C=20=EA=B0=80=EB=93=9C=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=EA=B8=88=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=3D=20=ED=83=AD=EB=A7=88=EB=8B=A4=20(PLAN=20572-2?= =?UTF-8?q?=20=C2=B7=20=EC=9A=94=EA=B5=AC=20400~403)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B04 · B07 · B08 · B09 가 공용 guardProjectLeave 에 {dirty, save, discard} 등록 — 고침 있을 때만 [취소 · 폐기 · 저장] - B04 = 관 지점 고침(drainageDirty) · 저장 = 관 지점 · 세부유역 영구저장 - B07 = CAD · 탭 고침 · 저장 = saveEditedDrawings · 폐기 = 고친 도면 CAD 자동백업도 버림(discardEditedRecovery) - B08 = 세션 b08-* 칸 넷 · 저장 = saveShown · B09 = b09-estimation 칸 · 저장 = saveChanged - 지금 프로젝트 직접 읽기(localStorage) → currentProjectId() · B02 새 프로젝트 = setCurrentProject — B02 · B03 · B04 · B07 · B08 · B09 · B11 - 다른 프로젝트 보관함 지우기 셋(B04 · B05 종단 · B11) 뺌 — enterProject 한 곳 · 다른 탭 보관분 안 지움 - B02 · B03 은 가드 없음 — 저장 안 한 프로젝트 값 없음(B03 올리기 끊김은 이어 올리기 배너가 받음) - 시험 test_560_4 넓힘(B 가드 · 탭 프로젝트 · 보관함) · helper_298_2 · helper_481_2 · test_204_2 흉내를 currentProjectId 로 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_view_profile.ts | 3 - B02_ProjRegister/B02_ProjRegister_UI_Page.ts | 5 +- B03_FileInput/B03_FileInput_UI_Page.ts | 10 +-- B03_FileInput/B03_FileInput_UI_Page_Flow.ts | 9 +-- B03_FileInput/B03_FileInput_UI_PlanImport.ts | 5 +- B04_PreProcess/B04_PreProcess_UI_Basins.ts | 2 + B04_PreProcess/B04_PreProcess_UI_MapViewer.ts | 3 + B04_PreProcess/B04_PreProcess_UI_Page.ts | 18 +++--- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 12 +++- B07_DesignDetail/B07_DesignDetail_UI_Save.ts | 13 ++++ B08_Quantity/B08_Quantity_UI_Page.ts | 13 +++- .../B09_Estimation_UI_Haul_Modal.ts | 5 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 12 +++- .../B09_Estimation_UI_Tabs_Base_Quote.ts | 5 +- .../B09_Estimation_UI_Tabs_Bill_Link.ts | 5 +- B09_Estimation/draft/B09_Draft_Print_Page.ts | 5 +- B11_Status/B11_Status_UI_Loading.ts | 8 +-- .../tester/helper_298_2_b08_b09_buttons.cjs | 2 + resources/tester/helper_481_2_save_move.cjs | 2 + .../tester/test_204_2_b09_bill_library.py | 3 +- resources/tester/test_560_4_leave_save.py | 61 ++++++++++++++++++- 21 files changed, 154 insertions(+), 47 deletions(-) diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 58a2342b9..5c04a3e3f 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -11,7 +11,6 @@ * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; -import { purgeOtherProjects } from "../b_asset_cache"; import { createProgressCircle } from "@ui/ui_template_progress"; import { fetchConfirmedSurface } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch"; @@ -96,8 +95,6 @@ export async function mountProfileView( ): Promise { const activeProjectId = shared.projectId; if (!activeProjectId) return; - // 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다. - void purgeOtherProjects(activeProjectId); const viewer = createRouteViewer(); // 구 우클릭 메뉴의 자유 텍스트 구조물 → 레지스트리 타입(2026-08-17 이관 매핑과 동일). diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index eb8496792..bff10db9f 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -25,8 +25,9 @@ import { isBlank } from "@util/common_util_validate"; import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name"; import { navigateTo } from "../A00_Common/router"; -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import "./B02_ProjRegister_UI_Style.css"; +import { setCurrentProject } from "../A00_Common/b_project_switch"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { @@ -267,7 +268,7 @@ export function renderB02ProjRegister(root: HTMLElement): void { } const data = (await response.json()) as { project_id: string }; - localStorage.setItem(CURRENT_PROJECT_ID_KEY, data.project_id); + setCurrentProject(data.project_id); showToast(L("B02_Proj_Success"), "success"); navigateTo(ROUTES.B03_FILE_INPUT); } catch (error) { diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 07a26c07d..838cd1958 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -1,9 +1,4 @@ -import { - CURRENT_PROJECT_ID_KEY, - ROUTES, - UPLOAD_ALLOWED_EXT, - UPLOAD_MAX_FILES, -} from "@config/config_frontend"; +import { ROUTES, UPLOAD_ALLOWED_EXT, UPLOAD_MAX_FILES } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createBanner } from "@ui/ui_template_banner"; import { createDrop } from "@ui/ui_template_drop"; @@ -56,6 +51,7 @@ import { } from "./B03_FileInput_UI_Support"; import "./B03_FileInput_UI_Style.css"; import "./B03_FileInput_UI_Style_Card.css"; +import { currentProjectId } from "../A00_Common/b_project_switch"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -72,7 +68,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const progressMap = new Map(); let uploadButton: HTMLButtonElement; let resumeBanner: HTMLDivElement; - let activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + let activeProjectId = currentProjectId(); // 업로드가 도는 동안은 화면 전체를 잠근다 — 같은 파일을 두 번 올리거나 도중에 다른 // 단계로 넘어가는 것을 막는다(2026-08-08 사용자 지시). let pageRoot: HTMLElement | null = null; diff --git a/B03_FileInput/B03_FileInput_UI_Page_Flow.ts b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts index d00b1902a..4face5f84 100644 --- a/B03_FileInput/B03_FileInput_UI_Page_Flow.ts +++ b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts @@ -7,7 +7,7 @@ * 화면이 들고 있는 상태는 `ctx` 로 받아 쓰기만 한다 — 동작·순서는 종전과 같다. * ========================================================================== */ -import { CURRENT_PROJECT_ID_KEY, type RoutePath } from "@config/config_frontend"; +import { type RoutePath } from "@config/config_frontend"; import { createBanner } from "@ui/ui_template_banner"; import { createButton, showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; @@ -29,6 +29,7 @@ import { type FileSlotState, type StoredUploadSession, } from "./B03_FileInput_UI_Support"; +import { currentProjectId } from "../A00_Common/b_project_switch"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -70,7 +71,7 @@ function clearDerivedCaches(projectId: string): void { export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle { async function detectPausedUploads(): Promise { - ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""); + ctx.setProjectId(currentProjectId()); ctx.resumeBanner.replaceChildren(); ctx.resumeBanner.classList.remove("is-visible"); if (!ctx.projectId()) return; @@ -128,7 +129,7 @@ export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle { async function attachSelectedTempBatch(): Promise { const batch = ctx.tempPicker.selected(); if (!batch) return; - ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""); + ctx.setProjectId(currentProjectId()); if (!ctx.projectId()) { ctx.pageError.textContent = L("B03_File_Error_Project"); return; @@ -193,7 +194,7 @@ export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle { } return; } - ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""); + ctx.setProjectId(currentProjectId()); const validation = validateSlots( ctx.slots, ctx.selectedStates(), diff --git a/B03_FileInput/B03_FileInput_UI_PlanImport.ts b/B03_FileInput/B03_FileInput_UI_PlanImport.ts index ae6793d7c..b4ec4e0e1 100644 --- a/B03_FileInput/B03_FileInput_UI_PlanImport.ts +++ b/B03_FileInput/B03_FileInput_UI_PlanImport.ts @@ -7,7 +7,7 @@ * 옛 결과가 지워지면 서버가 409 confirm 으로 묻고 → 확인 창 → confirm_replace 로 다시. * ========================================================================== */ -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { API_BASE_URL } from "@config/config_frontend"; import { createBanner } from "@ui/ui_template_banner"; import { createButton, @@ -20,6 +20,7 @@ import { import { t } from "@ui/ui_template_locale"; import { createListRow } from "@ui/ui_template_list_row"; import { openModal } from "@ui/ui_template_modal"; +import { currentProjectId } from "../A00_Common/b_project_switch"; interface AssignedRoute { assignee_id: number; @@ -100,7 +101,7 @@ export function mountPlanImport( bannerHost: HTMLElement, onImported: () => void, ): void { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + const projectId = currentProjectId(); if (!projectId) return; const lasFree = (): boolean => localStorage.getItem(`b03_las_free_${projectId}`) === "1"; const banner = el("div", { className: "b03-file__plan-update" }); diff --git a/B04_PreProcess/B04_PreProcess_UI_Basins.ts b/B04_PreProcess/B04_PreProcess_UI_Basins.ts index 96f61cc8b..b31e6d879 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Basins.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Basins.ts @@ -150,6 +150,7 @@ export interface DetailBasinOverlay { handleClick: (view: ViewState, x: number, y: number) => boolean; /** 모델 확정 시점에 관 지점과 세부유역을 영구저장한다. */ commit: () => Promise; + isDirty: () => boolean; draw: (context: CanvasRenderingContext2D, normalizer: Normalizer | null, view: ViewState) => void; dispose: () => void; } @@ -474,6 +475,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl ]); return true; }, + isDirty: () => dirty, async commit() { if (!projectId) return 0; const response = await saveDetailPipePoints( diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts index 91978e232..b5321c224 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts @@ -54,6 +54,8 @@ export interface SurfaceMapViewer { render: (projectId: string, routeBounds?: PlanBounds | null) => void; /** 관 매설 지점·세부유역을 영구저장한다. 저장한 관 개수를 돌려준다(모델 확정과 함께 호출). */ commitDrainage: () => Promise; + /** 관 지점을 고치고 아직 저장 안 했나(떠날 때 물음 · 572-2). */ + drainageDirty: () => boolean; dispose: () => void; } @@ -675,6 +677,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { }, /** 모델 확정과 함께 관 매설 지점·세부유역을 영구저장한다. */ commitDrainage: () => detailBasins.commit(), + drainageDirty: () => detailBasins.isDirty(), dispose() { loadSequence += 1; if (frameHandle) { diff --git a/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index ccb57834c..360e82fc2 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Page.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Page.ts @@ -1,4 +1,4 @@ -import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { ROUTES } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, @@ -15,7 +15,7 @@ import { createToggle } from "@ui/ui_template_toggle"; import { createToolbar } from "@ui/ui_template_toolbar"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; -import { clearPreloadMark, purgeOtherProjects } from "../A00_Common/b_asset_cache"; +import { clearPreloadMark } from "../A00_Common/b_asset_cache"; import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { @@ -47,6 +47,7 @@ import { createSurfacePointCloudViewer } from "./B04_PreProcess_UI_Viewer"; import "./B04_PreProcess_UI_Style.css"; // 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). import "./B04_PreProcess_UI_Style_Map.css"; +import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_switch"; // 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가 // 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정). @@ -93,9 +94,7 @@ function getModelFilter(model: SurfaceModelSummary): string { } export async function renderB04Surface(root: HTMLElement): Promise { - const guardedProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - // 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다. - if (guardedProjectId) void purgeOtherProjects(guardedProjectId); + const guardedProjectId = currentProjectId(); if (guardedProjectId) { const user = await fetchDashboardMe(); if (user.role !== "SYSTEM_ADMIN") { @@ -293,7 +292,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { workspace.append(statusBox, viewers, sheetSection, mapViewer.root); let workflowState: WorkflowState | undefined; - const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const layoutProjectId = currentProjectId(); if (layoutProjectId) { try { workflowState = await fetchWorkflowState(layoutProjectId); @@ -317,7 +316,7 @@ export async function renderB04Surface(root: HTMLElement): Promise { }); function getProjectId(): string | null { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); if (!projectId) showToast(L("B04_Surface_Error_Project"), "error"); return projectId; } @@ -670,6 +669,11 @@ export async function renderB04Surface(root: HTMLElement): Promise { }); root.replaceChildren(layout.root); + // 572-2 떠남 물음 — 고침 = 관 지점 · 저장 = 관 지점 · 세부유역 영구저장(모델 [확정]과 같은 길) + guardProjectLeave(layout.root, { + dirty: mapViewer.drainageDirty, + save: mapViewer.commitDrainage, + }); const projectId = getProjectId(); if (projectId) void onB04_Surface_Reset_Click(); } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index c87fcb543..45a0dde6d 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -20,7 +20,6 @@ import { } from "@ui/ui_template_elements"; import { askConfirm } from "@ui/ui_template_modal"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; -import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; // 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04). import { buildDesignInfoPanel, @@ -70,12 +69,14 @@ import { resetDrawings, saveDrawingsNow, saveEditedDrawings, + discardEditedRecovery, } from "./B07_DesignDetail_UI_Save"; import { openDrawingSettings } from "./B07_DesignDetail_UI_Settings"; // CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3). import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host"; import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit"; import { createCadTabs, type CadTab } from "@ui/cad_host/cad_host_tabs"; +import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_switch"; /** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */ interface DesignMeta { @@ -106,7 +107,7 @@ let reopenDrawingId: string | undefined; let reopenCrossSheet: number | undefined; export async function renderB07DesignDetail(root: HTMLElement): Promise { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); // 311 다시 세우면 옛 목록 접힘 그대로 — 기다리기 전에 읽음(처음 들어오면 B07 목록이 없어 기본값) const folded = foldedGroups(root); let workflowState: WorkflowState | undefined; @@ -516,6 +517,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { }); layout.root.classList.add("b07-design-layout"); root.replaceChildren(layout.root); + // 572-2 떠남 물음 — 고침 = CAD · 탭 · 저장 = 단계 이동과 같은 길 · 폐기 = 고친 도면 CAD 자동백업도 + if (projectId) + guardProjectLeave(layout.root, { + dirty: () => cadDirty || tabs.anyDirty(), + save: () => saveEditedDrawings(store), + discard: () => discardEditedRecovery(store), + }); // 페이지 진입 시 첫 도면(표지 · 311)을 자동 선택 — 빈 CAD 화면 방지. // CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다. diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Save.ts b/B07_DesignDetail/B07_DesignDetail_UI_Save.ts index b33365c5c..11d046eb7 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Save.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Save.ts @@ -222,3 +222,16 @@ export async function resetDrawings(store: DrawingStore): Promise { hideLoadingOverlay(); } } + +/** 떠남 [폐기](572-2) — 고친 도면(지금 CAD · 고친 탭)의 CAD 자동백업만 버림 · 다음에 들어와도 안 살아남. */ +export function discardEditedRecovery(store: DrawingStore): void { + const ids = store.tabs + .tabs() + .filter((tab) => tab.dirty) + .map((tab) => tab.id); + const current = store.current(); + if (current && store.cadDirty()) ids.push(current.id); + ids.forEach((id) => + localStorage.removeItem(`${CAD_RECOVERY_KEY}__${recoveryScopeOf(store.projectId, id)}`), + ); +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 9a011718b..1de252c52 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -16,7 +16,6 @@ import "./B08_Quantity_UI_Page.css"; import { createText } from "@ui/ui_template_text"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fillTabs } from "../A00_Common/b_page_tabs"; import { moveStage, WORKFLOW_STEP_ROUTES, type StageStep } from "../A00_Common/b_workflow_nav"; @@ -26,6 +25,8 @@ import { renderHydro, request } from "./B08_Quantity_UI_Hydro"; import { renderEarthwork } from "./B08_Quantity_UI_Earthwork"; import { createOptionsPanel } from "./B08_Quantity_UI_Options"; import { renderMilestone } from "./B08_Quantity_UI_Milestone"; +import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_switch"; +import { clearState, readState } from "../A00_Common/b_page_state"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -112,7 +113,7 @@ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null, onSa } export function renderB08Quantity(root: HTMLElement): void { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); const tabBar = document.createElement("div"); tabBar.className = "b08-quantity__tabs"; @@ -170,4 +171,12 @@ export function renderB08Quantity(root: HTMLElement): void { }, }); root.append(layout.root); + // 572-2 떠남 물음 — 고침 = 세션 b08-* 칸 · 저장 = 단계 이동과 같은 길 + const drafts = ["b08-options", "b08-materials", "b08-hydro", "b08-milestone"] as const; + if (projectId) + guardProjectLeave(layout.root, { + dirty: () => drafts.some((name) => readState(name, projectId) !== null), + save: saveShown, + discard: () => drafts.forEach((name) => clearState(name, projectId)), + }); } diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts index c1ca20c3e..cb83320de 100644 --- a/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts +++ b/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts @@ -15,7 +15,7 @@ * 연결 없음 = 단가연결 목록 → 옵션 값 창 옛 길). * ========================================================================== */ -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { API_BASE_URL } from "@config/config_frontend"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { @@ -45,6 +45,7 @@ import { noteBox, type StepBlock, } from "./B09_Estimation_UI_Haul_Steps"; +import { currentProjectId } from "../A00_Common/b_project_switch"; export interface HaulModalOpts extends PriceLinkOpts { /** 지금 연결(없으면 null) */ @@ -70,7 +71,7 @@ interface Preview { } async function preview(code: string, given: Row, rate: unknown, picks: unknown): Promise { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); if (!projectId) throw new Error("프로젝트 없음"); const res = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/logic-preview`, { method: "POST", diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index ff8f4a13c..4eeb511d8 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -12,7 +12,7 @@ import "@ui/ui_template_general_layout.css"; import "./B09_Estimation_UI_Page.css"; -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { API_BASE_URL } from "@config/config_frontend"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { createText } from "@ui/ui_template_text"; import { @@ -55,6 +55,7 @@ import { renderGroupPanel, unpickOnRelease, } from "./B09_Estimation_UI_Tabs_Groups"; +import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_switch"; // 252 초안 탭(공기 등) — draft 폴더가 있으면 등록 · 지우면 빈 목록(오류 없음) import.meta.glob("./draft/*_UI.ts", { eager: true }); @@ -121,7 +122,7 @@ export function computeSheets(payload: Row, user: Row): Row { } export function renderB09Estimation(root: HTMLElement): void { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); let payload: Row | null = null; let user: Row = {}; let sheets: Row = {}; @@ -378,6 +379,13 @@ export function renderB09Estimation(root: HTMLElement): void { }); root.append(layout.root); show(current); + // 572-2 떠남 물음 — 고침 = 세션 칸(불러온 뒤) · 저장 = 단계 이동과 같은 길 + if (projectId) + guardProjectLeave(layout.root, { + dirty: () => !!payload && readState("b09-estimation", projectId) !== null, + save: saveChanged, + discard: () => clearState("b09-estimation", projectId), + }); if (projectId) request(`/projects/${projectId}/estimation`).then( diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts index 8a28db2ef..4114f763f 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts @@ -6,9 +6,10 @@ * 길 = `B09_Estimation_Router_Quotes.py` · 바뀐 목록은 onChange(이름들)로 표 칸 「n 파일」에 반영 * ========================================================================== */ -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { API_BASE_URL } from "@config/config_frontend"; import { openModal } from "@ui/ui_template_modal"; import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { currentProjectId } from "../A00_Common/b_project_switch"; interface QuoteFile { name: string; @@ -17,7 +18,7 @@ interface QuoteFile { } const url = (key: string, name?: string): string => { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + const projectId = currentProjectId(); const q = new URLSearchParams({ key, ...(name ? { name } : {}) }); return `${API_BASE_URL}/projects/${projectId}/estimation/quotes${name ? "/file" : ""}?${q}`; }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index bdc49755d..31a4dbd21 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -12,7 +12,7 @@ * 층 읽기 권한 = 서버(`M02_Layer_Access`) * ========================================================================== */ -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { API_BASE_URL } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { loadRows } from "@ui/ui_template_item_picker"; import { itemName, type PickerLayer } from "@ui/ui_template_item_picker_data"; @@ -24,6 +24,7 @@ import type { Dist } from "../B08_Quantity/B08_Quantity_Haul_Cycle"; import type { Row } from "./B09_Estimation_Calc_Base"; import { openHaulModal } from "./B09_Estimation_UI_Haul_Modal"; import type { TabContext } from "./B09_Estimation_UI_Tabs"; +import { currentProjectId } from "../A00_Common/b_project_switch"; /** 연결 적용 — ref = null 이면 풀기 · from = 고른 호표(명칭 · 규격 · 단위 채움) */ export type ApplyRef = (ref: Row | null, from?: Row) => void; @@ -89,7 +90,7 @@ export function parseLib(lib: string): LibPick | null { } async function postLib(path: string, body: Row): Promise { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); if (!projectId) throw new Error("프로젝트 없음"); const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/${path}`, { method: "POST", diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.ts b/B09_Estimation/draft/B09_Draft_Print_Page.ts index 6024f699d..013f836ca 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Page.ts +++ b/B09_Estimation/draft/B09_Draft_Print_Page.ts @@ -13,7 +13,7 @@ import "@ui/ui_template_general_layout.css"; import "../B09_Estimation_UI_Page.css"; // 좌측 하단 단추 줄(b09-dock) 규칙 한 벌 import "./B09_Draft_Print_Page.css"; -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; import { createCheckbox } from "@ui/ui_template_checkbox"; import { createButton } from "@ui/ui_template_elements"; @@ -32,6 +32,7 @@ import type { FetchDrawingsPdf, PrintSheet, } from "./B09_Draft_Print_Types"; +import { currentProjectId } from "../../A00_Common/b_project_switch"; type AreaId = "drawings" | "quantity" | "cost"; @@ -116,7 +117,7 @@ function save(blob: Blob, name: string): void { } export function renderB09Print(root: HTMLElement): MountedPage { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); const status = createText("", "muted"); status.className += " b09-print__status"; diff --git a/B11_Status/B11_Status_UI_Loading.ts b/B11_Status/B11_Status_UI_Loading.ts index 80e5c289f..472f713a8 100644 --- a/B11_Status/B11_Status_UI_Loading.ts +++ b/B11_Status/B11_Status_UI_Loading.ts @@ -1,4 +1,4 @@ -import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; +import { ROUTES, type RoutePath } from "@config/config_frontend"; import { createButton } from "@ui/ui_template_elements"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { t } from "@ui/ui_template_locale"; @@ -7,12 +7,12 @@ import { markProjectPreloaded, preloadSurfaceAssets, PRELOAD_NO_SURFACE, - purgeOtherProjects, readPreloadTarget, } from "../A00_Common/b_asset_cache"; import { navigateTo } from "../A00_Common/router"; import { fetchSurfaceStatus } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import "./B11_Status_UI_Style.css"; +import { currentProjectId } from "../A00_Common/b_project_switch"; /* ============================================================================= * 자료 준비 화면 (B11) @@ -51,7 +51,7 @@ async function waitWhileDesigning( } export async function renderB11Loading(root: HTMLElement): Promise { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); const target = (readPreloadTarget() ?? ROUTES.B03_FILE_INPUT) as RoutePath; const progress = createProgressCircle({ label: t("B11_Loading_Start"), size: 96 }); @@ -83,8 +83,6 @@ export async function renderB11Loading(root: HTMLElement): Promise { // 초기 설계 체인이 도는 동안은 여기서 기다린다(2026-08-29 사용자 확정) — 자료를 담아도 // 계산이 안 끝난 화면으로 보내면 반쯤 된 값을 만지게 된다. await waitWhileDesigning(projectId, (label) => progress.set(0, label)); - // 다른 프로젝트 자료가 남아 있으면 지운다 — 프로젝트끼리 섞이면 안 된다. - await purgeOtherProjects(projectId); const signature = await preloadSurfaceAssets(projectId, (label, ratio) => { progress.set(ratio, label); }); diff --git a/resources/tester/helper_298_2_b08_b09_buttons.cjs b/resources/tester/helper_298_2_b08_b09_buttons.cjs index 4f7106425..63e5bee72 100644 --- a/resources/tester/helper_298_2_b08_b09_buttons.cjs +++ b/resources/tester/helper_298_2_b08_b09_buttons.cjs @@ -140,6 +140,8 @@ const common = { return true; }, }, + // 572-2 지금 프로젝트 = 탭마다 · 떠남 가드는 test_560_4 + "../A00_Common/b_project_switch": { currentProjectId: () => "P1", guardProjectLeave() {} }, "../A00_Common/b_page_state": { readState: (name) => store.get(name) ?? null, writeState: (name, v) => store.set(name, v), diff --git a/resources/tester/helper_481_2_save_move.cjs b/resources/tester/helper_481_2_save_move.cjs index 393d25590..bfd031bb2 100644 --- a/resources/tester/helper_481_2_save_move.cjs +++ b/resources/tester/helper_481_2_save_move.cjs @@ -128,6 +128,8 @@ const common = { return true; }, }, + // 572-2 지금 프로젝트 = 탭마다 · 떠남 가드는 test_560_4 + "../A00_Common/b_project_switch": { currentProjectId: () => "P1", guardProjectLeave() {} }, "../A00_Common/b_page_state": { readState: (name) => store.get(name) ?? null, writeState: (name, v) => store.set(name, v), diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index 71cd98c8c..30d551580 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -186,8 +186,7 @@ const toasts = []; const sent = []; globalThis.showToast = (m, k) => toasts.push([k, m]); globalThis.API_BASE_URL = "/api"; -globalThis.CURRENT_PROJECT_ID_KEY = "pid"; -globalThis.localStorage = { getItem: () => "P1" }; +globalThis.currentProjectId = () => "P1"; let picker = null; // 251-6 단가연결 = 한 창(openLogicPicker · 사전조합 + 덧붙인 줄 구조물 조합) globalThis.openLogicPicker = (done, lib) => { picker = lib; sent.push(["pre", lib.layer, lib.kindFilter.map((k) => k.value), lib.title]); }; diff --git a/resources/tester/test_560_4_leave_save.py b/resources/tester/test_560_4_leave_save.py index a47f156d2..9fb5a51b5 100644 --- a/resources/tester/test_560_4_leave_save.py +++ b/resources/tester/test_560_4_leave_save.py @@ -1,4 +1,4 @@ -"""560-4 D 단계 이동 = 저장 뒤 이동 지킴 시험 — 서버 · 화면 없이 소스만 본다. +"""560-4 D 단계 이동 = 저장 뒤 이동 · 572-2 B 떠남 물음 지킴 시험 — 서버 · 화면 없이 소스만 본다. 단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로가 한 함수(beforeLeave)를 거치고 · D02 첫 불러오기 실패에도 지도를 세움.""" import re @@ -25,3 +25,62 @@ def test_d02_leave_saves_and_builds_map_on_fail(): # 불러오기 catch 뒤에 paint() — 실패해도 지도 · 표를 세움 assert re.search(r"\} catch \(error\) \{[^}]*\}\s*active = firstEmpty\(\);\s*paint\(\);", tail) assert "sameBase" in ts # 옛 캐시는 그때 서버 값이 지금과 같을 때만 + + +# 572-2 B 페이지 떠남 물음 — 공용 가드에 {dirty, save} · 지금 프로젝트 = 탭마다(currentProjectId) +B_GUARDS = { + "B04_PreProcess/B04_PreProcess_UI_Page.ts": ( + "mapViewer.drainageDirty", + "mapViewer.commitDrainage", + ), + "B07_DesignDetail/B07_DesignDetail_UI_Page.ts": ( + "cadDirty || tabs.anyDirty()", + "saveEditedDrawings(store)", + ), + "B08_Quantity/B08_Quantity_UI_Page.ts": ("readState(name, projectId)", "save: saveShown"), + "B09_Estimation/B09_Estimation_UI_Shell.ts": ( + 'readState("b09-estimation", projectId)', + "save: saveChanged", + ), +} + +B_PROJECT_READS = [ + "B02_ProjRegister/B02_ProjRegister_UI_Page.ts", + "B03_FileInput/B03_FileInput_UI_Page.ts", + "B03_FileInput/B03_FileInput_UI_Page_Flow.ts", + "B03_FileInput/B03_FileInput_UI_PlanImport.ts", + *B_GUARDS, + "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts", + "B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts", + "B09_Estimation/B09_Estimation_UI_Haul_Modal.ts", + "B09_Estimation/draft/B09_Draft_Print_Page.ts", + "B11_Status/B11_Status_UI_Loading.ts", +] + + +def test_b_pages_guard_leave(): + for path, (dirty, save) in B_GUARDS.items(): + ts = _src(path) + at = ts.index("guardProjectLeave(layout.root, {") + block = ts[at : ts.index("});", at)] + assert dirty in block and save in block, path + b07 = _src("B07_DesignDetail/B07_DesignDetail_UI_Page.ts") + assert "discard: () => discardEditedRecovery(store)" in b07 # 폐기 = CAD 자동백업도 + + +def test_b_pages_read_tab_project(): + for path in B_PROJECT_READS: + ts = _src(path) + # 탭 공용 localStorage 직접 읽기 · 쓰기 없음 + assert "CURRENT_PROJECT_ID_KEY" not in ts, path + assert "currentProjectId()" in ts or "setCurrentProject(" in ts, path + + +def test_b_pages_leave_other_project_cache_to_enter(): + # 다른 프로젝트 보관함 지우기 = enterProject 한 곳(다른 탭 보관분을 지우지 않게) + for path in ( + "B04_PreProcess/B04_PreProcess_UI_Page.ts", + "A00_Common/design/design_view_profile.ts", + "B11_Status/B11_Status_UI_Loading.ts", + ): + assert "purgeOtherProjects" not in _src(path), path