Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-19 10:52:12 +09:00
34 changed files with 1334 additions and 165 deletions
+37 -13
View File
@@ -23,7 +23,11 @@ import {
toDesignProfile,
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch";
import {
saveProfileAlignment,
type ProfileAlignmentSaveResponse,
} from "../../B05_Profile/B05_Profile_Api_Fetch";
import type { SavePart } from "./design_save_api";
import { hasStaleDesigns } from "../../B06_Section/B06_Section_UI_Section_Common";
import {
blocksMinCover,
@@ -297,6 +301,28 @@ export function createDesignProfile(ctx: DesignProfileContext) {
listedBase(storedBase, detail, planted ? irregularStations : null);
/** 편집 후 다시 그린다 — 길게 누르기(초당 10회)도 한 프레임에 한 번만 그림. */
/** 한 본문 [저장] 조각(472-6) — 편집 없음 · 노선 초안 미리보기면 null · 저장 뒤 서버 선형으로 다시 그림. */
function alignmentSavePart(): SavePart | null {
if (!routeId || !store.dirty() || store === preview) return null; // 노선 초안 미리보기 편집은 안 보냄
return {
body: { route_id: routeId, ...store.edits() },
after: (response) => adoptSaved(response as unknown as ProfileAlignmentSaveResponse),
};
}
function adoptSaved(saved: ProfileAlignmentSaveResponse): void {
noticeDroppedEdits(saved);
const next = saved.profile_alignment as ProfileAlignment | undefined;
if (next?.base_pvi?.length) {
storedBase = toAlignmentBase(next);
base = listed();
alignment = buildAlignment(base!, store.edits());
if (detail) detail.longitudinal.profile_alignment = next;
}
store.markSaved();
draw();
}
function rebuild(): void {
if (base) alignment = buildAlignment(base, store.edits());
pagePipeline.pipelineGrade(projectId, store.edits());
@@ -597,19 +623,17 @@ export function createDesignProfile(ctx: DesignProfileContext) {
},
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
async save(): Promise<void> {
if (!routeId || !store.dirty() || store === preview) return; // 노선 초안 미리보기 편집은 안 보냄
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
noticeDroppedEdits(saved);
const next = saved.profile_alignment as ProfileAlignment | undefined;
if (next?.base_pvi?.length) {
storedBase = toAlignmentBase(next);
base = listed();
alignment = buildAlignment(base!, store.edits());
if (detail) detail.longitudinal.profile_alignment = next;
}
store.markSaved();
draw();
const part = alignmentSavePart();
if (!part) return;
await part.after?.(
(await saveProfileAlignment(projectId, routeId!, store.edits())) as unknown as Record<
string,
unknown
>,
);
},
/** 한 본문 [저장] 조각(472-6) — 편집 없음 · 노선 초안 미리보기면 null · 저장 뒤 서버 선형으로 다시 그림. */
savePart: () => alignmentSavePart(),
clear() {
detail = null;
base = null;
+128 -27
View File
@@ -25,6 +25,7 @@
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import {
flushPendingPipes,
pipesPart,
readPendingPipes,
writePendingPipes,
} from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
@@ -34,17 +35,39 @@ import {
fetchStructures,
flushPendingStructures,
readPendingStructures,
structuresPart,
writePendingStructures,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { flushPlusStations, outsideNotice } from "../../B05_Profile/B05_Profile_Api_PlusStations";
import { flushRouteParts, saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch";
import {
flushPlusStations,
outsideNotice,
plusStationsPart,
} from "../../B05_Profile/B05_Profile_Api_PlusStations";
import {
flushRouteParts,
routePartsBody,
saveProfileAlignment,
} from "../../B05_Profile/B05_Profile_Api_Fetch";
import {
markAlignmentSaved,
pendingAlignment,
writeAlignmentDraft,
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
import { flushPageSettings } from "../../B05_Profile/B05_Profile_Api_PageSettings";
import {
culvertOptionsPart,
flushCulvertOptions,
} from "../../B06_Section/B06_Section_Api_Culvert_Options";
import {
flushPageSettings,
pageSettingsPart,
} from "../../B05_Profile/B05_Profile_Api_PageSettings";
import {
DesignSaveError,
postDesignSave,
SAVE_PART_KEYS,
type SavePart,
type SavePartKey,
} from "./design_save_api";
import { clearState, stateKey } from "../b_page_state";
import {
carryAfterRouteSave,
@@ -75,18 +98,23 @@ export interface DesignSaveContext {
/** 저장 앞 준비 — 면적을 모으기 전 전 측점 재계산(B06). */
prepare?: () => Promise<void>;
/**
* 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다.
* `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(저장분으로 화면만 맞춤 · PUT 없음 · 433-7).
* 한 본문 차례 조각을 보기가 고침(472-6) — 받는 값 = 기본 조각(세션 초안 → 본문 · 없으면 null) ·
* 돌려주는 조각이 그 차례 본문 · 저장 뒤 정리(보기 화면 맞춤을 덧붙이거나 보기 목록으로 갈음).
*/
steps?: Partial<Record<Exclude<DesignSaveStep, "cross" | "route">, () => Promise<void>>>;
/** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */
cross: (confirm: boolean) => Promise<void>;
parts?: Partial<Record<SavePartKey, PartOverride>>;
/** 횡단 조각 — 보기마다 본문이 다르다(표준 횡단 · 카드 고침 · 유토). */
crossPart: (confirm: boolean) => Promise<SavePart | null>;
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */
done?: (confirm: boolean) => Promise<void> | void;
/** 멈춘 저장을 알린다. */
failed: (error: unknown, confirm: boolean) => void;
}
export type PartOverride = (
base: SavePart | null,
confirm: boolean,
) => Promise<SavePart | null> | SavePart | null;
const saving = new Set<string>();
/** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */
@@ -241,6 +269,9 @@ export async function saveDesign(
const confirm = options.confirm ?? false;
const fromRouteId = ctx.routeId();
if (fromRouteId === null) return false;
// 노선 초안이 없으면 한 본문 한 요청(472-6 6-A · 서버가 전부 아니면 없음) · 있으면 옛 차례(6-B 전)
const routeBody = await pendingRouteSave(ctx.projectId);
if (!routeBody) return saveOneBody(ctx, confirm, fromRouteId);
let routeId = fromRouteId;
/** 노선을 새로 저장했으면 그 결과 — 뒤 차례는 새 노선 · 화면 몫(옛 노선 판) 일은 건너뜀. */
let routeSaved: Awaited<ReturnType<typeof saveRouteDraft>> | null = null;
@@ -253,8 +284,7 @@ export async function saveDesign(
for (const step of DESIGN_SAVE_ORDER) {
try {
if (step === "route") {
const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄
if (!body) continue;
const body = routeBody;
const [before, beforeStructures] = await Promise.all([
savedPipesIfDraft(ctx.projectId),
savedStructuresIfDraft(ctx.projectId),
@@ -273,27 +303,15 @@ export async function saveDesign(
continue;
}
if (step === "cross") {
if (!routeSaved) await ctx.cross(confirm);
else {
carried = true;
const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId);
showToast(routeSaveSummary(routeSaved, outside), "info");
}
carried = true;
const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId);
if (routeSaved) showToast(routeSaveSummary(routeSaved, outside), "info");
continue;
}
// 상단측은 옛 누가거리 열쇠 — 노선이 바뀌면 횡단 차례(`carryAfterRouteSave`)가 새 측점으로 옮긴 뒤 보냄(443-2)
if (step === "uphill" && routeSaved) continue;
// 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로.
const own = routeSaved ? undefined : ctx.steps?.[step];
const base = defaultStep(step, ctx.projectId, routeId, fromRouteId, confirm);
const run =
step === "pipes"
? async () => {
await base();
await own?.();
}
: (own ?? base);
await run();
await defaultStep(step, ctx.projectId, routeId, fromRouteId, confirm)();
} catch (error) {
throw stepFailed(step, error); // 그 차례에서 멈춤 · 이름째 알림(429 ⑨)
}
@@ -322,3 +340,86 @@ export async function saveDesign(
hideLoadingOverlay();
}
}
/** 한 본문 기본 조각 — 세션 초안 그대로(옛 기본 창구와 같은 본문). */
async function defaultPart(
key: Exclude<SavePartKey, "cross">,
projectId: string,
routeId: number,
confirm: boolean,
): Promise<SavePart | null> {
switch (key) {
case "settings":
return pageSettingsPart(projectId);
case "pipes":
return pipesPart(projectId, confirm);
case "structures":
return structuresPart(projectId);
case "plusStations":
return plusStationsPart(projectId);
case "alignment": {
const draft = pendingAlignment(routeId);
if (!draft) return null;
return {
body: { route_id: routeId, ...draft },
after: (response) => {
if (response) noticeDroppedEdits(response);
markAlignmentSaved(routeId);
},
};
}
case "culvertOptions":
return culvertOptionsPart(stateKey("culvertopt", projectId, routeId));
case "uphill":
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}`);
}
/** 노선 안 바뀐 [저장] · [확정] — 조각을 모아 서버 한 창구에 한 번(472-6 6-A). 서버가 다 저장한 뒤에만 조각
* 정리(초안 비움 · 화면 맞춤) · 실패면 초안 그대로(다음 [저장]에서 다시). */
async function saveOneBody(
ctx: DesignSaveContext,
confirm: boolean,
routeId: number,
): Promise<boolean> {
showLoadingOverlay();
saving.add(ctx.projectId);
try {
await ctx.prepare?.();
const parts: Partial<Record<SavePartKey, SavePart | null>> = {};
for (const key of SAVE_PART_KEYS) {
if (key === "cross") {
parts.cross = await ctx.crossPart(confirm);
continue;
}
const base = await defaultPart(key, ctx.projectId, routeId, confirm);
const own = ctx.parts?.[key];
parts[key] = own ? await own(base, confirm) : base;
}
const result = await postDesignSave(ctx.projectId, routeId, confirm, parts);
if (result.waited) showToast("앞 저장이 끝나기를 기다린 뒤 저장했습니다.", "info");
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); // 다 나감 — 뒤처리는 새 상세로 그림
await ctx.done?.(confirm);
return true;
} catch (error) {
ctx.failed(oneBodyError(error), confirm);
return false;
} finally {
saving.delete(ctx.projectId);
hideLoadingOverlay();
}
}
+100
View File
@@ -0,0 +1,100 @@
/* =============================================================================
* A00_Common/design/design_save_api.ts
* [저장] · [확정] 한 본문(PLAN 472-6 6-A) — 차례 조각 꼴 · 서버 한 창구 `POST /design/save`.
*
* 차례마다 조각 하나 = 본문(없으면 null · 서버가 그 차례를 건너뜀) + 저장 뒤 정리(초안 비움 · 화면 맞춤).
* 서버는 전부 저장하거나 아무것도 안 바꿈 — 실패면 차례 이름 · 까닭(`restored` 거짓 = 복구 확인 필요).
* ========================================================================== */
import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL } from "@config/config_frontend";
/** 서버 본문 칸 차례 그대로(`B06_Section_Router_DesignSave`). */
export const SAVE_PART_KEYS = [
"settings",
"pipes",
"structures",
"plusStations",
"alignment",
"culvertOptions",
"uphill",
"cross",
] as const;
export type SavePartKey = (typeof SAVE_PART_KEYS)[number];
/** 차례 조각 — `after` 는 서버가 다 저장한 뒤에만(그 차례 응답을 받음). */
export interface SavePart {
body: unknown | null;
after?: (response: Record<string, unknown> | undefined) => Promise<void> | void;
}
const BODY_KEY: Record<SavePartKey, string> = {
settings: "settings",
pipes: "pipes",
structures: "structures",
plusStations: "plus_stations",
alignment: "alignment",
culvertOptions: "culvert_options",
uphill: "uphill",
cross: "cross",
};
export interface DesignSaveResult {
waited: boolean;
steps: Partial<Record<SavePartKey, Record<string, unknown>>>;
}
/** 실패 — 차례 이름(`step`) · 되돌림 됨(`restored`). */
export class DesignSaveError extends Error {
constructor(
message: string,
readonly step: SavePartKey | null,
readonly restored: boolean,
) {
super(message);
}
}
/** 조각 본문을 모아 한 번에 보냄. */
export async function postDesignSave(
projectId: string,
routeId: number,
confirm: boolean,
parts: Partial<Record<SavePartKey, SavePart | null>>,
): Promise<DesignSaveResult> {
const body: Record<string, unknown> = { route_id: routeId, confirm };
for (const key of SAVE_PART_KEYS) {
const part = parts[key];
if (part && part.body !== null && part.body !== undefined) body[BODY_KEY[key]] = part.body;
}
const controller = new AbortController();
// [확정]은 낡은 배수유역을 다시 셈 — 옛 차례들의 시간 한도 합 만큼
const timer = window.setTimeout(() => controller.abort(), API_ANALYSIS_TIMEOUT_MS * 3);
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/design/save`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok)
throw new DesignSaveError(
payload?.message ?? `HTTP ${response.status}`,
(payload?.step as SavePartKey) ?? null,
payload?.restored !== false,
);
return { waited: payload?.waited === true, steps: payload?.steps ?? {} };
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError")
throw new DesignSaveError(
"저장 응답을 시간 안에 못 받았습니다 — 새로고침해 저장됐는지 확인해 주세요.",
null,
false,
);
throw error;
} finally {
window.clearTimeout(timer);
}
}
+26 -15
View File
@@ -27,7 +27,8 @@ import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool"
import { createDesignPanel, type DesignPanel } from "./design_panel";
import { createDesignProfile, type DesignProfile } from "./design_profile";
import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout";
import { saveDesign, type DesignSaveStep } from "./design_save";
import { saveDesign, type PartOverride } from "./design_save";
import type { SavePart, SavePartKey } from "./design_save_api";
import { markDesignClean } from "./design_leave";
import { resetDesign } from "./design_reset";
import type { DesignView } from "./design_dock";
@@ -71,10 +72,10 @@ export interface DesignProfileBinding {
export interface DesignViewSave {
routeId: () => number | null;
prepare?: () => Promise<void>;
steps?: Partial<
Record<Exclude<DesignSaveStep, "cross" | "pipes" | "alignment">, () => Promise<void>>
>;
cross?: (confirm: boolean) => Promise<void>;
/** 한 본문 차례 조각 고침(472-6) — 구조물 · 상단측(종단 보기) 등. */
parts?: Partial<Record<SavePartKey, PartOverride>>;
/** 횡단 조각 — 횡단 보기 몫(표준 횡단 · 카드 고침 · 유토). */
crossPart?: (confirm: boolean) => Promise<SavePart | null>;
/** 저장 뒤 이 보기만의 캐시 비우기 · 다시 읽기(알림 · 다음 단계는 여기서 한다). */
done?: (confirm: boolean) => Promise<void> | void;
}
@@ -399,8 +400,8 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
await shared.ensureView("cross");
const profileSave = shared.bindings.profile?.save;
const crossSave = shared.bindings.cross?.save;
const crossBody = crossSave?.cross;
if (!crossBody) {
const crossPart = crossSave?.crossPart;
if (!crossPart) {
showToast(L(confirm ? "B06_Profile_Confirm_Failed" : "B06_Profile_Save_Failed"), "error");
return;
}
@@ -411,16 +412,26 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
projectId: shared.projectId,
routeId: () => routeIdOf(shared),
prepare: crossSave?.prepare,
steps: {
// 관 정본 PUT 은 세션 초안 한 번(기본 창구) — 배수유역도는 저장분을 다시 받아 화면만 맞춤(433-7 · 판
parts: {
// 관 본문은 세션 초안(기본 조각) — 배수유역도는 저장 뒤 저장분을 다시 받아 화면만 맞춤(433-7 · 판
// 메모리를 다시 보내면 B06 고침 직후 · 판이 안 선 [저장]에서 관을 잃음).
...(side ? { pipes: async () => side.reload(confirm) } : {}),
// 계획고 초안 — 한 벌 종단도가 저장하고 다시 그린다.
...(profile ? { alignment: () => profile.save() } : {}),
...profileSave?.steps,
...crossSave?.steps,
...(side
? {
pipes: (base: SavePart | null) => ({
body: base?.body ?? null,
after: async (response: Record<string, unknown> | undefined) => {
await base?.after?.(response);
await side.reload(confirm);
},
}),
}
: {}),
// 계획고 초안 — 한 벌 종단도가 본문을 내고 저장 뒤 서버 선형으로 다시 그린다.
...(profile ? { alignment: () => profile.savePart() } : {}),
...profileSave?.parts,
...crossSave?.parts,
},
cross: crossBody,
crossPart,
done: async (isConfirm) => {
markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4)
if (isConfirm) {