406 lines
14 KiB
TypeScript
406 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 → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지.
|
|
* 조작 = sessionStorage 캐시만(자동저장 없음) · [해석 시작] = POST …/stab/run(서버가 다시 검사 ·
|
|
* 저장 → 2D · 3D · 암반 · 광역 · 보고서 체인 · PLAN 564-3) · 진행 = GET …/stab/run/progress(다른
|
|
* 페이지에 갔다 와도 이어 보임) · [끊기] · 떠날 때 바뀐 값 = PUT …/stab/ground(beforeLeave) ·
|
|
* [초기화] = 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 { 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 { 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 cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`;
|
|
|
|
/** 단계 줄 왼쪽 띠 색 — 기다림 = 없음 */
|
|
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 = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
|
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,
|
|
beforeLeave: async () => !view?.editable || !dirty || store(),
|
|
});
|
|
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;
|
|
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;
|
|
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;
|
|
}
|
|
|
|
/** 떠날 때(560-4 beforeLeave) — 해석 없이 바뀐 값만 저장 · 틀리거나 실패 = 머묾 */
|
|
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 }),
|
|
});
|
|
sessionStorage.removeItem(cacheKey(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",
|
|
),
|
|
);
|
|
}
|
|
})();
|
|
}
|