- 1 가림막 저장(확정)도 도중 고침 검사 · 「저장 중 고친 값」 알림(키는 안 막음 · B07~B09 안 번짐) - 2b snapOptions 간격 = 10 만 · 옛 초안 0.1 · 1 m = null(load_station_snap_step 거울) - 3 추가 측점 = 측점 묶음 data-plus · B05 누름 · 가까이 표시가 그 속성으로 - 4d 숨은 동안 고른 카드 = 다음 다시 그림 끝에 밂 · 휠 · 누름이면 버림 - 7 pipe_calc set 다섯 = 풀개 · 횡단 · 종단 보기 onDispose - 8 저장 중 [초기화] = [저장]과 같은 알림 + 막음 · /route/reset = 프로젝트 잠금 안 - 9 isUserPick 거름 = 묶음 띄운 그 누름만 · 계산 중 새 누름 = 폄 - 시험 test_505_2_save_pick 새 · 398_1 busyPick 1 · 390_5 글 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
306 lines
15 KiB
TypeScript
306 lines
15 KiB
TypeScript
/* =============================================================================
|
|
* 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) · [확정]도 [저장]으로(새 노선을 본 뒤 다시 [확정]) · 다 저장하면 페이지 다시 세움.
|
|
*
|
|
* ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은
|
|
* 횡단 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 { 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 { clearRouteDrafts, clearState, stateKey } from "../b_page_state";
|
|
import {
|
|
pendingRouteSave,
|
|
rebuildAfterRouteSave,
|
|
routeCarryPart,
|
|
routeSaved,
|
|
} from "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply";
|
|
import type { RouteSaveBody } from "../../B05_Profile/B05_Profile_Api_Replan";
|
|
import { designSignature, markDesignClean } from "./design_leave";
|
|
|
|
export interface DesignSaveContext {
|
|
projectId: string;
|
|
routeId: () => number | null;
|
|
/** 저장 앞 준비 — 면적을 모으기 전 전 측점 재계산(B06). */
|
|
prepare?: () => Promise<void>;
|
|
/**
|
|
* 한 본문 차례 조각을 보기가 고침(472-6) — 받는 값 = 기본 조각(세션 초안 → 본문 · 없으면 null) ·
|
|
* 돌려주는 조각이 그 차례 본문 · 저장 뒤 정리(보기 화면 맞춤을 덧붙이거나 보기 목록으로 갈음).
|
|
*/
|
|
parts?: Partial<Record<SavePartKey, PartOverride>>;
|
|
/** 횡단 조각 — 보기마다 본문이 다르다(표준 횡단 · 카드 고침 · 유토). */
|
|
crossPart: (confirm: boolean) => Promise<SavePart | null>;
|
|
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. `reload` 거짓 = 알림만(노선 바뀐 저장 =
|
|
* 곧 다시 세움 · 옛 노선 id 로 다시 읽기는 헛일 · 498-3). */
|
|
done?: (confirm: boolean, reload?: boolean) => Promise<void> | void;
|
|
/** 멈춘 저장을 알린다. */
|
|
failed: (error: unknown, confirm: boolean) => void;
|
|
/** 노선 모양 바뀐 채 확정 — 노선 · 고침은 저장됨 · 페이지 다시 세우기 앞에 물음(요구 300 B2). */
|
|
routeMoved?: () => Promise<void>;
|
|
}
|
|
|
|
export type PartOverride = (
|
|
base: SavePart | null,
|
|
confirm: boolean,
|
|
) => Promise<SavePart | null> | SavePart | null;
|
|
|
|
const saving = new Set<string>();
|
|
/** 프로젝트마다 지금 도는 저장 한 번(498-3 D1) — 확정인가 · 끝 약속. */
|
|
const inflight = new Map<string, { confirm: boolean; done: Promise<unknown> }>();
|
|
/** 저장 중 막음 알림 — [저장] · [초기화] 같은 글(505-2). */
|
|
export const SAVE_BUSY_TEXT = "이전 수정을 저장 중입니다. 잠시 후 다시 해 주세요.";
|
|
|
|
/** 저장이 도는 중인가(498-3) — 뒤에서 도는 [저장] 포함. */
|
|
export function designSaveBusy(projectId: string): boolean {
|
|
return inflight.has(projectId);
|
|
}
|
|
|
|
/**
|
|
* 저장 한 번을 줄 세움(요구 302 D1) — 저장 중 [저장] = 막음 + 알림 · 저장 중 확정 = 잠그고 앞 저장 끝을 기다린 뒤
|
|
* 지금 화면으로(서버 한 본문 저장은 도중에 안 끊음) · 확정 중 또 확정 = 막음. 막으면 false.
|
|
*/
|
|
export async function queueDesignSave<T>(
|
|
projectId: string,
|
|
confirm: boolean,
|
|
job: () => Promise<T>,
|
|
): Promise<T | false> {
|
|
const before = inflight.get(projectId);
|
|
if (before && (!confirm || before.confirm)) {
|
|
showToast(SAVE_BUSY_TEXT, "warning");
|
|
return false;
|
|
}
|
|
if (before) showLoadingOverlay();
|
|
const run = (async () => {
|
|
try {
|
|
await before?.done.catch(() => undefined);
|
|
return await job();
|
|
} finally {
|
|
if (before) hideLoadingOverlay();
|
|
}
|
|
})();
|
|
const entry = { confirm, done: run };
|
|
inflight.set(projectId, entry);
|
|
try {
|
|
return await run;
|
|
} finally {
|
|
if (inflight.get(projectId) === entry) inflight.delete(projectId);
|
|
}
|
|
}
|
|
|
|
/** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */
|
|
export function savingDesign(projectId: string | null): boolean {
|
|
return projectId !== null && saving.has(projectId);
|
|
}
|
|
|
|
/** 차례 이름 — 실패하면 그 차례 이름째 알림(429 ⑨ · 초안은 세션에 남아 다음 [저장]에서 다시 나감). */
|
|
const STEP_LABEL: Record<SaveStepKey, string> = {
|
|
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<SavePartKey, "cross">,
|
|
projectId: string,
|
|
routeId: number,
|
|
confirm: boolean,
|
|
moved: boolean,
|
|
): Promise<SavePart | null> {
|
|
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}`);
|
|
}
|
|
|
|
/** 노선 저장 결과 알림 — 따라감 실패 · 화면에 없던 새 교차 관만(요구 298-3 · 결과 한 줄 · 노선 밖 되풀이 지움). */
|
|
function noticeRouteSaved(saved: RouteReplanResponse): void {
|
|
if (saved.follow_failed?.length)
|
|
showToast(
|
|
`${saved.follow_failed.join(" · ")} 따라감 실패 — 옛 누가거리 그대로입니다.`,
|
|
"error",
|
|
);
|
|
const crossing = saved.pipes?.crossing ?? 0;
|
|
if (crossing) showToast(`새 교차 관 ${crossing}개 — 새 세류선 교차점에 더했습니다.`, "info");
|
|
}
|
|
|
|
/**
|
|
* [저장] · [확정] — 세션에 쌓인 조작을 서버 한 창구에 한 번(472-6). 노선이 없으면 아무 일도 하지 않고 false ·
|
|
* 다 저장하면 true. 서버가 다 저장한 뒤에만 조각 정리(초안 비움 · 화면 맞춤) · 실패면 초안 그대로.
|
|
* 잠금(요구 302 D1) = 확정 · 노선 모양 바뀐 [저장]만 — 그 밖 [저장]은 뒤에서 · 저장 도중 고쳤으면 조각 정리 ·
|
|
* 깨끗한 표를 건너뜀(초안이 그대로 남아 다음 [저장] · [>]에서 다시 나감 · 화면 다시 읽기는 함).
|
|
*/
|
|
export async function saveDesign(
|
|
ctx: DesignSaveContext,
|
|
options: { confirm?: boolean } = {},
|
|
): Promise<boolean> {
|
|
const confirm = options.confirm ?? false;
|
|
const routeId = ctx.routeId();
|
|
if (routeId === null) return false;
|
|
saving.add(ctx.projectId);
|
|
let locked = confirm;
|
|
if (locked) showLoadingOverlay();
|
|
try {
|
|
const route: RouteSaveBody | null = await pendingRouteSave(ctx.projectId);
|
|
if (route && !locked) {
|
|
showLoadingOverlay(); // 노선 모양 바뀐 [저장] = 잠금 · 다시 세움(D1)
|
|
locked = true;
|
|
}
|
|
await ctx.prepare?.();
|
|
const parts: Partial<Record<SavePartKey, SavePart | null>> = {};
|
|
// 노선이 바뀌면 횡단 = 옛 노선 세션 고침 원본(서버가 새 측점에 넘김) · 화면 몫 조각은 옛 노선 판이라 안 씀
|
|
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 sent = designSignature(ctx.projectId); // 본문을 낸 때 고침 — 끝에 견줌(498-3)
|
|
const result = await postDesignSave(
|
|
ctx.projectId,
|
|
routeId,
|
|
confirm,
|
|
parts,
|
|
route ? { route, carry: carried?.carry } : {},
|
|
);
|
|
if (result.waited) showToast("앞 저장이 끝나기를 기다린 뒤 저장했습니다.", "info");
|
|
// 저장 도중 고침(가림막 동안 키 · 되돌리기 포함 · 505-2) — 조각 정리가 그 고침까지 비우므로 건너뜀(서버는 낸
|
|
// 본문을 저장함) · 노선 저장은 다시 세움이 맡음
|
|
// ponytail: 고침 하나라도 있으면 조각 정리를 통째 건너뜀 · 다음 저장이 초안 전부를 다시 냄
|
|
const touched = !route && designSignature(ctx.projectId) !== sent;
|
|
if (touched && locked)
|
|
showToast("저장 중 고친 값은 아직 저장 안 됨 — 다시 [저장]해 주세요.", "warning");
|
|
if (route) routeSaved(ctx.projectId); // 가게 노선 초안 · 조회 결과 캐시 · 편집기 「저장됨」
|
|
if (!touched) 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) {
|
|
if (!touched) markDesignClean(ctx.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4)
|
|
await ctx.done?.(confirm);
|
|
return true;
|
|
}
|
|
const saved = (result.steps.route ?? {}) as unknown as RouteReplanResponse;
|
|
noticeRouteSaved(saved);
|
|
// 새 노선 — 확정은 새 횡단 본문이 있어야 함 → 물음(머묾 / 새 노선으로 확정 뒤 이동) · 페이지 다시 세움.
|
|
if (confirm) {
|
|
hideLoadingOverlay(); // 물음 동안 가림막을 걷음
|
|
await ctx.routeMoved?.();
|
|
showLoadingOverlay();
|
|
} else {
|
|
// 옛 페이지 두 보기 다시 읽기는 헛일(곧 다시 세움 · 옛 노선 id) — 떠남 표 · 알림만(298-3)
|
|
markDesignClean(ctx.projectId);
|
|
await ctx.done?.(false, 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);
|
|
if (locked) hideLoadingOverlay();
|
|
}
|
|
}
|