/* ============================================================================= * 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 = { 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(path: string, init?: RequestInit): Promise { 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 { while (root.isConnected) { const p = await request(`/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("[data-error]")) { node.classList.remove("is-error"); node.removeAttribute("aria-invalid"); delete node.dataset.error; } for (const error of errors) { const node = main.querySelector(`[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("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 { 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 { if (!view?.editable || running || !valid()) return; showLoadingOverlay(); try { const body = await request(`/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 { 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 { 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(`/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", ), ); } })(); }