Files
Aislo/E05_StabReport/E05_StabReport_UI_Page.ts
eomsangdonandClaude Opus 5.5 c3fbcb13f1 feat(D · E): 떠남 = 공용 물음 [취소 · 폐기 · 저장] · 캐시 = 등록표 · 프로젝트 = 탭 currentProjectId (PLAN 572-3 · 요구 400~403)
- 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>
2026-10-28 18:09:08 +09:00

345 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.
* 보기 = 내장 엑셀(시트 탭 · 그림 겹판 · 쪽 나눔 점선 · 늦게 불러옴).
* 조작 = 캐시 `e05-report` 만(자동저장 없음) · [저장] = PUT {rev, book}(서버 재계산 · 판 다르면 409) ·
* [초기화] = POST …/reset · [보고서 갱신] = POST …/refresh(프로그램 몫 시트 · 그림만 다시).
* 열 때 해석이 바뀌었으면 서버가 저절로 갱신(`refreshed` · 덮인 시트 `overwritten` 알림).
* [엑셀] · [PDF] · [PDF 미리보기] = 화면 지금 책을 서버로(보기만이면 작업본 GET) · 저장 안 함.
* 떠날 때 고친 책이 있으면 공용 물음 [취소][폐기][저장](572-3).
* ========================================================================== */
import "./E05_StabReport_UI_Style.css";
import { API_BASE_URL, 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 { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
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[];
refreshed?: boolean;
}
interface CacheEntry {
rev: string;
book: Workbook;
}
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 = currentProjectId();
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,
dirty: () => editable && dirty,
save: async () => {
await save();
return !dirty; // 실패 = 고침 남음 = 머묾
},
discard: () => clearState("e05-report", projectId),
});
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 cached = keepCache ? readState<CacheEntry>("e05-report", projectId) : null;
if (cached && cached.rev === body.rev) book = cached.book;
else clearState("e05-report", 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;
writeState(
"e05-report",
{ rev: state?.rev ?? "", book: next } satisfies CacheEntry,
projectId,
);
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() }),
});
clearState("e05-report", 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 {
const body: ReportResponse = await request(`/projects/${projectId}/stab/report`);
await adopt(body, true);
// 페이지 단독 [해석] 뒤 서버가 저절로 갱신(565-2)
const over = body.overwritten ?? [];
if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000);
else if (body.refreshed) showToast(t("E05_Refreshed"), "success");
} catch (error) {
sheetHost.replaceChildren(
emptyBox(error instanceof Error ? error.message : t("Common_Status_Error")),
);
}
});
}