Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
314 lines
11 KiB
TypeScript
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);
|