Files
Aislo/E02_StabGround/E02_StabGround_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 c3fbcb13f1 feat(D · E): 떠남 = 공용 물음 [취소 · 폐기 · 저장] · 캐시 = 등록표 · 프로젝트 = 탭 currentProjectId (PLAN 572-3 · 요구 400~403)
- D02 · D03 · E02 말없이 저장 → mountGroupStep {dirty, save, discard} 공용 물음
- D04 · E05 · E06 가드 새로 · E03 = 대표 단면 별 저장 진행 중일 때만 물음
- D02 · E02~E07 세션 직접 읽기 · 쓰기 → 등록표 d02-points · e02-ground · e03-prob · e04-settings · e05-report · e06-rock · e07-settings
- 아홉 페이지 localStorage 지금 프로젝트 직접 읽기 → currentProjectId() (다른 탭이 바꿔도 이 탭 그대로)
- test_560_4 D · E 줄 넓힘(옛 beforeLeave 단언 → dirty · save) · test_401 PENDING 에서 D · E 뺌

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:09:08 +09:00

408 lines
14 KiB
TypeScript

/* =============================================================================
* E02_StabGround_UI_Page.ts
* E02 지반 조건(요구서 345 4-3 · 요구 347-2 · 3 · 4 · PLAN 532-3) — B05~B09 틀(group_workflow).
* GET /api/projects/{id}/stab/ground → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지.
* 조작 = 캐시 `e02-ground` 만(자동저장 없음) · [해석 시작] = POST …/stab/run(서버가 다시 검사 ·
* 저장 → 2D · 3D · 암반 · 광역 · 보고서 체인 · PLAN 564-3) · 진행 = GET …/stab/run/progress(다른
* 페이지에 갔다 와도 이어 보임) · [끊기] · 떠날 때 바뀐 값 = 공용 물음 [취소][폐기][저장](572-3) ·
* [초기화] = POST …/reset(초기값 덮어씀).
* 검사 = logic/E02_StabGround_Check(파이썬 짝).
* 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05).
* ========================================================================== */
import "./E02_StabGround_UI_Style.css";
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { createListRow } from "@ui/ui_template_list_row";
import { createProgressBar } from "@ui/ui_template_progress";
import { createText } from "@ui/ui_template_text";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing";
import { inferStationInterval } from "../common_util/common_util_station";
import { buildCards, hook, type E02View } from "./E02_StabGround_UI_Tables";
import {
checkGround,
type CheckError,
type Ground,
type GroundDefaults,
} from "./logic/E02_StabGround_Check";
interface GroundResponse {
ground: Ground;
saved?: boolean;
defaults?: GroundDefaults;
editable?: boolean;
missing_stations?: number[];
}
interface RunStep {
key: string;
state: string;
seconds: number | null;
}
/** 체인 진행(`E02_StabGround_Chain` 진행 꼴) */
interface RunProgress {
running: boolean;
percent: number;
steps: RunStep[];
message: string | null;
error: string | null;
cancelled: boolean;
}
/** 단계 줄 왼쪽 띠 색 — 기다림 = 없음 */
const STEP_TONE: Record<string, string> = {
run: "var(--color-accent)",
done: "var(--color-success)",
skip: "var(--color-success)",
error: "var(--color-danger)",
cancel: "var(--color-warning)",
};
/** 검사 열쇠 → 범위 열쇠(limits) */
function limitKey(path: string): string {
const last = path.split(".").pop() ?? "";
if (path.startsWith("safety_factors.")) return "safety_factor";
if (last === "from_m" || last === "to_m") return "chainage_m";
if (/\.points\.\d+\.0$/.test(path)) return "offset_m";
if (/\.points\.\d+\.1$/.test(path)) return "water_depth_m";
return last;
}
function errorText(error: CheckError, defaults: GroundDefaults): string {
const text = t(`E02_Err_${error.code}` as LocaleKey);
const bound = defaults.limits[limitKey(error.path)];
return error.code === "range" && bound ? `${text} (${bound[0]} ~ ${bound[1]})` : text;
}
async function request<T = GroundResponse>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown };
if (!response.ok) {
const detail = body.detail;
if (typeof detail === "string") throw new Error(detail);
const errors = (detail as { errors?: CheckError[] } | undefined)?.errors;
throw new Error(errors ? `${t("E02_Invalid")} ${errors.length}` : `HTTP ${response.status}`);
}
return body;
}
export function renderE02StabGround(root: HTMLElement): void {
const projectId = currentProjectId();
const main = el("div", { className: "e02-page" });
const status = el("div", { className: "e02-status" });
const runButton = createButton({
label: t("E02_Run"),
variant: "filled",
onClick: () => void runChain(),
});
const resetButton = createButton({
label: t("Common_Btn_Reset"),
variant: "danger-ghost",
onClick: () => void reset(),
});
runButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(resetButton, runButton);
const progressBar = createProgressBar();
const progressText = createText("");
const stepList = el("div", { className: "e02-run__steps" });
const cancelButton = createButton({
label: t("E02_Run_Cancel"),
variant: "danger-ghost",
size: "sm",
onClick: () => void cancelChain(),
});
const runBox = el("div", { className: "e02-run" });
runBox.append(progressBar.root, progressText, stepList, cancelButton);
runBox.hidden = true;
const panel = el("div", { className: "ui-sidebar-panel e02-panel" });
panel.append(
hook(createText(t("E02_Note")), "e02-hint"),
hook(createText(t("E02_Run_Hint")), "e02-hint"),
status,
runBox,
actions,
);
mountGroupStep(root, ROUTES.E02_STAB_GROUND, {
mainContent: main,
leftPanel: panel,
dirty: () => Boolean(view?.editable) && dirty,
save: store,
discard: () => clearState("e02-ground", projectId),
});
if (!projectId) return void main.append(hook(createText(t("E02_NoProject")), "e02-empty"));
let view: E02View | null = null;
let refreshStations = (): void => {};
let dirty = false;
let running = false;
let missing: number[] = [];
const paintButtons = (): void => {
runButton.disabled = resetButton.disabled = !view?.editable || running;
};
const paintRun = (p: RunProgress): void => {
running = p.running;
runBox.hidden = !p.running && p.steps.every((s) => s.state === "wait");
progressBar.set(p.percent / 100, `${p.percent}%`);
progressText.textContent = p.running ? `${t("E02_Run_Running")} · ${p.message ?? ""}` : "";
stepList.replaceChildren(
...p.steps.map((s) =>
createListRow({
label: t(`E02_Run_Step_${s.key}` as LocaleKey),
trailing: [
t(`E02_Run_State_${s.state}` as LocaleKey),
s.seconds !== null && s.state !== "wait" ? `${s.seconds} s` : "",
]
.filter(Boolean)
.join(" · "),
stripe: STEP_TONE[s.state] ?? "transparent",
active: s.state === "run",
size: 28,
}),
),
);
cancelButton.hidden = !p.running;
paintButtons();
};
/** 체인이 끝날 때까지 — 다른 페이지로 가면 멈춤(체인은 서버에서 계속 · 다시 오면 이어 봄) */
async function poll(): Promise<void> {
while (root.isConnected) {
const p = await request<RunProgress>(`/projects/${projectId}/stab/run/progress`).catch(
() => null,
);
if (!p) return;
paintRun(p);
if (!p.running) {
if (p.error) showToast(p.error, "error");
else if (p.cancelled) showToast(t("E02_Run_Cancelled"), "info");
else showToast(t("E02_Run_Done"), "success");
return;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
const paintStatus = (errors: CheckError[]): void => {
const lines: string[] = [];
if (view && !view.editable) lines.push(t("E02_ReadOnly"));
if (dirty) lines.push(t("E02_Unsaved"));
if (errors.length) lines.push(`${t("E02_Invalid")} ${errors.length}`);
status.replaceChildren(
...(lines.length
? [createText(lines.join("\n"), errors.length > 0 ? "error" : "warning")]
: []),
);
status.classList.toggle("is-error", errors.length > 0);
};
/** 틀린 칸 표시 — 칸마다 is-error(부품 오류 상태) + data-error(마우스 글) */
const mark = (): CheckError[] => {
if (!view) return [];
const errors = checkGround(view.ground, view.defaults.limits);
for (const node of main.querySelectorAll<HTMLElement>("[data-error]")) {
node.classList.remove("is-error");
node.removeAttribute("aria-invalid");
delete node.dataset.error;
}
for (const error of errors) {
const node = main.querySelector<HTMLElement>(`[data-path="${error.path}"]`);
if (!node) continue;
node.classList.add("is-error");
node.setAttribute("aria-invalid", "true");
node.dataset.error = errorText(error, view.defaults);
}
paintStatus(errors);
return errors;
};
const render = (): void => {
if (!view) return;
const cards = buildCards(view);
refreshStations = cards.refreshStations;
main.replaceChildren(
buildMissingBanner(missing, inferStationInterval(view.ground.stations)),
cards.root,
);
mark();
};
const remember = (): void => {
if (!view) return;
dirty = true;
writeState("e02-ground", view.ground, projectId);
};
const adopt = (body: GroundResponse, keepCache: boolean): void => {
if (!view || !body.defaults) return;
let ground = body.ground;
const cached = keepCache ? readState<Ground>("e02-ground", projectId) : null;
if (cached) ground = cached;
else clearState("e02-ground", projectId);
// 옛 캐시(지하수 갈래 칸 전) — 빠진 칸은 서버 값(서버가 기본값으로 채워 보냄)
ground.groundwater = { ...body.ground.groundwater, ...ground.groundwater };
dirty = Boolean(cached);
view.ground = ground;
view.defaults = body.defaults;
view.editable = body.editable ?? false;
paintButtons();
render();
};
/** 틀린 칸이 있으면 첫 까닭을 알리고 false */
function valid(): boolean {
const errors = mark();
if (view && errors.length) {
showToast(
`${t("E02_Invalid")} ${errors.length} — ${errorText(errors[0], view.defaults)}`,
"error",
);
}
return errors.length === 0;
}
/** 떠남 물음 [저장](572-3) — 해석 없이 바뀐 값만 저장 · 틀리거나 실패 = 머묾 */
async function store(): Promise<boolean> {
if (!view || !projectId || !valid()) return false;
showLoadingOverlay();
try {
await request(`/projects/${projectId}/stab/ground`, {
method: "PUT",
body: JSON.stringify({ ground: view.ground }),
});
clearState("e02-ground", projectId);
dirty = false;
showToast(t("E02_Saved"), "success");
return true;
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
return false;
} finally {
hideLoadingOverlay();
}
}
/** [해석 시작] — 서버가 검사 · 저장(잠금판은 이 순간만) → 체인은 뒤에서 */
async function runChain(): Promise<void> {
if (!view?.editable || running || !valid()) return;
showLoadingOverlay();
try {
const body = await request<GroundResponse & RunProgress>(`/projects/${projectId}/stab/run`, {
method: "POST",
body: JSON.stringify({ ground: view.ground }),
});
adopt({ ground: body.ground, defaults: view.defaults, editable: view.editable }, false);
paintRun(body);
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
return;
} finally {
hideLoadingOverlay();
}
void poll();
}
async function cancelChain(): Promise<void> {
cancelButton.disabled = true;
try {
await request(`/projects/${projectId}/stab/run/cancel`, { method: "POST" });
showToast(t("E02_Run_Stopping"), "info");
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
cancelButton.disabled = false;
}
}
async function reset(): Promise<void> {
if (!view?.editable) return;
if (
!(await showConfirmDialog(
t("E02_ResetConfirm"),
t("Common_Btn_Reset"),
t("Common_Btn_Cancel"),
))
)
return;
showLoadingOverlay();
try {
const body = await request(`/projects/${projectId}/stab/ground/reset`, { method: "POST" });
adopt({ ...body, defaults: view.defaults, editable: view.editable }, false);
showToast(t("E02_ResetDone"), "success");
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
hideLoadingOverlay();
}
}
attachHoverInfo(
main,
(target) => {
if (!view) return null;
const src = target.dataset.src;
const source = src ? view.defaults.sources[src] : undefined;
const userLayer = src?.startsWith("materials.") && !source;
return [
target.dataset.error,
target.dataset.def !== undefined ? `${t("E02_DefaultIs")} ${target.dataset.def}` : "",
source ?? (userLayer ? t("E02_UserLayer") : ""),
]
.filter(Boolean)
.join("\n");
},
"[data-path]",
);
void (async () => {
try {
const body = await request(`/projects/${projectId}/stab/ground`);
missing = body.missing_stations ?? [];
view = {
ground: body.ground,
defaults: body.defaults as GroundDefaults,
editable: false,
change: () => {
remember();
refreshStations();
mark();
},
rebuild: () => {
remember();
render();
},
};
adopt(body, true);
const p = await request<RunProgress>(`/projects/${projectId}/stab/run/progress`);
paintRun(p);
if (p.running) void poll();
} catch (error) {
main.replaceChildren(
hook(
createText(error instanceof Error ? error.message : t("Common_Status_Error")),
"e02-empty",
),
);
}
})();
}