/* ============================================================================= * M02_MasterTemplete_Hopyo_Sheet.ts * 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 * 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 제안 n/m — 뒤 셈 중이면 3 초마다 다시) * + [고른 n개 확인하고 만들기]. * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름 * · ① [고른 n개 확인하고 만들기] = 고른 호표마다 서버 보기 → 펼침 표(넣을 줄 = 체크 제안 · 빠질 줄 · 셈 중 또는 맞음 · 다른 점 수 · * [확인 창 열기]) ② [이대로 n개 만들기] = 하위 호표부터 차례로 `/hopyo-build`(몸 = 그 호표 체크 줄) — * 펼쳐 보이고 한 번 누름 = 사용자 확인 · 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · * `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄) * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 확인하고 만들기는 됨 * ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게). * ========================================================================== */ import { createButton, el, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import { buildHopyo, readHopyo, readHopyoGrid, readHopyoOne, type HopyoBrief, type HopyoBuilt, type HopyoGrid, type HopyoOne, type HopyoScope, type HopyoSpan, type HopyoView, } from "./M02_MasterTemplete_Hopyo_Api"; import { bodyOf, draftKey, HOPYO_BUILT, leftOut, loadDraft, openHopyoCheck, partsOf, POLL_MS, toTarget, } from "./M02_MasterTemplete_Hopyo_Check"; import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose"; import "./M02_MasterTemplete_Hopyo.css"; export interface HopyoSheetOptions { /** 실무자료 파일 층 */ scope: HopyoScope; key: string; 파일: string; /** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */ canWrite: boolean; /** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */ target?: HopyoScope; /** 처음 고를 호표 번(호표 화면 [엑셀에서 보기]) */ 호표?: number; /** 처음 보일 단가산출 행(330-3 근거 [엑셀에서 보기]) */ 단산행?: number | [number, number]; /** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */ onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void; } const ILWI = "일위대가"; const DANSAN = "단가산출"; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const won = (v: number | null | undefined): string => v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); /** 엑셀 행(1부터)이 든 호표 번 · 없으면 null */ export const hopyoAtRow = (list: HopyoSpan[], row: number): number | null => list.find((h) => h.행[0] <= row && row <= h.행[1])?.번 ?? null; /** 호표 영역 A1 — B열 ~ 시트 끝 열(북부청 = `B20:O24`) */ export const spanText = (행: [number, number], last: string): string => `B${행[0]}:${last}${행[1]}`; /** 시트 끝 열 글자 — 칸 · 열 정보 중 가장 오른쪽 */ export function lastColumn(sheet: Pick): string { let best = "A"; for (const k of [...Object.keys(sheet.칸), ...Object.keys(sheet.열 ?? {})]) { const c = /^[A-Z]+/.exec(k)?.[0]; if (c && (c.length > best.length || (c.length === best.length && c > best))) best = c; } return best; } /** 만들 차례 — 하위 호표(호표 참조 줄이 가리키는 고른 호표)부터 · 순환은 남은 차례대로 */ export function buildOrder(ones: { 번: number; 아래: number[] }[]): number[] { const left = new Map(ones.map((x) => [x.번, x.아래.filter((n) => n !== x.번)])); const out: number[] = []; while (left.size) { const ready = [...left].find(([, kids]) => kids.every((n) => !left.has(n))); const no = ready ? ready[0] : [...left.keys()][0]; out.push(no); left.delete(no); } return out; } /** 단가산출 행 → 행 전체 범위 A1(`4:9`) */ export const rowsText = (행: number | [number, number]): string => typeof 행 === "number" ? `${행}:${행}` : `${행[0]}:${행[1]}`; export function openHopyoSheet(opt: HopyoSheetOptions): void { let sheet: SpreadsheetHandle | null = null; let closed = false; let briefs: HopyoBrief[] = []; let spans: HopyoSpan[] = []; let ilwi = ""; // 일위대가 시트 id let last = "O"; let at = { 시트: "", 칸: "", 범위: "" }; // 시트 고름 let current: number | null = opt.호표 ?? null; const checked = new Set(); const items = new Map(); const left = el("div", { className: "m02-hopyo-sheet__grid", children: [note("엑셀을 읽는 중…")], }); const search = el("input", { className: "ui-input ui-input--sm", attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기" }, }) as HTMLInputElement; const list = el("div", { className: "m02-hopyo-sheet__list" }); const count = note(""); const go = createButton({ label: "고른 0개 확인하고 만들기", variant: "filled", size: "sm" }); go.disabled = true; const result = el("div", { className: "m02-hopyo-sheet__result" }); const right = el("div", { className: "m02-hopyo-sheet__side", children: [ search, el("div", { className: "m02-hopyo__bar", children: [count, ...(opt.canWrite ? [go] : [])] }), list, result, ], }); openModal({ title: `엑셀 보기 — ${opt.파일}`, closeLabel: "닫기", wide: true, dialogClass: "m02-modal m02-hopyo-sheet__dialog", beforeClose: async () => { closed = true; sheet?.destroy(); return true; }, mount: (body) => body.append(el("div", { className: "m02-hopyo-sheet", children: [left, right] })), }); const paintGo = (): void => { go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 확인하고 만들기`; go.disabled = !checked.size; }; /** 목록 강조 · 보이게(시트 고름은 그대로) */ function mark(no: number | null): void { items.get(current ?? NaN)?.classList.remove("is-current"); current = no; const row = items.get(no ?? NaN); row?.classList.add("is-current"); row?.scrollIntoView({ block: "nearest" }); } /** 호표 하나 강조 — 목록 · 시트 영역(같은 범위면 다시 안 부름) */ function focus(no: number): void { mark(no); const span = spans.find((h) => h.번 === no)?.행 ?? briefs.find((h) => h.번 === no)?.행; if (!sheet || !span) return; const text = spanText(span, last); if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi); } function paintList(): void { const q = search.value.trim().toLowerCase(); items.clear(); const shown = briefs.filter( (h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q), ); list.replaceChildren( ...shown.map((h) => { const box = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; box.checked = checked.has(h.번); box.hidden = !opt.canWrite; box.addEventListener("click", (e) => e.stopPropagation()); box.addEventListener("change", () => { if (box.checked) checked.add(h.번); else checked.delete(h.번); paintGo(); }); const row = el("div", { className: `m02-hopyo-sheet__item${h.번 === current ? " is-current" : ""}`, attrs: { "data-hopyo-no": String(h.번), title: `${h.이름} ${h.규격}`.trim() }, children: [ box, el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }), el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }), note(`${h.맞춤[0]}/${h.맞춤[1]}`), ], }); row.addEventListener("click", () => focus(h.번)); items.set(h.번, row); return row; }), ); count.textContent = q ? `${shown.length} / ${briefs.length}` : `호표 ${briefs.length}`; } search.addEventListener("input", paintList); async function mountSheet(grid: HopyoGrid): Promise { const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet"); if (closed) return; const book = structuredClone(grid.책); const first = book.시트.find((s) => s.이름 === ILWI) ?? book.시트[0]; if (!first) return void left.replaceChildren(note("시트 없음")); ilwi = first.id; last = lastColumn(first); book.활성 = first.id; left.replaceChildren(); sheet = createSpreadsheet(left, book, { readOnly: true, onSelect: (s) => { at = s; if (s.시트 !== ilwi) return; const no = hopyoAtRow(spans, Number(/\d+/.exec(s.칸)?.[0])); if (no !== null && no !== current) mark(no); }, }); // 칸을 눌러 뗌 → 그 호표 영역 고름(끌기 · 방향키 도중엔 안 바꿈) left.querySelector(".ss-grid-host")?.addEventListener("mouseup", (e) => { if ((e as MouseEvent).button !== 0 || at.시트 !== ilwi) return; const no = hopyoAtRow(spans, Number(/\d+/.exec(at.칸)?.[0])); if (no !== null) focus(no); }); } /** 뒤 숫자 판정 셈 중이면 3 초 뒤 목록 「제안 n/m」 다시 읽음 — 셈 끝 · 창 닫음 · 못 읽음이면 멈춤 */ function poll(file: HopyoView): void { if (!file.셈 || file.셈.끝) return; window.setTimeout(async () => { const next = closed ? null : await readHopyo(opt.scope, opt.key).catch(() => null); if (!next || closed) return; briefs = next.호표; paintList(); poll(next); }, POLL_MS); } async function load(): Promise { try { const [file, grid] = await Promise.all([ readHopyo(opt.scope, opt.key), readHopyoGrid(opt.scope, opt.key).catch((error: unknown) => new Error(why(error))), ]); if (closed) return; briefs = file.호표; spans = grid instanceof Error ? [] : grid.호표; paintList(); poll(file); if (grid instanceof Error) return void left.replaceChildren(note(grid.message)); await mountSheet(grid); if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN); else if (current !== null) focus(current); } catch (error) { if (!closed) left.replaceChildren(note(`못 읽음 — ${why(error)}`)); } } const table = (heads: string[], rows: HTMLElement[]): HTMLElement => el("table", { className: "ui-general-block__table m02-hopyo__table", children: [ el("thead", { children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], }), el("tbody", { children: rows }), ], }); /** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */ function bodyFor(one: HopyoOne): Parameters[2] { const h = one.호표; const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d); } function wholeFor(one: HopyoOne): Parameters[3] { const h = one.호표; const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null; } /** ① 고른 호표마다 서버 보기 → 펼침 표 */ async function review(): Promise { const target = opt.target ?? opt.scope; const nos = [...checked].sort((a, b) => a - b); go.disabled = true; const ones: HopyoOne[] = []; for (const no of nos) { if (closed) return; try { ones.push(await readHopyoOne(opt.scope, opt.key, no, target)); } catch (error) { showToast(`제${no}호표 — ${why(error)}`, "error"); } } paintGo(); const rows = ones.map((one) => { const h = one.호표; const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); const out = Object.entries(leftOut(h.줄, d)); const tally = (s: string): number => h.줄.filter((l) => l.맞춤.상태 === s).length; const calc = h.셈 && !h.셈.끝 ? `셈 중 ${h.셈.한}/${h.셈.모두}` : `맞음 ${tally("맞음")} · 다른 점 ${tally("다른 점")}`; const brief = briefs.find((x) => x.번 === h.번) ?? h; return el("tr", { attrs: { "data-review": String(h.번) }, children: [ el("td", { text: `제${h.번}호표 ${h.이름}`.trim() }), el("td", { text: wholeFor(one) ? "통째" : String(bodyFor(one).filter((x) => x.대상).length), className: "m02-hopyo__num", }), el("td", { text: out.map(([a, b]) => `${a} ${b}`).join(" · ") }), el("td", { text: calc }), el("td", { children: [ createButton({ label: "확인 창 열기", variant: "ghost", size: "sm", onClick: () => openHopyoCheck({ scope: opt.scope, key: opt.key, no: h.번, target, canWrite: opt.canWrite, onBuilt: (got) => opt.onImport?.(got, brief), }), }), ], }), ], }); }); const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one)); const all = createButton({ label: `이대로 ${ready.length}개 만들기`, variant: "filled", size: "sm", disabled: !ready.length, onClick: () => void makeAll(ready), }); all.setAttribute("data-hopyo-make-all", ""); result.replaceChildren( table(["호표", "넣을 줄", "빠질 줄", "숫자 판정", ""], rows), el("div", { className: "m02-hopyo__bar", children: [all, note("넣을 줄 = 체크 제안 · 확인 창에서 고친 것")], }), ); } /** ② 한 번 누름 = 사용자 확인 — 하위 호표부터 차례로 만들기 */ async function makeAll(ones: HopyoOne[]): Promise { const target = opt.target ?? opt.scope; const by = new Map(ones.map((one) => [one.호표.번, one])); const order = buildOrder( ones.map((one) => { const d = loadDraft(draftKey(opt.scope, opt.key, one.호표.번)); const kids = one.호표.줄.flatMap((l) => partsOf(l, d).flatMap((p) => (p.호표 !== undefined ? [p.호표] : [])), ); return { 번: one.호표.번, 아래: kids }; }), ); const rows: HTMLElement[] = []; let ok = 0; showLoadingOverlay(); try { for (const no of order) { const one = by.get(no)!; const h = briefs.find((x) => x.번 === no) ?? one.호표; const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() }); try { const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no }; const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one)); ok += 1; opt.onImport?.(got, h); rows.push( el("tr", { attrs: { "data-built": got.참조.키 }, children: [ head, el("td", { text: `${got.참조.키} · ${builtWord(got)}` }), el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }), el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }), ], }), ); } catch (error) { rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] })); } } } finally { hideLoadingOverlay(); } if (ok) document.dispatchEvent(new Event(HOPYO_BUILT)); result.replaceChildren(table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], rows)); showToast(`만듦 ${ok} / ${order.length}`, ok === order.length ? "success" : "warning"); } go.addEventListener("click", () => void review()); void load(); }