Files
Aislo/B09_Estimation/draft/B09_Draft_Print_Page.ts
T

314 lines
11 KiB
TypeScript

/* =============================================================================
* 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<Blob>;
xlsx?: (ids: string[]) => Promise<Blob>;
/** 그 PDF 곁 알림(도면 = 못 넣은 그림 수) */
note?: (pdf: Blob) => string;
}
async function post(projectId: string, path: string, body: unknown): Promise<Blob> {
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<Area | null> {
const def = AREAS.find((a) => a.id === id)!;
const load = MODS[def.file];
if (!load) return null;
const mod = (await load()) as Record<string, unknown>;
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<string, number>) | 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<string>(); // 체크 뺀 쪽
let seq = 0;
let shown: { url: string; blob: Promise<Blob> } | 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);