fix(B07): 편집이 사라지지 않게 하고 확정한 도면을 잠근다
사용자 편의성 점검 15건 중 반영 확정분 12건. 편집 손실 방지 - CAD에 미저장 표시(drawingDirty)를 두고, 도면을 바꾸기 전에 묻는다. 전에는 그은 선이 경고 없이 사라졌다(실측: 캔버스 서명이 원본과 동일). - 도면 전환을 겹쳐 눌러도 늦게 온 응답이 화면을 덮지 않게 가드를 뒀다. - 도면층 삭제·객체 이동을 되돌리기에 실어 Ctrl+Z로 돌아오게 했다. 확정한 도면은 읽기 전용 - runCommand 한 곳에서 보는 명령만 통과시킨다(허용 명시 방식). - 도면을 실을 때 앞 도면의 그리기 도구를 내린다 — 켜 둔 도구가 확정본에도 계속 그렸다. - 확정 버튼 자리를 [현재 도면 확정] / [수정]으로 가른다. 확정 해제는 [수정] 한 곳뿐 — 되돌리기·색 고르기로 확정이 풀리던 경로를 없앴다. 그 밖 - 잠금 도면층(도각·등고선·계류)은 마우스가 스쳐도 하이라이트하지 않는다. - 준비 중 도면 7종도 빈 도각으로 열린다(눌리지 않는 회색 버튼 제거). - 도면층 행이 잘리지 않게 패널을 300px로 넓히고 이름을 줄여 담는다. - 기본 그리기 색을 검정으로 — 흰 종이에 흰 선이라 안 보였다. - 색 고르는 동안 변경 통지가 연발하지 않는다. - 수량표는 앞 단계 산출물이라 도면에서 고치지 못하게 막고 안내한다. - 자동백업 칸을 도면별로 나눈다 — 되살리면 다른 도면에 붙었다. - 횡단 장 id를 시작 측점 기준으로 바꿔 구간이 달라져도 옛 확정이 안 붙는다. - 회사 도각 저장 전에 좌표를 검사하고, 기본 도각으로 되돌리기를 연다. - 횡단 파일이 없어도 나머지 도면 목록은 남는다. - 불러오는 중·실패를 화면에 표시한다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,9 +44,12 @@ import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
|
||||
|
||||
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
|
||||
interface DesignMeta {
|
||||
kind: "cover" | "cross" | "longitudinal" | "mass_haul" | "watershed";
|
||||
kind: DesignDrawingItem["kind"];
|
||||
/** 도면 식별자 — CAD가 자동백업 칸을 도면별로 나누는 데 쓴다. */
|
||||
drawingId: string;
|
||||
title: string;
|
||||
info: string;
|
||||
/** 확정본은 CAD에서 읽기 전용이 된다. 푸는 길은 [수정] 버튼 하나뿐. */
|
||||
confirmed: boolean;
|
||||
quantityTable: QuantityTable | null;
|
||||
hasPrev: boolean;
|
||||
@@ -80,23 +83,29 @@ const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
|
||||
const CAD_TOAST_MESSAGE = "aislo:b08:toast";
|
||||
const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
|
||||
|
||||
/** 도면 구성 12분류 (2026-08-29 사용자 확정 순서). kind가 없으면 아직 만들지 않는 도면. */
|
||||
/**
|
||||
* 도면 구성 12분류 (2026-08-29 사용자 확정 순서).
|
||||
*
|
||||
* 아직 내용을 만들지 않은 도면은 `blankId`로 서버의 빈 도각 도면에 물린다
|
||||
* (2026-09-01 사용자 지시) — 눌리지 않는 회색 버튼으로 두면 고장난 것처럼 보인다.
|
||||
*/
|
||||
const DRAWING_GROUPS: readonly {
|
||||
label: string;
|
||||
kind?: DesignDrawingItem["kind"];
|
||||
blankId?: string;
|
||||
}[] = [
|
||||
{ label: "표지", kind: "cover" },
|
||||
{ label: "계획평면도(지형)" },
|
||||
{ label: "계획평면도(노선배치도)" },
|
||||
{ label: "계획평면도(배치도)" },
|
||||
{ label: "계획평면도(라이다)" },
|
||||
{ label: "계획평면도(지형)", blankId: "blank_plan_terrain" },
|
||||
{ label: "계획평면도(노선배치도)", blankId: "blank_plan_route" },
|
||||
{ label: "계획평면도(배치도)", blankId: "blank_plan_layout" },
|
||||
{ label: "계획평면도(라이다)", blankId: "blank_plan_lidar" },
|
||||
{ label: "종단면도", kind: "longitudinal" },
|
||||
{ label: "표준 횡단면도" },
|
||||
{ label: "표준 횡단면도", blankId: "blank_cross_standard" },
|
||||
{ label: "횡단면도", kind: "cross" },
|
||||
{ label: "토적도(유토곡선)", kind: "mass_haul" },
|
||||
{ label: "유역도(배수 유역도)", kind: "watershed" },
|
||||
{ label: "표준도" },
|
||||
{ label: "용지도" },
|
||||
{ label: "표준도", blankId: "blank_standard" },
|
||||
{ label: "용지도", blankId: "blank_landuse" },
|
||||
];
|
||||
|
||||
/** B06 확정 산출물 기반 도면 목록 패널. */
|
||||
@@ -144,24 +153,30 @@ function buildDrawingSidePanel(
|
||||
for (const group of DRAWING_GROUPS) {
|
||||
const items = group.kind
|
||||
? drawings.filter((item) => item.kind === group.kind)
|
||||
: [];
|
||||
// 한 장짜리(와 아직 없는 도면)는 컨테이너 없이 버튼 하나로 둔다.
|
||||
: drawings.filter((item) => item.id === group.blankId);
|
||||
// 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다.
|
||||
if (items.length <= 1) {
|
||||
const [drawing] = items;
|
||||
const button = drawing
|
||||
? drawingButton(drawing, group.label)
|
||||
: document.createElement("button");
|
||||
// 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다.
|
||||
if (!drawing) {
|
||||
button.type = "button";
|
||||
button.className = "b07-drawing-button";
|
||||
button.disabled = true;
|
||||
button.title = "준비 중";
|
||||
const placeholder = document.createElement("button");
|
||||
placeholder.type = "button";
|
||||
placeholder.className = "b07-drawing-button";
|
||||
placeholder.disabled = true;
|
||||
placeholder.title = "준비 중";
|
||||
placeholder.dataset.pending = "true";
|
||||
const name = document.createElement("span");
|
||||
name.className = "b07-drawing-button__name";
|
||||
name.textContent = group.label;
|
||||
button.append(name);
|
||||
placeholder.append(name);
|
||||
panel.append(placeholder);
|
||||
continue;
|
||||
}
|
||||
button.dataset.pending = String(!drawing);
|
||||
const button = drawingButton(drawing, group.label);
|
||||
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
|
||||
button.dataset.pending = String(drawing.kind === "blank");
|
||||
if (drawing.kind === "blank")
|
||||
button.title = "준비 중 — 도각만 표시합니다";
|
||||
panel.append(button);
|
||||
continue;
|
||||
}
|
||||
@@ -356,13 +371,35 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CAD에 저장하지 않은 편집이 있는가. CAD가 편집마다 알려 주고, 도면을 싣거나
|
||||
* 저장하면 내려간다. 이 값이 참인 채로 도면을 바꾸면 그은 선이 소리 없이 사라진다
|
||||
* (2026-09-01 실측) — 그래서 바꾸기 전에 묻는다.
|
||||
*/
|
||||
let cadDirty = false;
|
||||
|
||||
/**
|
||||
* 확정·미확정에 따라 버튼 한 자리를 바꾼다 (2026-09-01 사용자 확정).
|
||||
* 미확정이면 [현재 도면 확정], 확정이면 [수정] — 확정을 푸는 유일한 길이다.
|
||||
*/
|
||||
const confirmButton = createButton({
|
||||
label: "현재 도면 확정",
|
||||
variant: "filled",
|
||||
onClick: () => void confirmCurrentDrawing(),
|
||||
onClick: () => {
|
||||
if (currentConfirmed) void reopenCurrentDrawing();
|
||||
else void confirmCurrentDrawing();
|
||||
},
|
||||
});
|
||||
confirmButton.disabled = true;
|
||||
|
||||
const applyConfirmButtonState = (): void => {
|
||||
confirmButton.textContent = currentConfirmed ? "수정" : "현재 도면 확정";
|
||||
confirmButton.title = currentConfirmed
|
||||
? "확정을 풀고 이 도면을 고칩니다."
|
||||
: "이 도면을 확정하고 저장합니다.";
|
||||
confirmButton.disabled = !currentDrawing || currentDrawing.kind === "blank";
|
||||
};
|
||||
|
||||
const findButton = (drawingId: string) =>
|
||||
drawingListEl?.querySelector<HTMLButtonElement>(
|
||||
`.b07-drawing-button[data-drawing-id="${drawingId}"]`,
|
||||
@@ -382,6 +419,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
index: number,
|
||||
): DesignMeta => ({
|
||||
kind: drawing.kind,
|
||||
drawingId: drawing.id,
|
||||
title: drawing.label,
|
||||
info: drawing.kind === "cross" ? drawing.label : "",
|
||||
confirmed: response.confirmed,
|
||||
@@ -451,18 +489,34 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
};
|
||||
|
||||
/** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */
|
||||
const mayDiscardEdits = (): boolean => {
|
||||
if (!cadDirty) return true;
|
||||
return window.confirm(
|
||||
"저장하지 않은 편집이 있습니다.\n" +
|
||||
"지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" +
|
||||
"남기려면 [취소]를 누르고 [현재 도면 확정]으로 저장하세요.",
|
||||
);
|
||||
};
|
||||
|
||||
/** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */
|
||||
let loadInFlight = false;
|
||||
|
||||
const loadDrawing = async (drawing: DesignDrawingItem, index: number) => {
|
||||
if (!projectId) return;
|
||||
if (!projectId || loadInFlight) return;
|
||||
loadInFlight = true;
|
||||
highlightActive(drawing.id);
|
||||
const button = findButton(drawing.id);
|
||||
if (button) button.dataset.loading = "true";
|
||||
cadHost.dataset.loading = "true";
|
||||
cadHost.dataset.error = ""; // 앞선 실패 표시를 지운다
|
||||
try {
|
||||
const response = await requestDrawing(drawing);
|
||||
currentDrawing = drawing;
|
||||
currentIndex = index;
|
||||
currentConfirmed = response.confirmed;
|
||||
confirmButton.disabled = response.confirmed;
|
||||
cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다
|
||||
applyConfirmButtonState();
|
||||
updateInfoPanel(drawing, response);
|
||||
sendLoad(response.drawing, buildMeta(drawing, response, index));
|
||||
} catch (error) {
|
||||
@@ -471,12 +525,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "CAD 도면을 불러오지 못했습니다.";
|
||||
showToast(cadHost.dataset.error, "error");
|
||||
if (currentDrawing) highlightActive(currentDrawing.id);
|
||||
} finally {
|
||||
loadInFlight = false;
|
||||
if (button) button.dataset.loading = "false";
|
||||
}
|
||||
};
|
||||
|
||||
const selectDrawing = (drawing: DesignDrawingItem) => {
|
||||
if (drawing === currentDrawing || !mayDiscardEdits()) return;
|
||||
void loadDrawing(drawing, drawings.indexOf(drawing));
|
||||
};
|
||||
|
||||
@@ -484,6 +542,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
if (currentIndex < 0) return;
|
||||
const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
|
||||
if (target < 0 || target >= drawings.length) return;
|
||||
if (!mayDiscardEdits()) return;
|
||||
void loadDrawing(drawings[target], target);
|
||||
};
|
||||
|
||||
@@ -514,11 +573,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
currentConfirmed = true;
|
||||
currentDrawing.confirmed = true;
|
||||
confirmButton.disabled = true;
|
||||
cadDirty = false; // 저장했다
|
||||
applyConfirmButtonState();
|
||||
drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다.
|
||||
|
||||
const button = findButton(currentDrawing.id);
|
||||
if (button) button.dataset.confirmed = "true";
|
||||
// 저장본을 다시 실어 CAD를 읽기 전용으로 돌린다 — 확정한 도면은 고칠 수 없다.
|
||||
// **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직
|
||||
// 편집이 열린 어긋난 순간이 생긴다.
|
||||
await loadDrawing(currentDrawing, currentIndex);
|
||||
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
|
||||
if (currentDrawing.kind === "cross") {
|
||||
infoPanelHost.replaceChildren(
|
||||
@@ -542,29 +606,37 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const invalidateCurrentDrawing = async () => {
|
||||
if (!projectId || !currentDrawing) return;
|
||||
const wasConfirmed = currentConfirmed;
|
||||
drawingCache.delete(currentDrawing.id);
|
||||
currentConfirmed = false;
|
||||
allDrawingsConfirmed = false;
|
||||
currentDrawing.confirmed = false;
|
||||
confirmButton.disabled = false;
|
||||
const button = findButton(currentDrawing.id);
|
||||
if (button) button.dataset.confirmed = "false";
|
||||
if (wasConfirmed) {
|
||||
try {
|
||||
await invalidateDesignDrawing(projectId, currentDrawing.id);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "도면 확정 상태를 되돌리지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
/**
|
||||
* [수정] — 확정을 풀고 다시 고칠 수 있게 한다. **확정이 풀리는 유일한 길**이다
|
||||
* (2026-09-01 사용자 확정). 예전에는 CAD의 편집 통지가 확정을 풀어서, 되돌리기나
|
||||
* 색 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
|
||||
*/
|
||||
async function reopenCurrentDrawing(): Promise<void> {
|
||||
if (!projectId || !currentDrawing || !currentConfirmed) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await invalidateDesignDrawing(projectId, currentDrawing.id);
|
||||
drawingCache.delete(currentDrawing.id);
|
||||
currentConfirmed = false;
|
||||
allDrawingsConfirmed = false;
|
||||
currentDrawing.confirmed = false;
|
||||
const button = findButton(currentDrawing.id);
|
||||
if (button) button.dataset.confirmed = "false";
|
||||
applyConfirmButtonState();
|
||||
// 확정을 풀면 서버가 원본에서 다시 그린다 — 그 도면을 실어야 편집이 열린다.
|
||||
await loadDrawing(currentDrawing, currentIndex);
|
||||
showToast("확정을 풀었습니다. 고친 뒤 다시 확정하세요.", "info");
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "도면 확정 상태를 되돌리지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const frameEditor = createFrameTemplateEditor({
|
||||
projectId: projectId as string,
|
||||
@@ -589,6 +661,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
drawing?: CadDrawing;
|
||||
quantityTable?: QuantityTable | null;
|
||||
direction?: "prev" | "next";
|
||||
dirty?: boolean;
|
||||
kind?: string;
|
||||
text?: string;
|
||||
actionId?: string;
|
||||
@@ -622,9 +695,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
} else if (message.type === CAD_ERROR_MESSAGE) {
|
||||
cadHost.dataset.error =
|
||||
message.detail ?? "CAD 도면을 표시하지 못했습니다.";
|
||||
cadHost.dataset.loading = "false";
|
||||
showToast(cadHost.dataset.error, "error");
|
||||
} else if (message.type === CAD_CHANGED_MESSAGE) {
|
||||
// 도각을 고치는 중에 온 변경 알림은 도면 편집이 아니다 — 확정을 풀면 안 된다.
|
||||
if (!frameEditor.isEditing()) void invalidateCurrentDrawing();
|
||||
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
|
||||
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
|
||||
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
|
||||
if (!frameEditor.isEditing()) cadDirty = message.dirty !== false;
|
||||
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
|
||||
navigateDrawing(message.direction);
|
||||
} else if (
|
||||
|
||||
Reference in New Issue
Block a user