/* ============================================================================= * 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 → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지. * 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT(서버가 다시 검사 · 측점은 서버 값) · * [초기화] = POST …/reset(초기값 덮어씀). 검사 = logic/E02_StabGround_Check(파이썬 짝). * 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05). * ========================================================================== */ import "./E02_StabGround_UI_Style.css"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, 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 { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { buildCards, 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[]; } const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`; /** 검사 열쇠 → 범위 열쇠(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 GroundResponse & { 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 = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const main = el("div", { className: "e02-page" }); const status = el("p", { className: "e02-status" }); const saveButton = createButton({ label: t("Common_Btn_Save"), variant: "filled", onClick: () => void save(), }); const resetButton = createButton({ label: t("Common_Btn_Reset"), variant: "danger-ghost", onClick: () => void reset(), }); saveButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬 const actions = el("div", { className: "ui-sidebar-actions" }); actions.append(resetButton, saveButton); const panel = el("div", { className: "ui-sidebar-panel e02-panel" }); panel.append(el("p", { className: "e02-hint", text: t("E02_Note") }), status, actions); mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel }); if (!projectId) return void main.append(el("p", { className: "e02-empty", text: t("E02_NoProject") })); let view: E02View | null = null; let refreshStations = (): void => {}; let dirty = false; let missing: number[] = []; 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.textContent = lines.join("\n"); status.classList.toggle("is-error", errors.length > 0); }; /** 틀린 칸 표시 — 칸마다 is-invalid + data-error(마우스 글) */ const mark = (): CheckError[] => { if (!view) return []; const errors = checkGround(view.ground, view.defaults.limits); for (const node of main.querySelectorAll(".is-invalid")) { node.classList.remove("is-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-invalid"); 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), cards.root); mark(); }; const remember = (): void => { if (!view) return; dirty = true; sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.ground)); }; const adopt = (body: GroundResponse, keepCache: boolean): void => { if (!view || !body.defaults) return; let ground = body.ground; const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null; if (cached) ground = JSON.parse(cached) as Ground; else sessionStorage.removeItem(cacheKey(projectId)); // 옛 캐시(지하수 갈래 칸 전) — 빠진 칸은 서버 값(서버가 기본값으로 채워 보냄) ground.groundwater = { ...body.ground.groundwater, ...ground.groundwater }; dirty = Boolean(cached); view.ground = ground; view.defaults = body.defaults; view.editable = body.editable ?? false; saveButton.disabled = resetButton.disabled = !view.editable; render(); }; async function save(): Promise { if (!view?.editable) return; const errors = mark(); if (errors.length) { showToast( `${t("E02_Invalid")} ${errors.length} — ${errorText(errors[0], view.defaults)}`, "error", ); return; } showLoadingOverlay(); try { const body = await request(`/projects/${projectId}/stab/ground`, { method: "PUT", body: JSON.stringify({ ground: view.ground }), }); adopt({ ...body, defaults: view.defaults, editable: view.editable }, false); showToast(t("E02_Saved"), "success"); } catch (error) { showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); } finally { hideLoadingOverlay(); } } 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); } catch (error) { main.replaceChildren( el("p", { className: "e02-empty", text: error instanceof Error ? error.message : t("Common_Status_Error"), }), ); } })(); }