diff --git a/D02_PlanBase/D02_PlanBase_UI_Page.ts b/D02_PlanBase/D02_PlanBase_UI_Page.ts index 6d6d4580d..fe583cb36 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Page.ts +++ b/D02_PlanBase/D02_PlanBase_UI_Page.ts @@ -1,16 +1,17 @@ /* ============================================================================= * D02_PlanBase_UI_Page.ts * D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · PLAN 560-2) — 메인 = 카카오맵 · 좌 = 전체 시점 · 종점. - * 조작(끌기 · 지도 누름 · 경위도 입력) = sessionStorage 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈) - * · 떠남(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) = 바뀐 것 있으면 저장 뒤 · 실패면 머묾(560-4) + * 조작(끌기 · 지도 누름 · 경위도 입력) = 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈) + * · 떠남(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) = 바뀐 것 있으면 공용 물음 [취소][폐기][저장](572-3) * · 자동저장 없음(CLAUDE.md 5장) · 캐시는 그때 서버 값(base)이 지금과 같을 때만 씀(D03 이 고친 값 우선). * 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · 떠남 = 그냥. + * 캐시 = 등록표 `d02-points`(프로젝트 칸 · 떠남 비우기에 걸림). * 첫 불러오기가 실패해도 지도 · 표는 세움(빈 화면 없음). * 지도 [일반][위성](560-3) = 카카오 HYBRID · 고른 값 = 화면 취향(`aislo:pref:`) 기억. * ========================================================================== */ import "./D02_PlanBase_UI_Style.css"; -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import { createButton, createInputField, @@ -24,7 +25,14 @@ import { createText } from "@ui/ui_template_text"; import { editMap, type MapPin, type MapType } from "@ui/ui_template_kakao_map"; import { createSegment } from "@ui/ui_template_segment"; import { t } from "@ui/ui_template_locale"; -import { readByKey, writeByKey } from "../A00_Common/b_page_state"; +import { + clearState, + readByKey, + readState, + writeByKey, + writeState, +} from "../A00_Common/b_page_state"; +import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; @@ -46,8 +54,6 @@ const KOREA_CENTER: [number, number] = [127.8, 36.3]; const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm const PREF_MAP_TYPE = "aislo:pref:d02.map.type"; -const cacheKey = (projectId: string): string => `d02_points_${projectId}`; - async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", @@ -68,7 +74,7 @@ function boundaryPoints(view: PointsView): Array { } export async function renderD02PlanBase(root: HTMLElement): Promise { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + const projectId = currentProjectId(); const mapHost = el("div", { className: "d02-map" }); const table = el("div", { className: "d02-points" }); const info = createText("", "muted"); @@ -87,7 +93,9 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { mountGroupStep(root, ROUTES.D02_PLAN_BASE, { mainContent: mapHost, leftPanel: left, - beforeLeave: async () => !editable || !dirty() || store(), + dirty: () => editable && dirty(), + save: () => store(), + discard: () => clearState("d02-points", projectId), }); let view: PointsView | null = null; @@ -103,7 +111,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { const serverPoints = (): Array => (view ? boundaryPoints(view) : [null, null]); const dirty = (): boolean => JSON.stringify(points) !== JSON.stringify(serverPoints()); const remember = (): void => - sessionStorage.setItem(cacheKey(projectId), JSON.stringify({ base: serverPoints(), points })); + writeState("d02-points", { base: serverPoints(), points }, projectId); const map = editMap({ hint: t("D02_Map_Hint"), @@ -232,7 +240,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { body: JSON.stringify(body), }), ); - sessionStorage.removeItem(cacheKey(projectId)); + clearState("d02-points", projectId); active = firstEmpty(); paint(); showToast(t("D02_Saved"), "success"); @@ -254,10 +262,10 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { if (mapHint) mapHint.textContent = t("D02_View_Only"); } adopt(await request(`/projects/${projectId}/plan/points`)); - const cached = JSON.parse(sessionStorage.getItem(cacheKey(projectId)) ?? "null") as { + const cached = readState<{ base?: Array; points?: Array; - } | null; + }>("d02-points", projectId); const sameBase = JSON.stringify(cached?.base) === JSON.stringify(points); if (editable && sameBase && cached?.points?.length === 2) points = cached.points; } catch (error) { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index bd955b005..8f9a1d3fb 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -13,7 +13,7 @@ * ========================================================================== */ import "./D03_PlanAnalysis_UI_Style.css"; -import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { ROUTES } from "@config/config_frontend"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import { editMap } from "@ui/ui_template_kakao_map"; @@ -27,6 +27,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler"; @@ -79,7 +80,7 @@ const errorText = (error: unknown): string => const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1); export async function renderD03PlanAnalysis(root: HTMLElement): Promise { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + const projectId = currentProjectId(); let editable = false; let start: Vertex = [0, 0]; let end: Vertex = [0, 0]; @@ -161,11 +162,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { panel.className = "ui-sidebar-panel d03-plan__panel"; panel.append(terrainCard, drawCard, pieceCard, actions); attachCollapsible(panel); - // 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 선만 저장 · 실패면 머묾 + // 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3) mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { mainContent: main, leftPanel: panel, - beforeLeave: async () => !editable || !dirty() || (await save()), + dirty: () => editable && dirty(), + save, + discard: () => clearState("d03-plan", projectId), }); root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout"); diff --git a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts index c66aa69a2..7af2d942e 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts +++ b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts @@ -11,7 +11,7 @@ * ========================================================================== */ import "./D04_PlanConfirm_UI_Style.css"; -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import { kstText } from "../common_util/common_util_time_kst"; import { createButton, @@ -30,6 +30,7 @@ import { tableRow, tableShell, type Column } from "@ui/ui_template_table"; import { t } from "@ui/ui_template_locale"; import { stationLabel } from "../common_util/common_util_station"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { searchCompanies } from "../A07_Register/A07_Register_Api_Fetch"; @@ -105,7 +106,7 @@ const sameFirm = (a: Assignee, b: Assignee): boolean => : b.company_id === null && (a.email ?? "").toLowerCase() === (b.email ?? "").toLowerCase()); export async function renderD04PlanConfirm(root: HTMLElement): Promise { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + const projectId = currentProjectId(); const base = `/projects/${projectId}/plan`; const main = el("div", { className: "d04-confirm" }); const pickList = el("div", { className: "d04-confirm__picks" }); @@ -113,7 +114,17 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise { pickCard.append(pickList); const actions = el("div", { className: "ui-sidebar-actions" }); const panel = el("div", { className: "d04-confirm__panel", children: [pickCard, actions] }); - mountGroupStep(root, ROUTES.D04_PLAN_CONFIRM, { mainContent: main, leftPanel: panel }); + // 떠날 때 저장 안 한 지정 업체가 있으면 공용 물음 [취소][폐기][저장](572-3) + mountGroupStep(root, ROUTES.D04_PLAN_CONFIRM, { + mainContent: main, + leftPanel: panel, + dirty: () => draft() !== null, + save: async () => { + await locked(save); + return draft() === null; // 실패 = 캐시 남음 = 머묾 + }, + discard: () => clearState("d04-assignees", projectId), + }); let summary: Summary = { name: "", years: [], pieces: [], assignees: [] }; let editable = false; // 산림청만 고침 diff --git a/E02_StabGround/E02_StabGround_UI_Page.ts b/E02_StabGround/E02_StabGround_UI_Page.ts index ed4add8d2..7ce810fa7 100644 --- a/E02_StabGround/E02_StabGround_UI_Page.ts +++ b/E02_StabGround/E02_StabGround_UI_Page.ts @@ -2,16 +2,16 @@ * E02_StabGround_UI_Page.ts * E02 지반 조건(요구서 345 4-3 · 요구 347-2 · 3 · 4 · PLAN 532-3) — B05~B09 틀(group_workflow). * GET /api/projects/{id}/stab/ground → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지. - * 조작 = sessionStorage 캐시만(자동저장 없음) · [해석 시작] = POST …/stab/run(서버가 다시 검사 · + * 조작 = 캐시 `e02-ground` 만(자동저장 없음) · [해석 시작] = POST …/stab/run(서버가 다시 검사 · * 저장 → 2D · 3D · 암반 · 광역 · 보고서 체인 · PLAN 564-3) · 진행 = GET …/stab/run/progress(다른 - * 페이지에 갔다 와도 이어 보임) · [끊기] · 떠날 때 바뀐 값 = PUT …/stab/ground(beforeLeave) · + * 페이지에 갔다 와도 이어 보임) · [끊기] · 떠날 때 바뀐 값 = 공용 물음 [취소][폐기][저장](572-3) · * [초기화] = POST …/reset(초기값 덮어씀). * 검사 = logic/E02_StabGround_Check(파이썬 짝). * 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05). * ========================================================================== */ import "./E02_StabGround_UI_Style.css"; -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import { createButton, el, @@ -25,6 +25,8 @@ import { createListRow } from "@ui/ui_template_list_row"; import { createProgressBar } from "@ui/ui_template_progress"; import { createText } from "@ui/ui_template_text"; import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { inferStationInterval } from "../common_util/common_util_station"; @@ -60,8 +62,6 @@ interface RunProgress { cancelled: boolean; } -const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`; - /** 단계 줄 왼쪽 띠 색 — 기다림 = 없음 */ const STEP_TONE: Record = { run: "var(--color-accent)", @@ -104,7 +104,7 @@ async function request(path: string, init?: RequestInit): Pr } export function renderE02StabGround(root: HTMLElement): void { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); const main = el("div", { className: "e02-page" }); const status = el("div", { className: "e02-status" }); const runButton = createButton({ @@ -143,7 +143,9 @@ export function renderE02StabGround(root: HTMLElement): void { mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel, - beforeLeave: async () => !view?.editable || !dirty || store(), + dirty: () => Boolean(view?.editable) && dirty, + save: store, + discard: () => clearState("e02-ground", projectId), }); if (!projectId) return void main.append(hook(createText(t("E02_NoProject")), "e02-empty")); @@ -247,15 +249,15 @@ export function renderE02StabGround(root: HTMLElement): void { const remember = (): void => { if (!view) return; dirty = true; - sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.ground)); + writeState("e02-ground", view.ground, projectId); }; const adopt = (body: GroundResponse, keepCache: boolean): void => { if (!view || !body.defaults) return; let ground = body.ground; - const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null; - if (cached) ground = JSON.parse(cached) as Ground; - else sessionStorage.removeItem(cacheKey(projectId)); + const cached = keepCache ? readState("e02-ground", projectId) : null; + if (cached) ground = cached; + else clearState("e02-ground", projectId); // 옛 캐시(지하수 갈래 칸 전) — 빠진 칸은 서버 값(서버가 기본값으로 채워 보냄) ground.groundwater = { ...body.ground.groundwater, ...ground.groundwater }; dirty = Boolean(cached); @@ -278,7 +280,7 @@ export function renderE02StabGround(root: HTMLElement): void { return errors.length === 0; } - /** 떠날 때(560-4 beforeLeave) — 해석 없이 바뀐 값만 저장 · 틀리거나 실패 = 머묾 */ + /** 떠남 물음 [저장](572-3) — 해석 없이 바뀐 값만 저장 · 틀리거나 실패 = 머묾 */ async function store(): Promise { if (!view || !projectId || !valid()) return false; showLoadingOverlay(); @@ -287,7 +289,7 @@ export function renderE02StabGround(root: HTMLElement): void { method: "PUT", body: JSON.stringify({ ground: view.ground }), }); - sessionStorage.removeItem(cacheKey(projectId)); + clearState("e02-ground", projectId); dirty = false; showToast(t("E02_Saved"), "success"); return true; diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index 631c48364..7c907017a 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -4,13 +4,13 @@ * 좌 = 측점 목록(조건 · 정렬 · N.G 만 · 대표 단면 별) · [해석] · [3D 보기] · [대표 단면 저장] * 위 = 평면 위험 띠 · 가운데 = 횡단(임계면 · 등안전율 · 후보 · 절편) · 우 = 결과 · 절편표 · 자유물체도 · 그래프 * 계산 = 서버 단독([해석] → POST …/stab/analyze2d · 잠금판 진행률 = GET …/stab/progress) · - * 대표 단면 켬/끔 = sessionStorage 캐시 → [대표 단면 저장] = PUT(자동저장 없음). + * 대표 단면 별 = 누를 때마다 바로 PUT(요구 390) · 떠날 때 저장 중이면 공용 물음 [취소][폐기][저장](572-3). * 「보강 검토」 탭 = 우측을 가정 재해석 · 벽 외적 안정으로 바꿈(`UI_Reinforce` · 서버 따로 저장본). * 「확률 · 민감도」 탭 = 대표 단면 확률 해석(`UI_Prob` · 서버 따로 저장본 · 카드 안 진행률). * ========================================================================== */ import "./E03_StabSection_UI_Style.css"; -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import { createButton, createInputField, @@ -23,6 +23,7 @@ import { } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { createSegment } from "@ui/ui_template_segment"; +import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { buildList } from "./E03_StabSection_UI_List"; @@ -92,10 +93,15 @@ async function api(path: string, init?: RequestInit): Promise { const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); export function renderE03StabSection(root: HTMLElement): void { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); const main = el("div", { className: "e03-page" }); const panel = el("div", { className: "ui-sidebar-panel e03-panel" }); - mountGroupStep(root, ROUTES.E03_STAB_SECTION, { mainContent: main, leftPanel: panel }); + mountGroupStep(root, ROUTES.E03_STAB_SECTION, { + mainContent: main, + leftPanel: panel, + dirty: () => repPending > 0, + save: () => repSaving.then(() => true), + }); if (!projectId) return void main.append(emptyBox(t("E03_NoProject"))); const pid: string = projectId; const base = `/projects/${pid}/stab`; @@ -118,6 +124,7 @@ export function renderE03StabSection(root: HTMLElement): void { let interval = 20; // 측점 간격 — 측점 목록에서 추정(요구 390) const planView: PlanView = {}; let repSaving: Promise = Promise.resolve(); // 별 누름마다 차례로 저장 + let repPending = 0; // 아직 안 끝난 별 저장(떠남 물음) // ---- 좌측 ---- const condField = createSelectField({ @@ -567,26 +574,34 @@ export function renderE03StabSection(root: HTMLElement): void { /** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */ function toggleRep(key: string): Promise { - repSaving = repSaving.then(async () => { - const row = result?.stations.find((r) => r.key === key); - if (!row) return; - const server = row.representative ?? { auto: [], user: null, on: false }; - const auto = server.auto.length > 0; - const next = !server.on === auto ? null : !server.on; - const overrides: Record = {}; - for (const r of result?.stations ?? []) { - const user = r.key === key ? next : (r.representative?.user ?? null); - if (user !== null) overrides[r.key] = user; - } - try { - await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) }); - row.representative = { ...server, user: next, on: next === null ? auto : next }; - paintList(); - void refreshReport(); - } catch (error) { - showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); - } - }); + repPending += 1; + repSaving = repSaving + .then(async () => { + const row = result?.stations.find((r) => r.key === key); + if (!row) return; + const server = row.representative ?? { auto: [], user: null, on: false }; + const auto = server.auto.length > 0; + const next = !server.on === auto ? null : !server.on; + const overrides: Record = {}; + for (const r of result?.stations ?? []) { + const user = r.key === key ? next : (r.representative?.user ?? null); + if (user !== null) overrides[r.key] = user; + } + try { + await api(`${base}/representative`, { + method: "PUT", + body: JSON.stringify({ overrides }), + }); + row.representative = { ...server, user: next, on: next === null ? auto : next }; + paintList(); + void refreshReport(); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } + }) + .finally(() => { + repPending -= 1; + }); return repSaving; } diff --git a/E03_StabSection/E03_StabSection_UI_Prob.ts b/E03_StabSection/E03_StabSection_UI_Prob.ts index 045696886..befa539d6 100644 --- a/E03_StabSection/E03_StabSection_UI_Prob.ts +++ b/E03_StabSection/E03_StabSection_UI_Prob.ts @@ -4,7 +4,7 @@ * 분포 입력(단면 물성 × c · φ · γ · 지하수위 · r_u — 쓰기 · 분포 · 변동계수/표준편차 · 절단 ±kσ) · * 표본(LHS · MC · N · 시드) · 갈래(임계면 고정 · 매회 탐색) · 방법 · c–φ 상관 → * 서버 계산(POST …/stab/prob/{측점} · 뒤에서 · 진행 GET …/stab/prob/progress · 카드 안 진행률). - * 기본값 후보 = 서버 `prob_defaults.json`(근거 · 사용자 확정 전) · 입력 = sessionStorage 캐시(자동저장 없음). + * 기본값 후보 = 서버 `prob_defaults.json`(근거 · 사용자 확정 전) · 입력 = 캐시 `e03-prob`(자동저장 없음). * 결과 = 요약 표 · 히스토그램 · 누적분포 · 민감도 · 토네이도(`UI_ProbChart`). * ========================================================================== */ @@ -21,6 +21,7 @@ import { createCheckbox } from "@ui/ui_template_checkbox"; import { createKv } from "@ui/ui_template_kv"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; +import { readState, writeState } from "../A00_Common/b_page_state"; import { card, emptyBox, muted } from "./E03_StabSection_UI_Parts"; import { e03Table } from "./E03_StabSection_UI_Table"; import { @@ -76,13 +77,12 @@ async function api(path: string, init?: RequestInit): Promise { /** 탭 하나 — 준비(setup) · 결과 · 진행을 들고 다시 그릴 때 `repaint` 를 부름 */ export function createProbPanel(projectId: string, base: string, repaint: () => void) { - const cacheKey = `e03_prob_cache:${projectId}`; const setups = new Map(); const records = new Map(); - let cache: ProbCache | null = JSON.parse(sessionStorage.getItem(cacheKey) ?? "null"); + let cache: ProbCache | null = readState("e03-prob", projectId); let busy: Progress | null = null; - const save = (): void => sessionStorage.setItem(cacheKey, JSON.stringify(cache)); + const save = (): void => writeState("e03-prob", cache, projectId); const slotOf = (v: ProbView, side: string): string => `${v.row.key}|${side}|${v.condition}`; async function load(slot: string, key: string, side: string, condition: string): Promise { diff --git a/E04_Stab3D/E04_Stab3D_UI_Page.ts b/E04_Stab3D/E04_Stab3D_UI_Page.ts index bf8562983..2f5a0a61f 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Page.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Page.ts @@ -4,12 +4,12 @@ * 좌 = 구간 목록 · 탐색 설정(기본 · 고급 접힘) · [해석] · 진행률 · [끊기] * 가운데 = 평면 지도(셀별 최소 안전율 · 임계 체적) · [3D 보기](지표 + 노선면 + 임계 활동면) * 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간 E03 측점 · 3D 와 같은 조건 안전율 최소) · 3D/2D 비 · 체적 · 방법) - * 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage + * 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = 캐시 `e04-settings` * · 없으면 저장된 결과의 설정 · 결과 열쇠 = 구간 + 조건(건기 · 우기 따로 남음). * ========================================================================== */ import "./E04_Stab3D_UI_Style.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 { createBadge } from "@ui/ui_template_badge"; import { createBanner } from "@ui/ui_template_banner"; @@ -24,6 +24,8 @@ import { createProgressBar } from "@ui/ui_template_progress"; import { createSegment } from "@ui/ui_template_segment"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { createText } from "@ui/ui_template_text"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { createPlanMap, type MapLayer, type StationPoint } from "./E04_Stab3D_UI_Map"; @@ -70,7 +72,6 @@ interface Progress { key: string | null; } -const cacheKey = (projectId: string): string => `e04_settings:${projectId}`; const CONDITIONS = ["dry", "wet"] as const; const rangeKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`; const sectionKey = (start: number, end: number, condition: string): string => @@ -135,7 +136,7 @@ function legend(layer: MapLayer): HTMLElement { } export function renderE04Stab3D(root: HTMLElement): void { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); const panel = el("div", { className: "ui-sidebar-panel e04-panel" }); const main = el("div", { className: "e04-page" }); mountGroupStep(root, ROUTES.E04_STAB_3D, { mainContent: main, leftPanel: panel }); @@ -252,8 +253,7 @@ export function renderE04Stab3D(root: HTMLElement): void { const station = (chainage: number): string => stationLabel(chainage, inferStationInterval(overview?.stations ?? []), 1); - const remember = (): void => - sessionStorage.setItem(cacheKey(projectId), JSON.stringify(settings.read())); + const remember = (): void => writeState("e04-settings", settings.read(), projectId); let shownKey = ""; settings.onChange(() => { remember(); @@ -456,9 +456,7 @@ export function renderE04Stab3D(root: HTMLElement): void { } const ov = overview!; settings.setDefaults(ov.defaults, ov.max_section_m); - const cached = JSON.parse( - sessionStorage.getItem(cacheKey(projectId)) ?? "null", - ) as Settings | null; + const cached = readState("e04-settings", projectId); const first = ov.saved[0] ?? ov.sections[0]; settings.write( cached ?? { diff --git a/E05_StabReport/E05_StabReport_UI_Page.ts b/E05_StabReport/E05_StabReport_UI_Page.ts index 9e345aa5a..ea0d0485e 100644 --- a/E05_StabReport/E05_StabReport_UI_Page.ts +++ b/E05_StabReport/E05_StabReport_UI_Page.ts @@ -3,14 +3,15 @@ * E05 안정성평가 보고서(요구서 345 4-3 · 요구 347-5 · PLAN 534-3) — B05~B09 틀(group_workflow). * GET /api/projects/{id}/stab/report → 작업본(없고 2D 결과가 있으면 서버가 처음 만듦) · 판 · stale. * 보기 = 내장 엑셀(시트 탭 · 그림 겹판 · 쪽 나눔 점선 · 늦게 불러옴). - * 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT {rev, book}(서버 재계산 · 판 다르면 409) · + * 조작 = 캐시 `e05-report` 만(자동저장 없음) · [저장] = PUT {rev, book}(서버 재계산 · 판 다르면 409) · * [초기화] = POST …/reset · [보고서 갱신] = POST …/refresh(프로그램 몫 시트 · 그림만 다시). * 열 때 해석이 바뀌었으면 서버가 저절로 갱신(`refreshed` · 덮인 시트 `overwritten` 알림). * [엑셀] · [PDF] · [PDF 미리보기] = 화면 지금 책을 서버로(보기만이면 작업본 GET) · 저장 안 함. + * 떠날 때 고친 책이 있으면 공용 물음 [취소][폐기][저장](572-3). * ========================================================================== */ import "./E05_StabReport_UI_Style.css"; -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import { createButton, el, @@ -24,6 +25,8 @@ import { createEmpty } from "@ui/ui_template_empty"; import { t } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; @@ -51,8 +54,6 @@ interface CacheEntry { book: Workbook; } -const cacheKey = (projectId: string): string => `e05_report_cache:${projectId}`; - async function failure(response: Response): Promise { const body = (await response.json().catch(() => ({}))) as { detail?: unknown }; return new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); @@ -91,7 +92,7 @@ function emptyBox(text: string): HTMLElement { } export function renderE05StabReport(root: HTMLElement): void { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); const main = el("div", { className: "e05-page" }); const sheetHost = el("div", { className: "e05-sheet" }); const status = el("div", { className: "e05-status" }); @@ -137,7 +138,16 @@ export function renderE05StabReport(root: HTMLElement): void { previewUrl = null; }; - mountGroupStep(root, ROUTES.E05_STAB_REPORT, { mainContent: main, leftPanel: panel }); + mountGroupStep(root, ROUTES.E05_STAB_REPORT, { + mainContent: main, + leftPanel: panel, + dirty: () => editable && dirty, + save: async () => { + await save(); + return !dirty; // 실패 = 고침 남음 = 머묾 + }, + discard: () => clearState("e05-report", projectId), + }); if (!projectId) return void sheetHost.append(emptyBox(t("E05_NoProject"))); let sheet: SpreadsheetHandle | null = null; @@ -178,10 +188,9 @@ export function renderE05StabReport(root: HTMLElement): void { return paint(); } let book = body.book; - const raw = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null; - const cached = raw ? (JSON.parse(raw) as CacheEntry) : null; + const cached = keepCache ? readState("e05-report", projectId) : null; if (cached && cached.rev === body.rev) book = cached.book; - else sessionStorage.removeItem(cacheKey(projectId)); + else clearState("e05-report", projectId); dirty = Boolean(cached && cached.rev === body.rev); const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet"); sheet?.destroy(); @@ -192,9 +201,10 @@ export function renderE05StabReport(root: HTMLElement): void { figureSrc, onChange: (next) => { dirty = true; - sessionStorage.setItem( - cacheKey(projectId), - JSON.stringify({ rev: state?.rev ?? "", book: next } satisfies CacheEntry), + writeState( + "e05-report", + { rev: state?.rev ?? "", book: next } satisfies CacheEntry, + projectId, ); paint(); }, @@ -223,7 +233,7 @@ export function renderE05StabReport(root: HTMLElement): void { method: "PUT", body: JSON.stringify({ rev: state.rev, book: sheet.getDoc() }), }); - sessionStorage.removeItem(cacheKey(projectId)); + clearState("e05-report", projectId); state = { ...state, rev: body.rev, meta: body.meta, stale: body.stale }; dirty = false; }; diff --git a/E06_StabRock/E06_StabRock_UI_Page.ts b/E06_StabRock/E06_StabRock_UI_Page.ts index 03840d0e0..6ee13e44d 100644 --- a/E06_StabRock/E06_StabRock_UI_Page.ts +++ b/E06_StabRock/E06_StabRock_UI_Page.ts @@ -4,12 +4,13 @@ * 좌 = 절리군 표 · 해석 설정 · [초기화] [해석] * 가운데 = 위험 띠 · 스테레오넷(등각 · 등면적 · 보기 = 전체 · 평면 · 쐐기 · 전도) * 우 = 측점별 모드 수 · 최소 안전율 · 판정 · 고른 측점 안전율 표 - * 조작 = sessionStorage 캐시만(자동저장 없음) · [초기화] = POST …/reset · + * 조작 = 캐시 `e06-rock` 만(자동저장 없음) · [초기화] = POST …/reset · * [해석] = 저장(고침이 있으면 PUT) → POST …/analyze(서버 단독) → 결과 GET · 저장 단추 없음. + * 떠날 때 고친 절리군이 있으면 공용 물음 [취소][폐기][저장 = PUT](572-3). * ========================================================================== */ import "./E06_StabRock_UI_Style.css"; -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import { createBanner } from "@ui/ui_template_banner"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { @@ -23,6 +24,8 @@ import { import { t } from "@ui/ui_template_locale"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { buildJointCards, @@ -61,8 +64,6 @@ interface StationResponse { stereonet: Record & { phi: number }; } -const cacheKey = (projectId: string): string => `e06_rock_cache:${projectId}`; - async function api(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", @@ -80,10 +81,26 @@ async function api(path: string, init?: RequestInit): Promise { } export function renderE06StabRock(root: HTMLElement): void { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); const panel = el("div", { className: "ui-sidebar-panel e06-panel" }); const main = el("div", { className: "e06-page" }); - mountGroupStep(root, ROUTES.E06_STAB_ROCK, { mainContent: main, leftPanel: panel }); + mountGroupStep(root, ROUTES.E06_STAB_ROCK, { + mainContent: main, + leftPanel: panel, + dirty: () => Boolean(view?.editable) && dirty, + save: async () => { + showLoadingOverlay(); + try { + return await putJoints(); // 틀린 칸 · 실패 = 머묾 + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + return false; + } finally { + hideLoadingOverlay(); + } + }, + discard: () => clearState("e06-rock", projectId), + }); if (!projectId) return void main.append(emptyBox(t("E06_NoProject"))); // ---- 좌 ---- @@ -191,7 +208,7 @@ export function renderE06StabRock(root: HTMLElement): void { const remember = (): void => { if (!view) return; dirty = true; - sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.doc)); + writeState("e06-rock", view.doc, projectId); }; function paintProjection(): void { @@ -273,9 +290,9 @@ export function renderE06StabRock(root: HTMLElement): void { const adopt = (body: RockResponse, keepCache: boolean): void => { if (!view) return; - const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null; - if (!cached) sessionStorage.removeItem(cacheKey(projectId)); - view.doc = cached ? (JSON.parse(cached) as JointsDoc) : body.joints; + const cached = keepCache ? readState("e06-rock", projectId) : null; + if (!cached) clearState("e06-rock", projectId); + view.doc = cached ?? body.joints; dirty = Boolean(cached); resetButton.disabled = analyzeButton.disabled = !view.editable; renderCards(); diff --git a/E07_StabArea/E07_StabArea_UI_Page.ts b/E07_StabArea/E07_StabArea_UI_Page.ts index f310f504f..3423d9920 100644 --- a/E07_StabArea/E07_StabArea_UI_Page.ts +++ b/E07_StabArea/E07_StabArea_UI_Page.ts @@ -4,12 +4,12 @@ * 좌 = 토층 물성(E02 물성 고름 · 범위) · 시나리오(표준 습윤지수 T/R · 최악 완전 포화) · 노선 띠 · [해석] · 진행률 * 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례 * 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점) - * 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage + * 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = 캐시 `e07-settings` * · 없으면 지난 결과 조건 + 물성은 지금 E02 값. * ========================================================================== */ import "./E07_StabArea_UI_Style.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 { createButton, @@ -31,6 +31,8 @@ import { createTable } from "@ui/ui_template_table"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { createText } from "@ui/ui_template_text"; import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map"; import { @@ -87,8 +89,6 @@ const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [ ["level_hi_pct", "E07_LevelHi", "band"], ]; -const cacheKey = (projectId: string): string => `e07_settings:${projectId}`; - async function api(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", @@ -156,7 +156,7 @@ function legend(layer: MapLayer, scenario: unknown): HTMLElement { } export function renderE07StabArea(root: HTMLElement): void { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const projectId = currentProjectId(); const panel = el("div", { className: "ui-sidebar-panel e07-panel" }); const main = el("div", { className: "e07-page" }); mountGroupStep(root, ROUTES.E07_STAB_AREA, { mainContent: main, leftPanel: panel }); @@ -283,7 +283,7 @@ export function renderE07StabArea(root: HTMLElement): void { map.setBand(Number(fields.get("band_m")!.input.value) || 100); } - const remember = (): void => sessionStorage.setItem(cacheKey(projectId), JSON.stringify(read())); + const remember = (): void => writeState("e07-settings", read(), projectId); panel.addEventListener("input", remember); scenario.select.addEventListener("change", () => { paintScenario(); @@ -477,9 +477,7 @@ export function renderE07StabArea(root: HTMLElement): void { return; } const ov = overview!; - const cached = JSON.parse( - sessionStorage.getItem(cacheKey(projectId)) ?? "null", - ) as Settings | null; + const cached = readState("e07-settings", projectId); // 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 · 캐시가 없으면 물성 = 지금 E02 값 const last = ov.result?.settings as Settings | undefined; write({ diff --git a/resources/tester/test_401_project_isolation.py b/resources/tester/test_401_project_isolation.py index fb8a5f388..b4642e97a 100644 --- a/resources/tester/test_401_project_isolation.py +++ b/resources/tester/test_401_project_isolation.py @@ -33,15 +33,6 @@ PENDING = { "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts", "B09_Estimation/draft/B09_Draft_Print_Page.ts", "B11_Status/B11_Status_UI_Loading.ts", - "D02_PlanBase/D02_PlanBase_UI_Page.ts", - "D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts", - "D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts", - "E02_StabGround/E02_StabGround_UI_Page.ts", - "E03_StabSection/E03_StabSection_UI_Page.ts", - "E04_Stab3D/E04_Stab3D_UI_Page.ts", - "E05_StabReport/E05_StabReport_UI_Page.ts", - "E06_StabRock/E06_StabRock_UI_Page.ts", - "E07_StabArea/E07_StabArea_UI_Page.ts", } diff --git a/resources/tester/test_560_4_leave_save.py b/resources/tester/test_560_4_leave_save.py index a47f156d2..bcdef0195 100644 --- a/resources/tester/test_560_4_leave_save.py +++ b/resources/tester/test_560_4_leave_save.py @@ -1,5 +1,6 @@ """560-4 D 단계 이동 = 저장 뒤 이동 지킴 시험 — 서버 · 화면 없이 소스만 본다. -단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로가 한 함수(beforeLeave)를 거치고 · D02 첫 불러오기 실패에도 지도를 세움.""" +단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로가 한 함수(beforeLeave)를 거치고 · D02 첫 불러오기 실패에도 지도를 세움. +572-3 — D · E 페이지는 공용 가드에 {dirty, save} 를 둠(말없이 저장 없음) · 캐시 = 등록표 · 프로젝트 = currentProjectId.""" import re from pathlib import Path @@ -20,8 +21,62 @@ def test_group_step_waits_before_leave(): def test_d02_leave_saves_and_builds_map_on_fail(): ts = _src("D02_PlanBase/D02_PlanBase_UI_Page.ts") - assert "beforeLeave:" in ts and "ui-step-nav" not in ts # 따로 듣개 없음 + assert "dirty:" in ts and "save:" in ts and "ui-step-nav" not in ts # 따로 듣개 없음 tail = ts[ts.rindex(" try {") :] # 불러오기 catch 뒤에 paint() — 실패해도 지도 · 표를 세움 assert re.search(r"\} catch \(error\) \{[^}]*\}\s*active = firstEmpty\(\);\s*paint\(\);", tail) assert "sameBase" in ts # 옛 캐시는 그때 서버 값이 지금과 같을 때만 + + +# 572-3 D · E — 고침 판별 · 저장 · [폐기] 때 지울 등록표 칸(E03 = 별 저장 진행 중만 · 지울 칸 없음) +DE_GUARDED = { + "D02_PlanBase/D02_PlanBase_UI_Page.ts": "d02-points", + "D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts": "d03-plan", + "D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts": "d04-assignees", + "E02_StabGround/E02_StabGround_UI_Page.ts": "e02-ground", + "E03_StabSection/E03_StabSection_UI_Page.ts": None, + "E05_StabReport/E05_StabReport_UI_Page.ts": "e05-report", + "E06_StabRock/E06_StabRock_UI_Page.ts": "e06-rock", +} +DE_PAGES = [ + *DE_GUARDED, + "E03_StabSection/E03_StabSection_UI_Prob.ts", + "E04_Stab3D/E04_Stab3D_UI_Page.ts", + "E07_StabArea/E07_StabArea_UI_Page.ts", +] +DE_STATES = [ + "d02-points", + "e02-ground", + "e03-prob", + "e04-settings", + "e05-report", + "e06-rock", + "e07-settings", +] + + +def test_de_pages_guard_leave(): + for path, name in DE_GUARDED.items(): + ts = _src(path) + call = ts[ts.index("mountGroupStep(root") :] + call = call[: call.index("});")] + assert "dirty: () =>" in call and "save" in call, path + assert "beforeLeave" not in ts, path # 말없이 저장 없음 — 공용 셋 물음 + if name: + assert f'discard: () => clearState("{name}", projectId)' in call, path + e03 = _src("E03_StabSection/E03_StabSection_UI_Page.ts") + assert "dirty: () => repPending > 0" in e03 and "repPending -= 1" in e03 + + +def test_de_pages_shared_project_and_registry(): + for path in DE_PAGES: + ts = _src(path) + assert "CURRENT_PROJECT_ID_KEY" not in ts and "localStorage" not in ts, path + assert "sessionStorage" not in ts, path + if path.endswith("_UI_Page.ts"): + assert "const projectId = currentProjectId();" in ts, path + registry = _src("A00_Common/b_page_state.ts") + for name in DE_STATES: + assert re.search(rf'"{name}": \{{[^}}]*bucket: "draft",[^}}]*scope: "project"', registry), ( + name + )