Files
Aislo/E05_StabReport/E05_StabReport_UI_Page.ts
T

328 lines
12 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 { createBanner } from "@ui/ui_template_banner";
import { createEmpty } from "@ui/ui_template_empty";
import { t } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
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);
}
/** 안내 글 한 줄 — 걸이 이름만 얹음 */
function note(text: string, hook: string): HTMLElement {
const node = createText(text, "muted");
node.classList.add(hook);
return node;
}
function emptyBox(text: string): HTMLElement {
const node = createEmpty({ text, small: true });
node.classList.add("e05-empty");
return node;
}
export function renderE05StabReport(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const main = el("div", { className: "e05-page" });
const sheetHost = el("div", { className: "e05-sheet" });
const status = el("div", { 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());
const staleBar = createBanner({
tone: "warning",
text: `${t("E05_Stale")} —`,
actions: [staleButton],
});
staleBar.classList.add("e05-stale");
staleBar.hidden = true;
main.prepend(staleBar);
main.append(sheetHost);
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(note(t("E05_Note"), "e05-hint"), status, exports, actions);
const frame = document.createElement("iframe");
frame.className = "e05-preview__frame";
frame.title = t("E05_Btn_Preview");
let previewUrl: string | null = null;
let previewModal: { close: () => void } | null = null;
const closePreview = (): void => {
previewModal?.close();
previewModal = null;
frame.removeAttribute("src");
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = null;
};
mountGroupStep(root, ROUTES.E05_STAB_REPORT, { mainContent: main, leftPanel: panel });
if (!projectId) return void sheetHost.append(emptyBox(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.replaceChildren(...(lines.length ? [note(lines.join("\n"), "e05-status__text")] : []));
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(emptyBox(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;
previewModal = openModal({
title: t("E05_Btn_Preview"),
closeLabel: t("Common_Btn_Close"),
wide: true,
dialogClass: "e05-preview",
mount: (body) => body.append(frame),
beforeClose: () => {
previewModal = null;
frame.removeAttribute("src");
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = null;
return true;
},
});
});
}
void run(async () => {
try {
await adopt(await request(`/projects/${projectId}/stab/report`), true);
} catch (error) {
sheetHost.replaceChildren(
emptyBox(error instanceof Error ? error.message : t("Common_Status_Error")),
);
}
});
}