/* ============================================================================= * 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 { 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 { 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 => { 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, overlay = false): Promise => { 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 => { 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 { if (!editable || !dirty) return; await run(async () => { await putBook(); showToast(t("E05_Saved"), "success"); }, true); } async function refresh(): Promise { 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 { 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 { 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 { await run(async () => download(await fetchFile(kind), `안정성평가_보고서.${kind}`)); } async function preview(): Promise { 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"), }), ); } }); }