Files
Aislo/E05_StabReport/E05_StabReport_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 e7afaa6664 feat(534-3): E05 안정성평가 보고서 — 빌더 · storage · 길 · 화면
- 빌더: E02 지반 · E03 2D 요약 · 측점별 저장본 · 대표 단면 · E04 3D 요약 → 프로그램 몫 시트(입력조건 · 측점표 · 대표단면 · 3D요약) · 사용자 몫(표지 · 개요)은 처음 한 번 · N.G 서식 10 · O.K 서식 11 직접 · 해석 지문
- 그림: 서버 SVG(지층 · 암 경계 · 우기 지하수위 · 임계면 · 노폭 · 비탈 높이 · 평면 위험 띠) → PNG(공용 svg_png) · fig/<측점 키>.png
- storage: 작업본 report.json · meta(지문 · 시트 서명) · 초기값 initial_snapshot · 갱신 덮임 목록 · 옛 판 _지난/
- 길: GET(없으면 처음 만듦) · PUT 판 409 + 서버 재계산 · refresh · reset · xlsx · pdf(POST 화면 책 · GET 작업본) · 그림
- 화면: 내장 엑셀(시트 탭 · 그림 겹판) · 캐시 · [저장] · [초기화] · [보고서 갱신] · 해석 바뀜 띠 · [엑셀] · [PDF] · [PDF 미리보기]
- 시험 test_534_3_e05_report 5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:34:40 +09:00

301 lines
11 KiB
TypeScript

/* =============================================================================
* E05_StabReport_UI_Page.ts
* E05 안정성평가 보고서(요구서 345 4-3 · 요구 347-5 · PLAN 534-3) — B05~B09 틀(group_workflow).
* GET /api/projects/{id}/stab/report → 작업본(없고 2D 결과가 있으면 서버가 처음 만듦) · 판 · stale.
* 보기 = 내장 엑셀(시트 탭 · 그림 겹판 · 쪽 나눔 점선 · 늦게 불러옴).
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT {rev, book}(서버 재계산 · 판 다르면 409) ·
* [초기화] = POST …/reset · [보고서 갱신] = POST …/refresh(프로그램 몫 시트 · 그림만 다시).
* [엑셀] · [PDF] · [PDF 미리보기] = 화면 지금 책을 서버로(보기만이면 작업본 GET) · 저장 안 함.
* ========================================================================== */
import "./E05_StabReport_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 { t } from "@ui/ui_template_locale";
import { mountGroupStep } from "../A00_Common/group_workflow";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
interface ReportMeta {
fingerprint: string;
built_at: string;
figures: string[];
}
interface ReportResponse {
book: Workbook | null;
rev?: string | null;
meta?: ReportMeta | null;
stale?: boolean;
has_initial?: boolean;
editable?: boolean;
reason?: string;
overwritten?: string[];
}
interface CacheEntry {
rev: string;
book: Workbook;
}
const cacheKey = (projectId: string): string => `e05_report_cache:${projectId}`;
async function failure(response: Response): Promise<Error> {
const body = (await response.json().catch(() => ({}))) as { detail?: unknown };
return new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
}
async function request(path: string, init?: RequestInit): Promise<ReportResponse> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
if (!response.ok) throw await failure(response);
return (await response.json()) as ReportResponse;
}
function download(blob: Blob, name: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function renderE05StabReport(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const main = el("div", { className: "e05-page" });
const staleBar = el("div", { className: "e05-stale" });
staleBar.hidden = true;
const sheetHost = el("div", { className: "e05-sheet" });
main.append(staleBar, sheetHost);
const status = el("p", { className: "e05-status" });
const button = (label: string, variant: "filled" | "ghost" | "danger-ghost", run: () => void) =>
createButton({ label, variant, onClick: run });
const saveButton = button(t("Common_Btn_Save"), "filled", () => void save());
const resetButton = button(t("Common_Btn_Reset"), "danger-ghost", () => void reset());
const refreshButton = button(t("E05_Btn_Refresh"), "ghost", () => void refresh());
const xlsxButton = button(t("E05_Btn_Xlsx"), "ghost", () => void exportFile("xlsx"));
const pdfButton = button(t("E05_Btn_Pdf"), "ghost", () => void exportFile("pdf"));
const previewButton = button(t("E05_Btn_Preview"), "ghost", () => void preview());
const staleButton = button(t("E05_Btn_Refresh"), "filled", () => void refresh());
staleBar.append(el("span", { text: `${t("E05_Stale")} —` }), staleButton);
const all = [saveButton, resetButton, refreshButton, xlsxButton, pdfButton, previewButton];
for (const b of all) b.disabled = true; // 읽어 온 뒤 켬
const exports = el("div", { className: "ui-sidebar-actions" });
exports.append(xlsxButton, pdfButton, previewButton);
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(refreshButton, resetButton, saveButton);
const panel = el("div", { className: "ui-sidebar-panel e05-panel" });
panel.append(el("p", { className: "e05-hint", text: t("E05_Note") }), status, exports, actions);
const frame = document.createElement("iframe");
frame.className = "e05-preview__frame";
frame.title = t("E05_Btn_Preview");
const previewBox = el("div", { className: "e05-preview" });
previewBox.hidden = true;
let previewUrl: string | null = null;
const closePreview = (): void => {
previewBox.hidden = true;
frame.removeAttribute("src");
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = null;
};
const head = el("div", { className: "e05-preview__head" });
head.append(button(t("Common_Btn_Close"), "ghost", closePreview));
previewBox.append(head, frame);
main.append(previewBox);
mountGroupStep(root, ROUTES.E05_STAB_REPORT, { mainContent: main, leftPanel: panel });
if (!projectId)
return void sheetHost.append(el("p", { className: "e05-empty", text: t("E05_NoProject") }));
let sheet: SpreadsheetHandle | null = null;
let state: ReportResponse | null = null;
let editable = false;
let dirty = false;
let busy = false;
const paint = (): void => {
const lines: string[] = [];
if (!editable) lines.push(t("E05_ReadOnly"));
if (busy) lines.push(t("E05_Working"));
if (dirty) lines.push(t("E05_Unsaved"));
if (state?.meta?.built_at)
lines.push(`${t("E05_BuiltAt")} ${state.meta.built_at.replace("T", " ")}`);
status.textContent = lines.join("\n");
const ready = Boolean(state?.book) && !busy;
saveButton.disabled = !ready || !editable || !dirty;
resetButton.disabled = !ready || !editable || !state?.has_initial;
refreshButton.disabled = staleButton.disabled = !ready || !editable;
xlsxButton.disabled = pdfButton.disabled = previewButton.disabled = !ready;
staleBar.hidden = !state?.stale || !editable;
};
const figureSrc = (key: string): string =>
`${API_BASE_URL}/projects/${projectId}/stab/report/fig?key=${encodeURIComponent(key)}` +
`&v=${encodeURIComponent(state?.meta?.built_at ?? "")}`;
/** 서버 응답 받음 — keepCache 면 같은 판 캐시를 살림 */
const adopt = async (body: ReportResponse, keepCache: boolean): Promise<void> => {
state = body;
if (body.editable !== undefined) editable = body.editable;
if (!body.book) {
sheet?.destroy();
sheet = null;
sheetHost.replaceChildren(el("p", { className: "e05-empty", text: body.reason ?? "" }));
dirty = false;
return paint();
}
let book = body.book;
const raw = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null;
const cached = raw ? (JSON.parse(raw) as CacheEntry) : null;
if (cached && cached.rev === body.rev) book = cached.book;
else sessionStorage.removeItem(cacheKey(projectId));
dirty = Boolean(cached && cached.rev === body.rev);
const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet");
sheet?.destroy();
sheetHost.replaceChildren();
sheet = createSpreadsheet(sheetHost, book, {
tabs: true,
readOnly: !editable,
figureSrc,
onChange: (next) => {
dirty = true;
sessionStorage.setItem(
cacheKey(projectId),
JSON.stringify({ rev: state?.rev ?? "", book: next } satisfies CacheEntry),
);
paint();
},
});
paint();
};
const run = async (task: () => Promise<void>, overlay = false): Promise<void> => {
busy = true;
paint();
if (overlay) showLoadingOverlay();
try {
await task();
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
if (overlay) hideLoadingOverlay();
busy = false;
paint();
}
};
const putBook = async (): Promise<void> => {
if (!sheet || !state) return;
const body = await request(`/projects/${projectId}/stab/report`, {
method: "PUT",
body: JSON.stringify({ rev: state.rev, book: sheet.getDoc() }),
});
sessionStorage.removeItem(cacheKey(projectId));
state = { ...state, rev: body.rev, meta: body.meta, stale: body.stale };
dirty = false;
};
async function save(): Promise<void> {
if (!editable || !dirty) return;
await run(async () => {
await putBook();
showToast(t("E05_Saved"), "success");
}, true);
}
async function refresh(): Promise<void> {
if (!editable || !state) return;
if (
dirty &&
!(await showConfirmDialog(t("E05_SaveFirst"), t("Common_Btn_Save"), t("Common_Btn_Cancel")))
)
return;
await run(async () => {
if (dirty) await putBook();
const body = await request(`/projects/${projectId}/stab/report/refresh`, {
method: "POST",
body: JSON.stringify({ rev: state?.rev }),
});
await adopt({ ...body, editable }, false);
const over = body.overwritten ?? [];
if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000);
else showToast(t("E05_Refreshed"), "success");
});
}
async function reset(): Promise<void> {
if (!editable) return;
if (
!(await showConfirmDialog(
t("E05_ResetConfirm"),
t("Common_Btn_Reset"),
t("Common_Btn_Cancel"),
))
)
return;
await run(async () => {
const body = await request(`/projects/${projectId}/stab/report/reset`, { method: "POST" });
await adopt({ ...body, editable }, false);
showToast(t("E05_ResetDone"), "success");
}, true);
}
/** 화면 지금 책(고칠 수 있으면) 또는 작업본(보기만) → 서버가 다시 풀어 만든 파일 */
async function fetchFile(kind: "xlsx" | "pdf"): Promise<Blob> {
const url = `${API_BASE_URL}/projects/${projectId}/stab/report/${kind}`;
const init: RequestInit =
editable && sheet
? {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ book: sheet.getDoc() }),
}
: { credentials: "include" };
const response = await fetch(url, init);
if (!response.ok) throw await failure(response);
return response.blob();
}
async function exportFile(kind: "xlsx" | "pdf"): Promise<void> {
await run(async () => download(await fetchFile(kind), `안정성평가_보고서.${kind}`));
}
async function preview(): Promise<void> {
await run(async () => {
const blob = await fetchFile("pdf");
closePreview();
previewUrl = URL.createObjectURL(blob);
frame.src = previewUrl;
previewBox.hidden = false;
});
}
void run(async () => {
try {
await adopt(await request(`/projects/${projectId}/stab/report`), true);
} catch (error) {
sheetHost.replaceChildren(
el("p", {
className: "e05-empty",
text: error instanceof Error ? error.message : t("Common_Status_Error"),
}),
);
}
});
}