/* ============================================================================= * A00_Common/design/design_save.ts * B05 · B06 **저장 한 줄기**(계획서 24-1 · 472-6) — [저장] · [확정]이 어느 보기에서 눌려도 같은 한 본문. * * (노선) → 페이지 설정 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단 * * 차례 조각(세션 초안 → 본문 + 저장 뒤 정리)을 모아 서버 한 창구(`POST /design/save`)에 한 번 — 서버가 차례대로 * 안쪽 창구를 부르고 전부 저장하거나 아무것도 안 바꿈(472-6 6-A). 서버가 다 저장한 뒤에만 조각 정리 · 실패면 * 초안 그대로(다음 [저장]에서 다시) · 차례 이름째 알림. * * 노선(346-3 · 6-B) — 노선 초안이 마지막 저장과 다르면 같은 본문에 `route`(계약 `RouteDraft`) · `carry`(옛 노선 * 세션 횡단 고침 원본)를 실음. 서버가 맨 앞에서 노선 저장(관 · 구조물 · 추가 측점 좌표 따라감 · 종횡단 · 계획고 * 이월) → 관 · 구조물 초안 다시 얹기 → 새 노선으로 위 차례 → 횡단 = 세션 고침을 측점 열쇠로 넘긴 patch(같은 TS · * Node). 이 때 조각은 기본 조각만(화면 몫 일 = 옛 노선 판이라 건너뜀) · 계획고 초안 = 노선 초안 칸 ?? 옛 노선 칸 · * 손 상단측은 안 보냄(요구 274) · [확정]도 [저장]으로(새 노선을 본 뒤 다시 [확정]) · 다 저장하면 페이지 다시 세움. * 노선 모양이 바뀌면 = 초기화(492 · 요구 284 · 285) — 잠금 막 앞 확인 창(지워질 수 · [취소] = 요청 0) · 서버가 * 시설 · 손 입력을 버리고 다시 셈(응답 `route_reset`) → 알림 한 줄 · 세션 초안을 통째 비우고 서버 정본으로 다시 세움. * * ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은 * 횡단 patch 보다 먼저 나가야 사용자 수정이 맨 위에 얹힌다(2026-09-06 · 09-12) — 차례 = 서버 한 곳. * 영구저장은 여기서만 한다(CLAUDE.md 5장 — 자동저장 없음). * ========================================================================== */ import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { pipesPart } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { structuresPart } from "../../B05_Profile/B05_Profile_Api_Structures"; import { outsideNotice, plusStationsPart } from "../../B05_Profile/B05_Profile_Api_PlusStations"; import { routePartsBody } from "../../B05_Profile/B05_Profile_Api_Fetch"; import type { RouteReplanResponse } from "../../B05_Profile/B05_Profile_Api_Replan"; import { markAlignmentSaved, pendingAlignment, } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { culvertOptionsPart } from "../../B06_Section/B06_Section_Api_Culvert_Options"; import { pageSettingsPart } from "../../B05_Profile/B05_Profile_Api_PageSettings"; import { DesignSaveError, postDesignSave, SAVE_PART_KEYS, type SavePart, type SavePartKey, type SaveStepKey, } from "./design_save_api"; import { clearDrafts, clearRouteDrafts, clearState, stateKey } from "../b_page_state"; import { pendingRouteSave, rebuildAfterRouteSave, routeCarryPart, routeSaved, routeSaveSummary, } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply"; import type { RouteSaveBody } from "../../B05_Profile/B05_Profile_Api_Replan"; import { confirmRouteReset } from "../../B05_Profile/B05_Profile_UI_RouteReset"; import { routeResetNotice } from "../../B05_Profile/logic/B05_Profile_Route_Reset"; export interface DesignSaveContext { projectId: string; routeId: () => number | null; /** 저장 앞 준비 — 면적을 모으기 전 전 측점 재계산(B06). */ prepare?: () => Promise; /** * 한 본문 차례 조각을 보기가 고침(472-6) — 받는 값 = 기본 조각(세션 초안 → 본문 · 없으면 null) · * 돌려주는 조각이 그 차례 본문 · 저장 뒤 정리(보기 화면 맞춤을 덧붙이거나 보기 목록으로 갈음). */ parts?: Partial>; /** 횡단 조각 — 보기마다 본문이 다르다(표준 횡단 · 카드 고침 · 유토). */ crossPart: (confirm: boolean) => Promise; /** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */ done?: (confirm: boolean) => Promise | void; /** 멈춘 저장을 알린다. */ failed: (error: unknown, confirm: boolean) => void; } export type PartOverride = ( base: SavePart | null, confirm: boolean, ) => Promise | SavePart | null; const saving = new Set(); /** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */ export function savingDesign(projectId: string | null): boolean { return projectId !== null && saving.has(projectId); } /** 차례 이름 — 실패하면 그 차례 이름째 알림(429 ⑨ · 초안은 세션에 남아 다음 [저장]에서 다시 나감). */ const STEP_LABEL: Record = { route: "노선", settings: "페이지 설정", pipes: "배수관", structures: "구조물", plusStations: "추가 측점", alignment: "계획선", culvertOptions: "암거 옵션", uphill: "상단측", cross: "횡단", }; /** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */ export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] }): void { const dropped = saved.dropped_edit_chainages ?? []; if (!dropped.length) return; const where = dropped .slice(0, 5) .map((value) => `${value.toFixed(2)}m`) .join(" · "); showToast( `계획고 편집 ${dropped.length}곳을 지웠습니다 — 측점 · 관 · 구조물이 없는 자리: ${where}` + (dropped.length > 5 ? ` 외 ${dropped.length - 5}곳` : ""), "info", ); } /** 노선 초안 동안의 계획고 초안 칸 — `design_pipeline_page` 의 `DRAFT_ROUTE_ID` 와 같은 값(sub7). */ const DRAFT_ROUTE_ID = 0; /** 계획고 조각 — 노선이 바뀌면 노선 초안 칸(`DRAFT_ROUTE_ID`) ?? 옛 노선 칸의 초안(서버가 새 노선 번호로 고쳐 * 씀 · 측점이 없는 자리는 버리고 알림 · 213-2) · 저장 뒤 두 칸 다 비움. */ function alignmentPart(routeId: number, moved: boolean): SavePart | null { const draft = (moved ? pendingAlignment(DRAFT_ROUTE_ID) : null) ?? pendingAlignment(routeId); if (!draft) return null; return { body: { route_id: routeId, ...draft }, after: (response) => { if (response) noticeDroppedEdits(response); markAlignmentSaved(routeId); if (moved) markAlignmentSaved(DRAFT_ROUTE_ID); }, }; } /** 한 본문 기본 조각 — 세션 초안 그대로(옛 기본 창구와 같은 본문). */ async function defaultPart( key: Exclude, projectId: string, routeId: number, confirm: boolean, moved: boolean, ): Promise { switch (key) { case "settings": // 노선 뒤 — 노선 저장이 새로 만든 종단에도 경고 기준이 얹힘(448) return pageSettingsPart(projectId); case "pipes": return pipesPart(projectId, confirm); case "structures": return structuresPart(projectId); case "plusStations": // 계획고 앞 — 추가 측점 자리의 계획고 편집이 버려지지 않게(368-2) return plusStationsPart(projectId); case "alignment": return alignmentPart(routeId, moved); case "culvertOptions": return culvertOptionsPart(stateKey("culvertopt", projectId, routeId)); case "uphill": if (moved) return null; // 노선이 바뀌면 손 상단측은 안 보냄 · 새 지형이 정함(요구 274 · 횡단 조각이 비움) return { body: await routePartsBody(projectId), after: () => clearState("uphill", projectId), }; } } /** 서버 실패 → 화면 글 — 차례 이름째 · 모두 그대로 · 되돌림마저 실패면 「복구 확인 필요」. */ function oneBodyError(error: unknown): Error { if (!(error instanceof DesignSaveError)) return error instanceof Error ? error : new Error(String(error)); if (!error.restored) return new Error(`저장 실패 · 복구 확인 필요 — ${error.message}`); const where = error.step ? `${STEP_LABEL[error.step]} 차례에서 ` : ""; return new Error(`${where}실패 — 아무것도 저장하지 않았습니다(모두 그대로). ${error.message}`); } /** 노선 저장 결과 알림 — 초기화면 지운 수 한 줄(492) · 아니면 노선 밖 관 · 구조물 · 따라감 실패 · 결과 한 줄. */ function noticeRouteSaved(saved: RouteReplanResponse, carryOutside: number): void { const reset = routeResetNotice(saved.route_reset, saved.pipes?.crossing ?? 0); if (reset) return showToast(reset, "info"); const outside = (saved.pipes?.outside ?? 0) + (saved.structures?.outside ?? 0); if (outside) showToast(outsideNotice(outside), "warning"); if (saved.follow_failed?.length) showToast( `${saved.follow_failed.join(" · ")} 따라감 실패 — 옛 누가거리 그대로입니다.`, "error", ); showToast(routeSaveSummary(saved, carryOutside), "info"); } /** * [저장] · [확정] — 세션에 쌓인 조작을 서버 한 창구에 한 번(472-6). 노선이 없으면 아무 일도 하지 않고 false · * 다 저장하면 true. 서버가 다 저장한 뒤에만 조각 정리(초안 비움 · 화면 맞춤) · 실패면 초안 그대로. */ export async function saveDesign( ctx: DesignSaveContext, options: { confirm?: boolean } = {}, ): Promise { const confirm = options.confirm ?? false; const routeId = ctx.routeId(); if (routeId === null) return false; const route: RouteSaveBody | null = await pendingRouteSave(ctx.projectId); // 노선 모양이 바뀐 [저장] = 초기화(492-2) — 잠금 막 앞에서 지워질 수를 물음 · [취소] = 요청 0 if (route && !(await confirmRouteReset(ctx.projectId, route.draft))) return false; showLoadingOverlay(); saving.add(ctx.projectId); try { await ctx.prepare?.(); const parts: Partial> = {}; // 노선이 바뀌면 횡단 = 옛 노선 세션 고침 원본(서버가 새 측점에 넘김) · 화면 몫 조각은 옛 노선 판이라 안 씀 const carried = route ? routeCarryPart(ctx.projectId, routeId) : null; for (const key of SAVE_PART_KEYS) { if (key === "cross") { parts.cross = carried ? carried.cross : await ctx.crossPart(confirm); continue; } const base = await defaultPart(key, ctx.projectId, routeId, confirm, !!route); const own = route ? undefined : ctx.parts?.[key]; parts[key] = own ? await own(base, confirm) : base; } const result = await postDesignSave( ctx.projectId, routeId, confirm, parts, route ? { route, carry: carried?.carry } : {}, ); if (result.waited) showToast("앞 저장이 끝나기를 기다린 뒤 저장했습니다.", "info"); if (route) routeSaved(ctx.projectId); // 가게 노선 초안 · 조회 결과 캐시 · 편집기 「저장됨」 for (const key of SAVE_PART_KEYS) await parts[key]?.after?.(result.steps[key]); clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32) saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림 if (!route) { await ctx.done?.(confirm); return true; } const saved = (result.steps.route ?? {}) as unknown as RouteReplanResponse; noticeRouteSaved(saved, Number(result.steps.carry?.outside ?? 0)); // 초기화 — 세션 초안(시설 · 계획고 · 횡단 캐시)을 비워 서버 정본으로 다시 세움(추천 [적용]과 같음) if (saved.route_reset?.reset) clearDrafts(ctx.projectId); // 새 노선 — [확정]은 새 노선을 본 뒤 다시(단계 닫기는 새 횡단 본문이 있어야 함) · 페이지 다시 세움. if (confirm) showToast("노선을 저장했습니다 — 새 노선을 확인한 뒤 다시 [확정]하세요.", "info"); else await ctx.done?.(false); await rebuildAfterRouteSave(ctx.projectId); // 옛 노선 칸 · 초안 전 hold = 서버가 새 노선에 넘겼음 — 쓰레기만 비움(넘김 규칙 그대로 · 세션 넘침 388-3) if (saved.route_id !== routeId) clearRouteDrafts(ctx.projectId, routeId); clearState("route-draft-hold", ctx.projectId); return true; } catch (error) { ctx.failed(oneBodyError(error), confirm); return false; } finally { saving.delete(ctx.projectId); hideLoadingOverlay(); } }