- 빌더: 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>
301 lines
11 KiB
TypeScript
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"),
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
}
|