/* ============================================================================= * B09_Draft_Print_Page.ts * 인쇄 미리보기 페이지(PLAN 302-1 · 요구 192-3 · 초안) — 주소 #/b09-print(`…_Print_Route`). * 좌측 = 영역 셋(도면 · 수량산출 · 원가계산) 고르기 + 그 영역 쪽 목록(체크로 빼고 넣기 · 차례 그대로) + * [PDF 내려받기] [엑셀 내려받기](도면 = PDF 만) + [B09 로 돌아가기] * 본문 = 고른 쪽들의 PDF 를 브라우저 PDF 보기로(보는 것 = 받는 것) · 만드는 동안 작은 표시(잠금 없음) · * 실패 = 까닭 한 줄 · 체크를 바꾸면 다시 만듦(최신 요청만 채택) * 영역 = 다른 창 파일(`…_Print_Quantity` collectQuantitySheets · `…_Print_Cost` collectCostSheets · * `…_Print_Drawings` fetchDrawingList · fetchDrawingsPdf) — 없으면 「준비 중」 * 표 영역 PDF · 엑셀 = 서버(`B09_Draft_Print_Router` table-pdf · table-xlsx) · 서버는 꼴만 바꿈 * ========================================================================== */ import "@ui/ui_template_general_layout.css"; import "./B09_Draft_Print_Page.css"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; import { createButton } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import type { MountedPage } from "../../A00_Common/router"; import { navigateTo } from "../../A00_Common/router"; import { workflowSteps } from "../../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../../A00_Common/b_workflow_nav"; import { inlineImages, pdfBody, xlsxBody } from "./B09_Draft_Print_Table"; import type { CollectSheets, DrawingItem, FetchDrawingList, FetchDrawingsPdf, PrintSheet, } from "./B09_Draft_Print_Types"; type AreaId = "drawings" | "quantity" | "cost"; const AREAS: { id: AreaId; label: string; file: string }[] = [ { id: "drawings", label: "도면", file: "./B09_Draft_Print_Drawings.ts" }, { id: "quantity", label: "수량산출", file: "./B09_Draft_Print_Quantity.ts" }, { id: "cost", label: "원가계산", file: "./B09_Draft_Print_Cost.ts" }, ]; /** 영역 파일(다른 창 몫) — 없는 파일은 목록에 없음 = 「준비 중」 */ const MODS = import.meta.glob([ "./B09_Draft_Print_Drawings.ts", "./B09_Draft_Print_Quantity.ts", "./B09_Draft_Print_Cost.ts", ]); /** 영역 한 벌 — 쪽 목록 · 고른 쪽 PDF · 엑셀(도면 없음) */ interface Area { items: DrawingItem[]; pdf: (ids: string[]) => Promise; xlsx?: (ids: string[]) => Promise; /** 그 PDF 곁 알림(도면 = 못 넣은 그림 수) */ note?: (pdf: Blob) => string; } async function post(projectId: string, path: string, body: unknown): Promise { const res = await fetch(`${API_BASE_URL}/projects/${projectId}/print/${path}`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { const detail = ((await res.json().catch(() => null)) as { detail?: unknown } | null)?.detail; throw new Error(typeof detail === "string" ? detail : `HTTP ${res.status}`); } return res.blob(); } /** 표 영역 — 쪽 = PrintSheet · PDF · 엑셀 = 서버 꼴 바꿈 */ function tableArea(projectId: string, title: string, sheets: PrintSheet[]): Area { const pick = (ids: string[]) => ids.map((id) => sheets.find((s) => s.id === id)!); return { items: sheets.map(({ id, name }) => ({ id, name })), pdf: async (ids) => { const picked = await Promise.all(pick(ids).map(inlineImages)); return post(projectId, "table-pdf", pdfBody(title, picked)); }, xlsx: (ids) => post(projectId, "table-xlsx", xlsxBody(title, pick(ids))), }; } /** 영역 파일 읽기 — 없음 = null(준비 중) */ async function loadArea(projectId: string, id: AreaId): Promise { const def = AREAS.find((a) => a.id === id)!; const load = MODS[def.file]; if (!load) return null; const mod = (await load()) as Record; if (id === "drawings") { const list = mod.fetchDrawingList as FetchDrawingList | undefined; const pdf = mod.fetchDrawingsPdf as FetchDrawingsPdf | undefined; if (!list || !pdf) return null; const skipped = mod.skippedImagesOf as ((b: Blob) => Record) | undefined; const note = (b: Blob) => { const n = Object.values(skipped?.(b) ?? {}).reduce((a, v) => a + v, 0); return n ? ` · 못 넣은 그림 ${n}개` : ""; }; return { items: await list(projectId), pdf: (ids) => pdf(projectId, ids), note }; } const collect = mod[id === "quantity" ? "collectQuantitySheets" : "collectCostSheets"] as CollectSheets | undefined; return collect ? tableArea(projectId, def.label, await collect(projectId)) : null; } function save(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 renderB09Print(root: HTMLElement): MountedPage { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const status = document.createElement("p"); status.className = "b09-print__status"; status.dataset.b09PrintStatus = ""; const frame = document.createElement("iframe"); frame.className = "b09-print__frame"; frame.title = "인쇄 미리보기"; const body = document.createElement("div"); body.className = "b09-print"; body.append(status, frame); const areaCard = createPanelCard("영역"); const areaRow = document.createElement("div"); areaRow.className = "b09-print__areas"; areaCard.append(areaRow); const sheetCard = createPanelCard("쪽 목록"); const list = document.createElement("div"); list.className = "b09-print__list"; sheetCard.append(list); let area: Area | null = null; let areaId: AreaId = AREAS[0].id; let off = new Set(); // 체크 뺀 쪽 let seq = 0; let shown: { url: string; blob: Promise } | null = null; let timer = 0; const label = () => AREAS.find((a) => a.id === areaId)!.label; const say = (text: string, error = false) => { status.textContent = text; status.classList.toggle("is-error", error); }; const picked = () => (area?.items ?? []).map((it) => it.id).filter((id) => !off.has(id)); const clearFrame = () => { if (shown?.url) URL.revokeObjectURL(shown.url); shown = null; frame.removeAttribute("src"); }; /** 고른 쪽 PDF 다시 만듦 — 최신 요청만 보임 · 내려받기는 같은 PDF */ const rebuild = () => { const my = ++seq; const ids = picked(); clearFrame(); if (!area) return; if (!ids.length) return say("고른 쪽이 없습니다."); say(`${label()} PDF 만드는 중…`); const blob = area.pdf(ids); shown = { url: "", blob }; blob.then( (b) => { if (my !== seq) return; shown!.url = URL.createObjectURL(b); frame.src = shown!.url; say(`${label()} · ${ids.length}장 묶음${area?.note?.(b) ?? ""}`); }, (error: unknown) => my === seq && say(`PDF 를 못 만들었습니다 — ${errText(error)}`, true), ); }; const later = () => { clearTimeout(timer); timer = window.setTimeout(rebuild, 300); }; const pdfBtn = createButton({ label: "PDF 내려받기", onClick: () => { if (!shown) return; const name = `${label()}.pdf`; shown.blob.then( (b) => save(b, name), () => undefined, ); }, }); pdfBtn.dataset.b09PrintPdf = ""; const xlsxBtn = createButton({ label: "엑셀 내려받기", variant: "ghost", onClick: () => { const ids = picked(); if (!area?.xlsx || !ids.length) return; const name = `${label()}.xlsx`; say(`${label()} 엑셀 만드는 중…`); area.xlsx(ids).then( (b) => (save(b, name), say(`${label()} 엑셀을 내려받았습니다.`)), (error: unknown) => say(`엑셀을 못 만들었습니다 — ${errText(error)}`, true), ); }, }); xlsxBtn.dataset.b09PrintXlsx = ""; const back = createButton({ label: "B09 로 돌아가기", variant: "ghost", onClick: () => navigateTo(ROUTES.B09_ESTIMATION), }); const drawList = () => { list.replaceChildren( ...(area?.items ?? []).map((it) => { const row = document.createElement("label"); row.className = "b09-print__item"; const box = document.createElement("input"); box.type = "checkbox"; box.checked = !off.has(it.id); box.dataset.b09PrintItem = it.id; box.addEventListener("change", () => { if (box.checked) off.delete(it.id); else off.add(it.id); later(); }); const name = document.createElement("span"); name.textContent = it.name; row.append(box, name); return row; }), ); }; const pickArea = (id: AreaId) => { areaId = id; area = null; off = new Set(); const my = ++seq; clearFrame(); areaRow.replaceChildren( ...AREAS.map((a) => { const b = createButton({ label: a.label, size: "sm", variant: a.id === id ? "filled" : "ghost", onClick: () => a.id !== areaId && pickArea(a.id), }); b.dataset.b09PrintArea = a.id; return b; }), ); xlsxBtn.hidden = id === "drawings"; drawList(); if (!projectId) return say("현재 프로젝트가 없습니다.", true); say(`${label()} 쪽 목록 읽는 중…`); loadArea(projectId, id).then( (got) => { if (my !== seq) return; if (!got) return say(`${label()} — 준비 중`); area = got; drawList(); if (!got.items.length) return say(`${label()} — 쪽이 없습니다.`); rebuild(); }, (error: unknown) => my === seq && say(`${label()} 목록을 못 읽었습니다 — ${errText(error)}`, true), ); }; // 두 줄(302-5) — 위 = 내려받기 · 아래 = [?](도움말 줄) · 돌아가기 — 패널 폭 넘침 없음 const files = document.createElement("div"); files.className = "b09-print__row"; files.append(pdfBtn, xlsxBtn); const nav = document.createElement("div"); nav.className = "b09-print__row"; nav.dataset.helpRow = ""; nav.append(back); const actions = document.createElement("div"); actions.className = "ui-sidebar-actions b09-print__actions"; actions.append(files, nav); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-print__panel"; left.append(areaCard, sheetCard, actions); attachCollapsible(left); const layout = createWorkflowLayout({ title: "인쇄 미리보기", steps: workflowSteps(), activeStep: 6, mainContent: body, leftPanel: left, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex: number) => { if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); root.append(layout.root); pickArea(areaId); return { covers: () => false, show: () => undefined, dispose: () => { seq++; clearTimeout(timer); clearFrame(); }, }; } const errText = (error: unknown): string => error instanceof Error ? error.message : String(error);