/* ============================================================================= * B06_Section_UI_Page_Persist.ts * B06 페이지의 **저장 흐름** — 암 경계선 세션 저장소, 편집분 수집, [저장]·[확정]. * * `B06_Section_UI_Page` 에서 떼어낸 몫이다(700줄 제한, 2026-09-02). 화면 조립·조정창 * 배선은 페이지에 남고, 여기에는 "세션에 쌓인 조작을 정본으로 내보내는" 경로만 둔다 * (CLAUDE.md 5장 데이터 3층: 영구저장은 [저장]·[확정]에서만). * ========================================================================== */ import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import { confirmSections, saveSections, type CrossSectionPatch, type SectionContextResponse, type SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import { invalidateSectionDetail } from "./B06_Section_Section_Store"; import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { saveProfileAlignment } from "../B05_Profile/B05_Profile_Api_Fetch"; import { clearAlignmentDrafts, readAlignmentDraft, } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state"; import { BERM_DEFAULT_INTERVAL_M, BERM_DEFAULT_SLOPE_DEG, BERM_DEFAULT_WIDTH_M, } from "@util/common_util_cross_berm"; import { applyStructureAreaRows, STRUCTURE_ROW_KEYS, structureAreaRows, } from "./B06_Section_Structure_Layouts"; import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { L } from "./B06_Section_UI_Page_Common"; /** * 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. * * **등록표를 거쳐 읽는다**(2026-09-07 고침). 예전에는 옛 키 `b06:rockb:{p}:{r}` 를 날문자열로 * 읽었는데, 쓰는 쪽(`B06_Section_UI_Page.ts` 의 `stateKey("rockb", …)`)은 새 키 * `aislo:draft:rockb:{p}:{r}` 에 쓰고 있어 **읽는 쪽이 늘 빈 값**을 받았다. 그래서 * 계획선을 고쳐 횡단을 다시 계산할 때(`B06_Section_Cross_Refresh`) 사용자가 옮긴 암 경계선이 * 안 실려 나가 토사/암 나눔이 기본값으로 돌아갔다 — 수량이 갈리는 자리다. * 실측(용화 5601e828, route 169): 경계선을 -0.5 → -0.8m 로 옮기니 새 키에만 `{"0.00":-0.8}` * 가 쌓이고 옛 키는 아예 없었다. */ export function readRockBoundarySession( projectId: string, routeId: number, ): Record { const stored = readState>("rockb", projectId, routeId); return stored && typeof stored === "object" ? stored : {}; } /** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */ export interface BermSessionSpec { width_m: number; interval_m: number; slope_deg: number; } /** * 사용자가 놓은 소단 **한 구간** — 종단 범위 + 제원. * * 사용자는 측점 하나가 아니라 **구간**에 놓는다(2026-09-07 확정: 「길이 + 기준측점 전·후」). * 그래서 세션에는 구간 목록으로 두고, 측점별 제원은 읽는 자리에서 편다 — 구간을 측점으로 * 펴서 저장하면 나중에 「어디부터 어디까지 놓았나」를 되짚을 수 없다. */ export interface BermSpan extends BermSessionSpec { start_m: number; end_m: number; } /** * 세션에 쌓인 소단 구간 목록. 없거나 손상되면 빈 목록. * * 암 경계선과 같은 성격이다 — 확정 전에는 세션에만 있으므로 계획선 재계산에 **함께 실어 * 보내야** 한다. 안 실으면 계획선을 고치는 순간 계단이 사라진다(계획서 3-9). */ export function readBermSpans(projectId: string, routeId: number): BermSpan[] { const stored = readState("berm", projectId, routeId); return Array.isArray(stored) ? stored : []; } export function writeBermSpans(projectId: string, routeId: number, spans: BermSpan[]): void { writeState("berm", spans, projectId, routeId); } /** 소단 타입 id — 레지스트리(C군 사면안정)와 한 벌이다. */ export const BERM_TYPE_ID = "berm"; /** * 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다 * (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리). * * 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을 * 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은 * 그 사본만 본다. */ export function bermSpansFromStructures( structures: ReadonlyArray<{ type_id: string; start_m?: number | null; end_m?: number | null; chainage_m?: number | null; options?: Record; }>, ): BermSpan[] { const spans: BermSpan[] = []; for (const item of structures) { if (item.type_id !== BERM_TYPE_ID) continue; const anchor = item.chainage_m ?? item.start_m ?? null; const start = item.start_m ?? anchor; const end = item.end_m ?? anchor; if (start === null || end === null) continue; const options = item.options ?? {}; const number = (key: string, fallback: number): number => { const parsed = Number(options[key]); return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; }; const width = number("width_m", BERM_DEFAULT_WIDTH_M); const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M); if (width <= 0 || interval <= 0) continue; // 폭·간격이 0이면 계단이 없다 spans.push({ start_m: Math.min(start, end), end_m: Math.max(start, end), width_m: width, interval_m: interval, slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG), }); } return spans; } /** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */ export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null { const found = spans.find( (span) => chainageM >= Math.min(span.start_m, span.end_m) - 1e-6 && chainageM <= Math.max(span.start_m, span.end_m) + 1e-6, ); return found ? { width_m: found.width_m, interval_m: found.interval_m, slope_deg: found.slope_deg } : null; } /** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */ export interface RockBoundaryStore { /** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */ offsets: Map; control: RockBoundaryControl; /** 세션값 읽기 — 프로젝트·노선이 정해진 뒤에 부른다. */ load: () => void; /** context(config) 기본값·스텝 반영. */ setDefaults: (defaultOffsetM: number, stepM: number) => void; } /** * 암 경계선 오프셋(측점별) 세션 저장소. * * 서버 재계산 없이 프론트 세션(sessionStorage)에 보관하고, 종횡단 확정 시 * cross_patches로 DB(data.design.rock_boundary_offset_m)에 병합한다. */ export function createRockBoundaryStore(options: { sessionKey: () => string | null; detail: () => SectionDetailResponse | null; refreshCard: (chainageM: number) => void; /** 경계 이동 → 2단계 무릎·단면적 재계산. */ recompute: (chainageM: number) => void; }): RockBoundaryStore { const { sessionKey, detail, refreshCard, recompute } = options; let rockBoundaryDefault = -0.5; let rockBoundaryStep = 0.1; /** * 암 경계선 오프셋은 **0을 넘을 수 없다**(2026-08-02 사용자 지시). 오프셋은 지면선에서 * 아래로 파고든 깊이라, 양수가 되면 경계선이 지표면 위로 떠올라 토사층이 음수가 된다. * DB에 옛 양수값이 남아 있어도 읽는 즉시 0으로 눌러 계산이 뒤집히지 않게 한다. */ const clamp = (value: number): number => Math.min(value, 0); const offsets = new Map(); const key = (chainageM: number): string => chainageM.toFixed(2); function persist(): void { const storageKey = sessionKey(); if (!storageKey) return; try { writeByKey(storageKey, JSON.stringify(Object.fromEntries(offsets))); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } } return { offsets, load(): void { offsets.clear(); const storageKey = sessionKey(); if (!storageKey) return; try { const raw = readByKey(storageKey); if (!raw) return; const parsed = JSON.parse(raw) as Record; Object.entries(parsed).forEach(([chainage, offset]) => { if (Number.isFinite(offset)) offsets.set(chainage, offset); }); } catch { /* 손상된 세션 값은 무시 — 기본값으로 재시작. */ } }, setDefaults(defaultOffsetM: number, stepM: number): void { rockBoundaryDefault = defaultOffsetM; rockBoundaryStep = stepM; }, control: { get stepM() { return rockBoundaryStep; }, get defaultOffsetM() { return rockBoundaryDefault; }, offsetFor: (section) => clamp( offsets.get(key(section.chainage_m)) ?? section.design?.rock_boundary_offset_m ?? rockBoundaryDefault, ), adjust: (chainageM, deltaM) => { const stored = detail()?.cross_sections.find( (section) => Math.abs(section.chainage_m - chainageM) < 0.01, )?.design?.rock_boundary_offset_m; const current = clamp(offsets.get(key(chainageM)) ?? stored ?? rockBoundaryDefault); offsets.set(key(chainageM), clamp(Math.round((current + deltaM) * 100) / 100)); persist(); refreshCard(chainageM); recompute(chainageM); }, reset: (chainageM) => { offsets.set(key(chainageM), rockBoundaryDefault); persist(); refreshCard(chainageM); recompute(chainageM); }, }, }; } /** 측점별 암 절토 경사비 저장소 — 값(Map)과 카드 제어기를 함께 낸다(2026-09-07). */ export interface CutSlopeStore { /** 측점키(누가거리 2자리) → 경사비(1:n 의 n). `buildCrossPatches` 가 그대로 읽는다. * **0 은 「표준값을 씀」**이다 — 되돌리기(↺)가 남기는 값이라, 저장분에 옛 사용자 값이 * 남아 있어도 0 이 그것을 덮어 표준으로 돌려놓는다(지우기만 하면 옛 값이 되살아난다). */ ratios: Map; control: CutSlopeControl; load: () => void; } /** * 측점 하나만 다른 **암 절토 경사** 세션 저장소. * * 암 경계선 저장소와 같은 꼴이다 — 세션(sessionStorage)에 쌓고 [저장]·[확정]에서 * `cross_patches`(`design.cut_slope_ratio_user`)로 정본에 나간다. * * ⚠ 값을 넣으면 **재계산을 부른다**. 경사는 나르는 값이 아니라 기하 입력이라, 값만 바꾸고 * 다시 계산하지 않으면 설계선은 옛 경사로 남고 숫자만 새것이 된다. */ export function createCutSlopeStore(options: { sessionKey: () => string | null; /** 지금 표준 횡단면 설정(세션 편집값 우선) — 되돌릴 자리를 여기서 읽는다. */ standard: () => StandardCrossSection | null; refreshCard: (chainageM: number) => void; /** 경사 변경 → 사면·소단·단면적 재계산. */ recompute: (chainageM: number) => void; }): CutSlopeStore { const { sessionKey, standard, refreshCard, recompute } = options; const ratios = new Map(); const key = (chainageM: number): string => chainageM.toFixed(2); /** 이 측점이 쓰는 표준 경사비 — 지반 프리셋(암/토사)의 값. */ const standardRatio = (section: { design?: { geometry_preset?: string } | null }): number => { const preset = section.design?.geometry_preset === "soil" ? "soil" : "rock"; const group = standard()?.[preset] as { cut_slope_ratio?: number } | undefined; const value = group?.cut_slope_ratio; return typeof value === "number" && value > 0 ? value : 0.4; }; function persist(): void { const storageKey = sessionKey(); if (!storageKey) return; try { writeByKey(storageKey, JSON.stringify(Object.fromEntries(ratios))); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } } return { ratios, load(): void { ratios.clear(); const storageKey = sessionKey(); if (!storageKey) return; try { const raw = readByKey(storageKey); if (!raw) return; const parsed = JSON.parse(raw) as Record; Object.entries(parsed).forEach(([chainage, ratio]) => { // 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다. if (Number.isFinite(ratio) && ratio >= 0) ratios.set(chainage, ratio); }); } catch { /* 손상된 세션 값은 무시 — 표준값으로 재시작. */ } }, control: { standardRatioFor: (section) => standardRatio(section), ratioFor: (section) => { const session = ratios.get(key(section.chainage_m)); if (session === 0) return standardRatio(section); // 되돌림 — 저장분보다 세션이 먼저다. if (typeof session === "number" && session > 0) return session; const stored = (section.design as { cut_slope_ratio_user?: number } | undefined) ?.cut_slope_ratio_user; if (typeof stored === "number" && stored > 0) return stored; return standardRatio(section); }, set: (chainageM, ratio) => { // null = 되돌리기. 지우지 않고 0 을 남겨야 저장분에 있던 옛 값까지 표준으로 돌아간다. if (ratio === null || !Number.isFinite(ratio) || ratio <= 0) ratios.set(key(chainageM), 0); else ratios.set(key(chainageM), Math.round(ratio * 10000) / 10000); persist(); refreshCard(chainageM); recompute(chainageM); }, }, }; } /** [저장]·[확정]이 함께 쓰는 페이지 상태 창구. */ export interface SectionPersistContext { projectId: string | null; routeId: () => number | null; detail: () => SectionDetailResponse | null; context: () => SectionContextResponse | null; standardValues: () => StandardCrossSection | undefined; /** 조정창 구간값을 정본으로 내보낸다(세션 → 서버). */ flushCulvertOptions: () => Promise; /** 측점별 편집분의 출처 묶음 — 세션·제어기에 흩어진 값을 페이지가 모아 준다. */ patchSources: () => CrossPatchSources; /** 저장 직전 **전 측점을 다시 계산**한다(표준단면·계획선 최신값으로). * ⚠ 없으면 옛 면적이 그대로 실려 나간다 — 2026-09-09 실측: 표준단면 암 측구 상폭을 * 0.69 → 0.9 로 고친 뒤 저장했더니 정본 한 줄에 **새 측구 기하(0.9) + 옛 절토 면적 * (0.69 기준 3.33㎡)** 이 섞여 남았다. 만진 측점만 새 값으로 서던 자리다. */ reconcileDesigns: () => Promise; } /** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */ export function collectSectionEdits(ctx: SectionPersistContext): { crossPatches: CrossSectionPatch[]; massHaul: Record | undefined; } { const crossPatches = buildCrossPatches(ctx.patchSources()); const detail = ctx.detail(); // 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산해 싣는다 // (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). 카드 그리기가 고쳐 둔 값에 // 기대면 화면에 안 뜬 측점이 표준값으로 남는다. const byChainage = new Map(crossPatches.map((patch) => [patch.chainage_m, patch])); const patchFor = (chainageM: number): CrossSectionPatch => { const existing = byChainage.get(chainageM); if (existing) return existing; const created: CrossSectionPatch = { chainage_m: chainageM }; byChainage.set(chainageM, created); crossPatches.push(created); return created; }; // 카드 버튼 선택(지반유형·단면유형·측구·포장·2단 비탈) — 세션 초안이 정본으로 나가는 // 유일한 길이다(2026-09-06 사용자 확정: 버튼을 누를 때 서버가 저장하지 않는다). crossDesignChoices(ctx.projectId, ctx.routeId()).forEach((choice, chainage) => { const patch = patchFor(chainage); patch.ground_type = choice.ground_type; patch.section_mode = choice.section_mode; if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side; if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type; if (choice.paved !== undefined) patch.paved = choice.paved; if (choice.two_stage_slope !== undefined) patch.two_stage_slope = choice.two_stage_slope; // ⚠ 「자동」(null)은 patch 로 못 보낸다 — 서버 병합이 최상위 null 을 걷는다. 카드 토글은 // 자동으로 되돌아가지 않으므로(켬/끔 둘 뿐) 지금은 손해가 없다(2026-09-09). if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice; }); if (detail) { // 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다 // (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). applyStructureAreaRows(detail.cross_sections, structureAreaRows(detail.cross_sections)); // 면적은 **전 측점**을 싣는다 — 브라우저가 만든 값이 곧 작업본이다. 카드 버튼을 // 바꾸면 구조물이 없는 측점의 면적도 달라지므로 구조물 측점만 보내면 수량이 어긋난다. for (const section of detail.cross_sections) { const design = section.design as Record | undefined; if (!design) continue; let patch: CrossSectionPatch | null = null; for (const key of STRUCTURE_ROW_KEYS) { if (typeof design[key] !== "number") continue; patch = patch ?? patchFor(section.chainage_m); patch[key] = design[key] as number; } } } // 유토곡선 **정본은 서버가 낸다**(2026-09-06 사용자 확정) — 저장 뒤 `recompute_server_side` // 가 Node 로 다시 계산해 덮어쓴다. 그래서 여기서는 곡선도 배분도 만들지 않는다. // 서버가 만들 수 없는 것 하나만 보낸다: 사용자가 끌어 옮긴 balloon 위치(화면값). const offsets = balloonOffsetsPayload(); return { crossPatches, massHaul: offsets ? { balloon_offsets: offsets } : undefined, }; } /** 계획선 편집 초안이 있으면 종단 정본에 쓰고 초안을 지운다. 없으면 아무 일도 하지 않는다. */ async function flushAlignmentDraft(projectId: string, routeId: number | null): Promise { if (routeId === null) return; const draft = readAlignmentDraft(routeId); if (!draft) return; await saveProfileAlignment(projectId, routeId, draft); clearAlignmentDrafts(); } /** 세션에 쌓인 조정창·구조물 조작을 정본으로 내보낸다 — [저장]·[확정] 공통 앞단. */ async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise { // ⚠ 순서는 **B05 [임시저장]과 같아야 한다**(2026-09-12 사용자: 어느 페이지에서 저장해도 // 결과가 같아야 한다). 관 목록은 B05 가 **전체 스냅샷**으로, B06 이 **바뀐 것만**(추가· // 삭제·이동·구간값) 담으므로, 스냅샷을 먼저 얹고 그 위에 델타를 적용해야 한다. 반대로 // 하면 스냅샷이 B06 편집을 통째로 덮는다. await flushPendingPipes(projectId).catch((error) => { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`배수관 저장에 실패했습니다.${detail}`, "error"); }); // 조정창 구간값·추가·삭제·이동은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다 // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). await ctx.flushCulvertOptions(); // B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만 // 실려, B06에서 저장·확정하면 세션에만 남아 옛 방향이 정본에 그대로 있었다 // (2026-09-06). 서버가 종단 정본과 저장된 횡단 설계를 함께 갱신하므로 아래 // 횡단 patch 저장보다 **먼저** 나가야 사용자 수정이 위에 얹힌다. await flushUphillOverrides(projectId).catch(() => undefined); // B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단 // 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다 // (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다). // 계획선 편집(▲/▼)은 세션 초안에만 있다 — B06 에서 고쳤든 B05 에서 고쳤든 여기서 // 종단 정본으로 내보낸다(2026-09-12). 종전에는 B06 이 초안을 **읽기만** 해서, B06 에서 // 저장하면 계획선 편집이 다음 진입 때 사라졌다. await flushAlignmentDraft(projectId, ctx.routeId()).catch((error) => { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`계획선 저장에 실패했습니다.${detail}`, "error"); }); await flushPendingStructures(projectId).catch((error) => { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`구조물 저장에 실패했습니다.${detail}`, "error"); }); } /** 임시 저장 — 저장만 하고 페이지는 그대로 둔다. */ export async function saveCurrentSections(ctx: SectionPersistContext): Promise { const routeId = ctx.routeId(); if (!ctx.projectId || routeId === null) return; showLoadingOverlay(); try { // ⚠ 면적을 모으기 **전에** 전 측점을 다시 계산한다 — 안 그러면 표준단면을 고친 뒤 // 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측). await ctx.reconcileDesigns(); await flushPendingEdits(ctx, ctx.projectId); const edits = collectSectionEdits(ctx); await saveSections( ctx.projectId, routeId, ctx.standardValues(), edits.crossPatches.length ? edits.crossPatches : undefined, edits.massHaul, ); // 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을 // 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다 // (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음). invalidateSectionDetail(ctx.projectId, routeId); showToast(L("B06_Profile_Save_Success"), "success"); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error"); } finally { hideLoadingOverlay(); } } /** 확정 — 저장 뒤 다음 단계(상세설계)로 넘어간다. */ export async function confirmCurrentSections(ctx: SectionPersistContext): Promise { const routeId = ctx.routeId(); if (!ctx.projectId || routeId === null) return; showLoadingOverlay(); try { await ctx.reconcileDesigns(); // 저장과 같은 이유 — 옛 면적이 정본으로 굳는 것을 막는다 await flushPendingEdits(ctx, ctx.projectId); const edits = collectSectionEdits(ctx); await confirmSections( ctx.projectId, routeId, ctx.standardValues(), edits.crossPatches.length ? edits.crossPatches : undefined, edits.massHaul, ); showToast(L("B06_Profile_Confirm_Success"), "success"); goToWorkflowStage(ctx.projectId, WORKFLOW_STEP_ROUTES[4]); } catch (error) { const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed"); showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error"); } finally { hideLoadingOverlay(); } }