- 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>
645 lines
22 KiB
TypeScript
645 lines
22 KiB
TypeScript
/* =============================================================================
|
|
* E03_StabSection_UI_Page.ts
|
|
* E03 2D 측점 해석(요구서 345 4-3 · 요구 347 · 351 · PLAN 532-3) — B05~B09 틀(group_workflow).
|
|
* 좌 = 측점 목록(조건 · 정렬 · N.G 만 · 대표 단면 별) · [해석] · [3D 보기] · [대표 단면 저장]
|
|
* 위 = 평면 위험 띠 · 가운데 = 횡단(임계면 · 등안전율 · 후보 · 절편) · 우 = 결과 · 절편표 · 자유물체도 · 그래프
|
|
* 계산 = 서버 단독([해석] → POST …/stab/analyze2d · 잠금판 진행률 = GET …/stab/progress) ·
|
|
* 대표 단면 별 = 누를 때마다 바로 PUT(요구 390) · 떠날 때 저장 중이면 공용 물음 [취소][폐기][저장](572-3).
|
|
* 「보강 검토」 탭 = 우측을 가정 재해석 · 벽 외적 안정으로 바꿈(`UI_Reinforce` · 서버 따로 저장본).
|
|
* 「확률 · 민감도」 탭 = 대표 단면 확률 해석(`UI_Prob` · 서버 따로 저장본 · 카드 안 진행률).
|
|
* ========================================================================== */
|
|
|
|
import "./E03_StabSection_UI_Style.css";
|
|
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
|
|
import {
|
|
createButton,
|
|
createInputField,
|
|
createSelectField,
|
|
el,
|
|
hideLoadingOverlay,
|
|
setLoadingOverlayProgress,
|
|
showLoadingOverlay,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
|
import { createSegment } from "@ui/ui_template_segment";
|
|
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 { buildList } from "./E03_StabSection_UI_List";
|
|
import { buildPlan, type PlanView } from "./E03_StabSection_UI_Plan";
|
|
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
|
|
import { card, check, emptyBox, muted } from "./E03_StabSection_UI_Parts";
|
|
import { buildResult } from "./E03_StabSection_UI_Result";
|
|
import {
|
|
buildLegends,
|
|
createSectionCanvas,
|
|
WALL_ID,
|
|
type SectionToggles,
|
|
} from "./E03_StabSection_UI_Section";
|
|
import { openView3d } from "./E03_StabSection_UI_View3D";
|
|
import {
|
|
buildReinforce,
|
|
type SlopeAsk,
|
|
type WallAsk,
|
|
type WallRecord,
|
|
type WhatIfRecord,
|
|
} from "./E03_StabSection_UI_Reinforce";
|
|
import { buildSupport, type SupportAsk, type SupportRecord } from "./E03_StabSection_UI_Support";
|
|
import { createProbPanel } from "./E03_StabSection_UI_Prob";
|
|
import type {
|
|
Analysis,
|
|
Condition,
|
|
SectionView,
|
|
SortMode,
|
|
StationRow,
|
|
} from "./logic/E03_StabSection_Logic";
|
|
|
|
interface Result2d {
|
|
status: "ok" | "none";
|
|
fresh: boolean;
|
|
editable: boolean;
|
|
running: boolean;
|
|
conditions: Condition[];
|
|
computed_at?: string;
|
|
summary?: Record<string, unknown>;
|
|
/** 2D 탐색 한계 — "model_end" · "2h" · 수(비탈 끝 + m) */
|
|
search_reach?: string | number;
|
|
stations: StationRow[];
|
|
/** 횡단 없어 해석에서 빠진 측점 누가거리(E01 복사본) */
|
|
missing_stations?: number[];
|
|
}
|
|
|
|
interface Progress {
|
|
running: boolean;
|
|
percent: number;
|
|
message: string;
|
|
error: string | null;
|
|
}
|
|
|
|
async function api<T>(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) {
|
|
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
|
|
export function renderE03StabSection(root: HTMLElement): void {
|
|
const projectId = currentProjectId();
|
|
const main = el("div", { className: "e03-page" });
|
|
const panel = el("div", { className: "ui-sidebar-panel e03-panel" });
|
|
mountGroupStep(root, ROUTES.E03_STAB_SECTION, {
|
|
mainContent: main,
|
|
leftPanel: panel,
|
|
dirty: () => repPending > 0,
|
|
save: () => repSaving.then(() => true),
|
|
});
|
|
if (!projectId) return void main.append(emptyBox(t("E03_NoProject")));
|
|
const pid: string = projectId;
|
|
const base = `/projects/${pid}/stab`;
|
|
const prob = createProbPanel(pid, base, () => paintSection());
|
|
|
|
let result: Result2d | null = null;
|
|
const sections = new Map<string, SectionView>();
|
|
let condition: Condition = "dry";
|
|
let sort: SortMode = "chainage";
|
|
let ngOnly = false;
|
|
let active: string | null = null;
|
|
let side: string | null = null;
|
|
let picked: number | null = null;
|
|
const toggles: SectionToggles = { map: true, centers: false, candidates: true };
|
|
let mode: "result" | "reinforce" | "prob" = "result";
|
|
let busy = false;
|
|
const slopeRecs = new Map<string, WhatIfRecord>(); // 측점 열쇠 → 마지막 가정
|
|
const wallRecs = new Map<string, WallRecord>();
|
|
const supportRecs = new Map<string, SupportRecord>();
|
|
let interval = 20; // 측점 간격 — 측점 목록에서 추정(요구 390)
|
|
const planView: PlanView = {};
|
|
let repSaving: Promise<void> = Promise.resolve(); // 별 누름마다 차례로 저장
|
|
let repPending = 0; // 아직 안 끝난 별 저장(떠남 물음)
|
|
|
|
// ---- 좌측 ----
|
|
const condField = createSelectField({
|
|
label: t("E03_Condition"),
|
|
options: [],
|
|
compact: true,
|
|
onChange: (v) => {
|
|
condition = v as Condition;
|
|
side = null;
|
|
picked = null;
|
|
paint();
|
|
},
|
|
});
|
|
const sortField = createSelectField({
|
|
label: t("E03_Sort"),
|
|
options: [
|
|
{ value: "chainage", text: t("E03_Sort_Chainage") },
|
|
{ value: "fs", text: t("E03_Sort_Fs") },
|
|
],
|
|
compact: true,
|
|
onChange: (v) => {
|
|
sort = v as SortMode;
|
|
paintList();
|
|
},
|
|
});
|
|
type ReachPick = "model_end" | "2h" | "m";
|
|
const reachSegment = createSegment({
|
|
options: [
|
|
{ value: "model_end", label: t("E03_Reach_model_end") },
|
|
{ value: "2h", label: t("E03_Reach_2h_Short"), title: t("E03_Reach_2h") },
|
|
{ value: "m", label: t("E03_Reach_m_Short"), title: t("E03_Reach_m") },
|
|
],
|
|
value: "model_end",
|
|
size: 24,
|
|
ariaLabel: t("E03_Reach"),
|
|
onChange: () => paintReach(),
|
|
});
|
|
reachSegment.root.dataset.role = "reach";
|
|
reachSegment.root.title = t("E03_Reach_Hint");
|
|
const reachMeters = createInputField({
|
|
type: "number",
|
|
min: 0.1,
|
|
compact: true,
|
|
unit: "m",
|
|
onInput: () => paintReach(),
|
|
});
|
|
reachMeters.input.dataset.role = "reach-m";
|
|
reachMeters.input.title = t("E03_Reach_Hint");
|
|
const reachLabelNode = muted(t("E03_Reach"));
|
|
const reachNote = muted("", "reach-note");
|
|
const reachLabel = (reach: string | number | undefined): string =>
|
|
typeof reach === "number"
|
|
? t("E03_Reach_m_Value").replace("{v}", String(reach))
|
|
: t(reach === "2h" ? "E03_Reach_2h" : "E03_Reach_model_end");
|
|
/** 고른 값(서버로 보낼 꼴) — 정한 m 이 비면 null */
|
|
const reachPicked = (): string | number | null => {
|
|
const pick = reachSegment.get() as ReachPick;
|
|
if (pick !== "m") return pick;
|
|
const meters = Number(reachMeters.input.value);
|
|
return meters > 0 ? meters : null;
|
|
};
|
|
/** 정한 m 칸 보임 · 「이 결과」 한 줄 — 고른 값이 결과와 다르면 「해석 전」 */
|
|
const paintReach = (): void => {
|
|
reachMeters.root.hidden = reachSegment.get() !== "m";
|
|
if (result?.status !== "ok") return void (reachNote.textContent = "");
|
|
const used = result.search_reach ?? "model_end";
|
|
const picked = reachPicked();
|
|
reachNote.textContent =
|
|
`${t("E03_Reach_Used")} ${reachLabel(used)}` +
|
|
(picked !== null && picked !== used ? ` · ${t("E03_Reach_Pending")}` : "");
|
|
};
|
|
/** 서버 저장값을 칸에 맞춤 */
|
|
const showReach = (reach: string | number | undefined): void => {
|
|
const custom = typeof reach === "number";
|
|
reachSegment.set(custom ? "m" : reach === "2h" ? "2h" : "model_end");
|
|
if (custom) reachMeters.input.value = String(reach);
|
|
paintReach();
|
|
};
|
|
const ngBox = check(t("E03_NgOnly"), false, (v) => {
|
|
ngOnly = v;
|
|
paintList();
|
|
});
|
|
const status = muted("", "status");
|
|
const listHost = el("div", {
|
|
className: "e03-panel",
|
|
attrs: { style: "flex: 1 1 auto; min-height: 0" },
|
|
});
|
|
const analyzeButton = createButton({
|
|
label: t("E03_Analyze"),
|
|
variant: "filled",
|
|
onClick: () => void analyze(),
|
|
});
|
|
const view3dButton = createButton({
|
|
label: t("E03_View3d"),
|
|
variant: "ghost",
|
|
onClick: () => void show3d(),
|
|
});
|
|
const controls = el("div", { className: "e03-panel__row" });
|
|
controls.append(condField.root, sortField.root);
|
|
const actions = el("div", { className: "ui-sidebar-actions" });
|
|
actions.append(view3dButton, analyzeButton);
|
|
panel.append(
|
|
controls,
|
|
reachLabelNode,
|
|
reachSegment.root,
|
|
reachMeters.root,
|
|
reachNote,
|
|
ngBox,
|
|
status,
|
|
listHost,
|
|
actions,
|
|
);
|
|
|
|
// ---- 본문 ----
|
|
const missingHost = el("div");
|
|
const planHost = el("div");
|
|
const toolbar = el("div", { className: "e03-card__head" });
|
|
const sectionCard = card();
|
|
const legendHost = el("div");
|
|
const canvas = createSectionCanvas((i) => {
|
|
picked = i;
|
|
paintSection();
|
|
});
|
|
sectionCard.append(toolbar, canvas.root, legendHost);
|
|
const resultHost = el("div", { className: "e03-result", attrs: { style: "min-height: 0" } });
|
|
const body = el("div", { className: "e03-body" });
|
|
body.append(sectionCard, resultHost);
|
|
main.append(missingHost, planHost, body);
|
|
|
|
const repOf = (row: StationRow): { on: boolean; user: boolean | null } => {
|
|
const server = row.representative ?? { auto: [], user: null, on: false };
|
|
return { on: server.on, user: server.user };
|
|
};
|
|
|
|
const currentRow = (): StationRow | undefined => result?.stations.find((r) => r.key === active);
|
|
|
|
/** 고른 측점 · 조건의 비탈 — 고른 게 없으면 안전율 가장 낮은 쪽 */
|
|
const currentAnalysis = (view: SectionView): Analysis | null => {
|
|
const list = view.analyses.filter((a) => a.condition === condition);
|
|
if (!list.length) return null;
|
|
const found = side ? list.find((a) => a.side === side) : null;
|
|
if (found) return found;
|
|
const worst = list
|
|
.slice()
|
|
.sort((a, b) => (a.critical?.fs ?? Infinity) - (b.critical?.fs ?? Infinity))[0];
|
|
side = worst.side;
|
|
return worst;
|
|
};
|
|
|
|
const paintStatus = (): void => {
|
|
const lines: string[] = [];
|
|
if (!result) lines.push(t("E03_Loading"));
|
|
else if (result.status === "none") lines.push(t("E03_NotYet"));
|
|
else {
|
|
lines.push(`${t("E03_Computed")} ${result.computed_at ?? ""}`);
|
|
if (!result.fresh) lines.push(t("E03_Stale"));
|
|
}
|
|
if (result && !result.editable) lines.push(t("E03_ReadOnly"));
|
|
status.textContent = lines.join("\n");
|
|
analyzeButton.disabled = !result?.editable;
|
|
reachMeters.input.disabled = !result?.editable;
|
|
reachSegment.root.querySelectorAll("button").forEach((b) => (b.disabled = !result?.editable));
|
|
view3dButton.disabled = result?.status !== "ok";
|
|
};
|
|
|
|
const paintList = (): void => {
|
|
if (!result) return;
|
|
listHost.replaceChildren(
|
|
buildList(
|
|
{
|
|
rows: result.stations,
|
|
conditions: result.conditions,
|
|
condition,
|
|
sort,
|
|
ngOnly,
|
|
active,
|
|
interval,
|
|
representative: repOf,
|
|
editable: result.editable,
|
|
},
|
|
(key) => void select(key),
|
|
(key) => void toggleRep(key),
|
|
),
|
|
);
|
|
};
|
|
|
|
const paintSection = (): void => {
|
|
const view = active ? sections.get(active) : undefined;
|
|
toolbar.replaceChildren();
|
|
resultHost.replaceChildren();
|
|
legendHost.replaceChildren();
|
|
if (!view) {
|
|
canvas.render(null);
|
|
const text = result?.status === "ok" ? t("E03_PickStation") : t("E03_NotYet");
|
|
const none = card();
|
|
none.append(emptyBox(text));
|
|
resultHost.append(none);
|
|
return;
|
|
}
|
|
const analysis = currentAnalysis(view);
|
|
const sides = view.analyses.filter((a) => a.condition === condition);
|
|
const sideField = createSelectField({
|
|
options: sides.map((a) => ({
|
|
value: a.side,
|
|
text: `${t(a.side === "left" ? "E03_Left" : "E03_Right")} · ${t(a.role === "cut" ? "E03_Cut" : "E03_Fill")}`,
|
|
})),
|
|
value: analysis?.side,
|
|
compact: true,
|
|
ariaLabel: t("E03_Side"),
|
|
onChange: (v) => {
|
|
side = v;
|
|
picked = null;
|
|
paintSection();
|
|
},
|
|
});
|
|
const title = el("h3", {
|
|
className: "e03-card__title",
|
|
text: `${stationLabel(view.chainage_m, interval, 1)} · ${t(`E03_Cond_${condition}` as LocaleKey)}`,
|
|
});
|
|
const flags = el("div", { className: "e03-panel__row" });
|
|
flags.append(
|
|
check(t("E03_Show_Map"), toggles.map, (v) => ((toggles.map = v), paintSection())),
|
|
check(t("E03_Show_Centers"), toggles.centers, (v) => ((toggles.centers = v), paintSection())),
|
|
check(
|
|
t("E03_Show_Candidates"),
|
|
toggles.candidates,
|
|
(v) => ((toggles.candidates = v), paintSection()),
|
|
),
|
|
);
|
|
const modeField = createSelectField({
|
|
options: [
|
|
{ value: "result", text: t("E03_Tab_Result") },
|
|
{ value: "reinforce", text: t("E03_Tab_Reinforce") },
|
|
{ value: "prob", text: t("E03_Tab_Prob") },
|
|
],
|
|
value: mode,
|
|
compact: true,
|
|
ariaLabel: t("E03_Tab"),
|
|
onChange: (v) => {
|
|
mode = v as typeof mode;
|
|
paintSection();
|
|
},
|
|
});
|
|
modeField.select.dataset.role = "mode";
|
|
toolbar.append(title, sideField.root, modeField.root, flags);
|
|
const draw = { view, analysis, toggles, picked };
|
|
canvas.render(draw);
|
|
legendHost.append(buildLegends(draw));
|
|
const rule = view.water_rule?.[condition];
|
|
if (rule?.mode === "ru")
|
|
legendHost.append(muted(t("E03_Water_Ru").replace("{v}", String(rule.ru)), "water-ru"));
|
|
if (view.regions.some((r) => r.material_id === WALL_ID))
|
|
legendHost.append(muted(t("E03_Walls_Hint")));
|
|
const row = currentRow();
|
|
if (mode === "prob" && row) {
|
|
resultHost.append(
|
|
prob.render({ row, condition, side, editable: !!result?.editable && !busy }),
|
|
);
|
|
return;
|
|
}
|
|
if (mode === "reinforce" && row) {
|
|
if (row.walls && !wallRecs.has(row.key) && !busy && result?.editable)
|
|
queueMicrotask(() => void runWall(row.key, { method: "coulomb", water_ratio: 0 }));
|
|
resultHost.append(
|
|
buildReinforce({
|
|
row,
|
|
condition,
|
|
side,
|
|
editable: !!result?.editable,
|
|
busy,
|
|
slope: slopeRecs.get(row.key),
|
|
wall: wallRecs.get(row.key),
|
|
runSlope: (ask) => void runSlope(row.key, ask),
|
|
runWall: (ask) => void runWall(row.key, ask),
|
|
}),
|
|
buildSupport({
|
|
row,
|
|
view,
|
|
condition,
|
|
side,
|
|
editable: !!result?.editable,
|
|
busy,
|
|
record: supportRecs.get(row.key),
|
|
run: (ask) => void runSupport(row.key, ask),
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
resultHost.append(
|
|
buildResult(
|
|
view,
|
|
analysis,
|
|
picked,
|
|
(i) => {
|
|
picked = i;
|
|
paintSection();
|
|
},
|
|
analysis ? row?.cases[condition]?.[analysis.side] : undefined,
|
|
),
|
|
);
|
|
};
|
|
|
|
/** 가정 재해석 · 벽 검토 — 서버 계산(수십 초) 동안 단추만 잠금 */
|
|
async function reinforce<T>(
|
|
path: string,
|
|
body: unknown,
|
|
done: (value: T) => void,
|
|
): Promise<void> {
|
|
busy = true;
|
|
paintSection();
|
|
try {
|
|
done(await api<T>(path, { method: "POST", body: JSON.stringify(body) }));
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
|
} finally {
|
|
busy = false;
|
|
paintSection();
|
|
}
|
|
}
|
|
const runSlope = (key: string, ask: SlopeAsk): Promise<void> =>
|
|
reinforce<WhatIfRecord>(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => {
|
|
slopeRecs.set(key, rec);
|
|
side = rec.assume.side; // 전 그림 = 가정한 비탈
|
|
});
|
|
const runSupport = (key: string, ask: SupportAsk): Promise<void> =>
|
|
reinforce<SupportRecord>(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => {
|
|
supportRecs.set(key, rec);
|
|
side = rec.assume.side;
|
|
});
|
|
const runWall = (key: string, ask: WallAsk): Promise<void> =>
|
|
reinforce<WallRecord>(`${base}/wallcheck/${encodeURIComponent(key)}`, ask, (rec) =>
|
|
wallRecs.set(key, rec),
|
|
);
|
|
|
|
const paintPlan = (): void => {
|
|
planHost.replaceChildren(
|
|
result
|
|
? buildPlan(
|
|
result.stations,
|
|
condition,
|
|
active,
|
|
interval,
|
|
planView,
|
|
(key) => void select(key),
|
|
)
|
|
: el("div"),
|
|
);
|
|
};
|
|
|
|
function paint(): void {
|
|
paintStatus();
|
|
paintList();
|
|
paintPlan();
|
|
paintSection();
|
|
}
|
|
|
|
async function select(key: string): Promise<void> {
|
|
if (active !== key) picked = null;
|
|
active = key;
|
|
paintList();
|
|
paintPlan();
|
|
if (result?.status === "ok" && !sections.has(key) && !currentRow()?.error) {
|
|
try {
|
|
sections.set(key, await api<SectionView>(`${base}/section/${encodeURIComponent(key)}`));
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
|
}
|
|
}
|
|
if (active === key) paintSection();
|
|
}
|
|
|
|
async function load(): Promise<void> {
|
|
const before = result?.status;
|
|
result = await api<Result2d>(`${base}/result2d`);
|
|
if (before === "none" && result.status === "ok") active = null; // 첫 해석 뒤 = 가장 낮은 측점으로
|
|
sections.clear();
|
|
showReach(result.search_reach);
|
|
interval = inferStationInterval(
|
|
result.stations.slice().sort((a, b) => a.chainage_m - b.chainage_m),
|
|
);
|
|
missingHost.replaceChildren(buildMissingBanner(result.missing_stations, interval));
|
|
const conds = result.conditions;
|
|
if (!conds.includes(condition)) condition = conds[0] ?? "dry";
|
|
condField.setOptions(
|
|
conds.map((c) => ({ value: c, text: t(`E03_Cond_${c}` as LocaleKey) })),
|
|
condition,
|
|
);
|
|
const keys = result.stations.map((r) => r.key);
|
|
delete planView.zoom; // 다시 읽음 = 띠 원배율
|
|
if (!active || !keys.includes(active)) {
|
|
// 처음 = 그 조건 가장 낮은 안전율 측점
|
|
const worst = result.stations
|
|
.filter((r) => r.min[condition] !== null && r.min[condition] !== undefined)
|
|
.sort((a, b) => (a.min[condition] as number) - (b.min[condition] as number))[0];
|
|
active = worst?.key ?? keys[0] ?? null;
|
|
}
|
|
paint();
|
|
if (active) await select(active);
|
|
if (result.running) await watch();
|
|
}
|
|
|
|
/** 잠금판 + 진행률 — 끝나면 다시 읽음 */
|
|
async function watch(): Promise<void> {
|
|
showLoadingOverlay();
|
|
try {
|
|
for (;;) {
|
|
const p = await api<Progress>(`${base}/progress`);
|
|
setLoadingOverlayProgress(
|
|
p.percent / 100,
|
|
`${t("E03_Analyzing")} ${p.percent}% · ${p.message}`,
|
|
);
|
|
if (!p.running) {
|
|
if (p.error) showToast(p.error, "error");
|
|
else showToast(t("E03_Done"), "success");
|
|
break;
|
|
}
|
|
await sleep(1000);
|
|
}
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
await load();
|
|
}
|
|
|
|
async function analyze(): Promise<void> {
|
|
const reach = reachPicked();
|
|
if (reach === null) {
|
|
reachMeters.setError(t("E03_Reach_Invalid"));
|
|
return;
|
|
}
|
|
reachMeters.setError();
|
|
try {
|
|
const response = await api<{ status: string }>(`${base}/analyze2d`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ search_reach: reach }),
|
|
});
|
|
if (response.status === "fresh") {
|
|
showToast(t("E03_Fresh"), "info");
|
|
await load();
|
|
return;
|
|
}
|
|
await watch();
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
|
}
|
|
}
|
|
|
|
/** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */
|
|
function toggleRep(key: string): Promise<void> {
|
|
repPending += 1;
|
|
repSaving = repSaving
|
|
.then(async () => {
|
|
const row = result?.stations.find((r) => r.key === key);
|
|
if (!row) return;
|
|
const server = row.representative ?? { auto: [], user: null, on: false };
|
|
const auto = server.auto.length > 0;
|
|
const next = !server.on === auto ? null : !server.on;
|
|
const overrides: Record<string, boolean | null> = {};
|
|
for (const r of result?.stations ?? []) {
|
|
const user = r.key === key ? next : (r.representative?.user ?? null);
|
|
if (user !== null) overrides[r.key] = user;
|
|
}
|
|
try {
|
|
await api(`${base}/representative`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ overrides }),
|
|
});
|
|
row.representative = { ...server, user: next, on: next === null ? auto : next };
|
|
paintList();
|
|
void refreshReport();
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
|
}
|
|
})
|
|
.finally(() => {
|
|
repPending -= 1;
|
|
});
|
|
return repSaving;
|
|
}
|
|
|
|
/** 보고서 갱신 — E05 [보고서 갱신] 과 같은 길(GET 이 작업본 없으면 처음 만듦 · 뒤에서) */
|
|
let reportRun = 0;
|
|
async function refreshReport(): Promise<void> {
|
|
const run = ++reportRun;
|
|
await sleep(800); // 별을 잇달아 누르면 마지막 한 번만
|
|
if (run !== reportRun) return;
|
|
try {
|
|
const state = await api<{ book: unknown; rev: string | null }>(
|
|
`/projects/${pid}/stab/report`,
|
|
);
|
|
if (!state.book || run !== reportRun) return;
|
|
const body = await api<{ overwritten?: string[] }>(`/projects/${pid}/stab/report/refresh`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ rev: state.rev }),
|
|
});
|
|
const over = body.overwritten ?? [];
|
|
if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000);
|
|
} catch {
|
|
/* 보고서는 E05 를 열 때 stale 로 알림 */
|
|
}
|
|
}
|
|
|
|
async function show3d(): Promise<void> {
|
|
if (!result) return;
|
|
try {
|
|
const data = await api<Parameters<typeof openView3d>[0]>(`${base}/corridor3d`);
|
|
await openView3d(data, result.stations, condition, t(`E03_Cond_${condition}` as LocaleKey));
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
|
}
|
|
}
|
|
|
|
paintStatus();
|
|
load().catch((error) => {
|
|
status.textContent = error instanceof Error ? error.message : t("Common_Status_Error");
|
|
});
|
|
}
|