/* ============================================================================= * M02_MasterTemplete_Hopyo_Sheet.ts * 일위대가 실무자료 엑셀 보기 몸(PLAN 329 · 338-3 · 359-1 · 359-2 · 785-4) — 본 칸 불러오기 보기(`Hopyo_Import`)가 바로 담음 · * 머리 막대 = 시트 · 호표 수 · 검산 · 제안 n / 줄 m · 숫자 판정 셈 중 + 부르는 쪽 덧붙임(`headExtra` — 층 · 안 이은 줄 · [끝내기]) · * 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 셋 일위대가표 · 단가산출표 · 단가산출 상세 · * 칠 · 범례 · 뛰기 · 뒤로 = `Hopyo_SheetNav` · 779-4 · 같은 파일 몸은 다시 씀 = 그 자리로 뜀 · 779-6) · * 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장] · * 단가산출표 · 상세 탭이면 단가산출 목록으로 바뀜(780-2 · `Hopyo_SheetSk`). * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 머리 행이면 영역 고름 * · [수정] = 시트 아래 줄 판(785-6 `Hopyo_Rows` · 줄 누름과 따로) · 단가산출 탭 블록 = 같은 자리 블록 판 · * 오른쪽 판 아래 [옮기기](확인한 줄 · 블록 · 고름 캐시 `createJudgeState` 한 벌) * · [저장] · [모두 고정으로 들여오기] = `Hopyo_SheetMake` * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 저장은 됨 * · `buildHopyoSheet` = 몸(머리 · 띠 · 시트 + 목록) · `openHopyoSheet` = 모달 껍데기([로직 추가] 「실무 호표」 길 — 785-7 이 지움) * ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게). * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { createCheckbox } from "@ui/ui_template_checkbox"; import { createInputField } from "@ui/ui_template_input"; 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 { readHopyo, type HopyoBrief, type HopyoBuilt, type HopyoGrid, type HopyoSpan, type HopyoView, type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; import { POLL_MS } from "./M02_MasterTemplete_Hopyo_Check"; import { createJudgeMove, createJudgeRows, createJudgeState, } from "./M02_MasterTemplete_Hopyo_Rows"; import * as Nav from "./M02_MasterTemplete_Hopyo_SheetNav"; import { createSheetSk, type SheetSk } from "./M02_MasterTemplete_Hopyo_SheetSk"; import { note, why, wireMake } from "./M02_MasterTemplete_Hopyo_SheetMake"; import "./M02_MasterTemplete_Hopyo.css"; export { buildOrder } from "./M02_MasterTemplete_Hopyo_SheetMake"; 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; /** 창을 닫았음(좌측 파일 줄 고름 표시) */ onClose?: () => void; /** 머리 막대 끝에 덧붙일 것(본 칸 불러오기 보기 — 층 · 안 이은 줄 · [끝내기]) */ headExtra?: HTMLElement[]; /** 단가산출 판정 · 옮김 뒤 */ onSkChange?: () => void; /** 엑셀 결과를 읽었음 · 다시 읽었음(뒤 셈 중 3 초 다시 읽기 포함) — 호표 번 목록을 바로 넘김 */ onRead?: (file: HopyoView) => void; } /** 몸 — 머리 막대 · 어긋남 글 · 시트 + 목록(세로로 쌓는 자리에 이어 붙임) */ export interface HopyoSheetBody { parts: HTMLElement[]; /** 몸을 걷음(마지막 창 손잡이 · 격자 · 스프레드시트) */ destroy: () => void; /** 모달에서 앞으로 꺼낸 몸을 제자리로 — 했으면 참(닫기 대신) */ lower: () => boolean; /** 안 옮긴 단가산출 블록 수(판정 전 null) */ skCount: () => { 블록: number; 밖: number; 줄: number } | null; /** 그 단가산출 블록으로 뜀 + 줄 판 엶 */ showSk: (번: number) => void; } const ILWI = "일위대가"; const DANSAN = ["단가산출", "단가산출근거"]; // 형식별 블록 시트(EST · STmate · 역할 id 없는 옛 격자) /** 열린 창 손잡이(779-6) — 파일마다 · 다시 부르면 그 창이 앞으로 와 그 자리로 뜀 */ const opened = new Map void>(); const sheetId = (o: HopyoSheetOptions): string => `${o.scope.layer}:${o.scope.owner ?? ""}:${o.key}`; /** 목록 숫자 「연결된 줄 / 연결할 줄」(358-3 두 칸 · 없으면 옛 맞춤 [제안, 줄]) — 다 연결(같고 0 넘음)이면 참 · * 0/0 은 색 없음 */ export function allLinked(h: { 맞춤: [number, number]; 연결할?: number; 연결된?: number; }): boolean { const [done, all] = h.연결할 === undefined ? h.맞춤 : [h.연결된 ?? 0, h.연결할]; return all > 0 && done === all; } /** 목록 숫자 글 — 358-3 두 칸이 있으면 그것 */ const linkText = (h: { 맞춤: [number, number]; 연결할?: number; 연결된?: number }): string => h.연결할 === undefined ? `${h.맞춤[0]}/${h.맞춤[1]}` : `${h.연결된 ?? 0}/${h.연결할}`; /** 엑셀 행(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; } /** 단가산출 행 → B ~ F 열 범위 A1(`B4:F9`) */ export const rowsText = (행: number | [number, number]): string => typeof 행 === "number" ? `B${행}:F${행}` : `B${행[0]}:F${행[1]}`; /** 모달 껍데기 — 같은 파일 몸이 이미 있으면(본 칸 · 모달) 그 몸을 그 자리로 뜀 */ export function openHopyoSheet(opt: HopyoSheetOptions): void { const id = sheetId(opt); if (opened.has(id)) return opened.get(id)!(opt); const view = buildHopyoSheet(opt); openModal({ title: `엑셀 보기 — ${opt.파일}`, closeLabel: "닫기", wide: true, dialogClass: "m02-modal m02-hopyo-sheet__dialog", beforeClose: async () => { if (view.lower()) return false; // 꺼낸 창 = 닫기 대신 제자리로 view.destroy(); return true; }, mount: (body) => body.append(...view.parts), }); } export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { const id = sheetId(opt); let sheet: SpreadsheetHandle | null = null; let nav: Nav.SheetNav | null = null; let sk: SheetSk | null = null; // 단가산출 탭 오른쪽 판(780-2) let detail = ""; // 단가산출 상세 시트 id let above: Element[] = []; // 앞으로 꺼냈을 때 원래 위에 있던 창 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 target = opt.target ?? opt.scope; const state = createJudgeState(opt.scope, opt.key); // 판정 고름 캐시(785-6) let panel: { destroy: () => void } | null = null; // 시트 아래 줄 판 몸 const head = el("div", { className: "ui-toolbar m02-hopyo__bar", children: [note("엑셀을 읽는 중…")], }); const warn = el("p", { className: "ui-text ui-text--warning m02-hopyo__warn", attrs: { "data-text": "14" }, }); warn.hidden = true; const left = el("div", { className: "m02-hopyo-sheet__grid", children: [note("엑셀을 읽는 중…")], }); const lines = el("div", { className: "m02-hopyo-sheet__lines", attrs: { "data-hopyo-lines": "" }, }); // 시트 아래 줄 판 자리 lines.hidden = true; const closeLines = (): void => { panel?.destroy(); panel = null; lines.hidden = true; }; const openLines = (make: (host: HTMLElement) => { destroy: () => void }): void => { panel?.destroy(); lines.hidden = false; panel = make(lines); }; const col = el("div", { className: "m02-hopyo-sheet__col", children: [left, lines] }); const searchField = createInputField({ type: "search", placeholder: "호표 · 품명 · 규격 찾기", compact: true, onInput: () => paintList(), }); const search = searchField.input; const list = el("div", { className: "m02-hopyo-sheet__list" }); const count = note(""); const go = createButton({ label: "저장", variant: "filled", size: "sm" }); go.disabled = true; go.setAttribute("data-hopyo-save", ""); const fixAll = createButton({ label: "모두 고정으로 들여오기", size: "sm" }); fixAll.setAttribute("data-hopyo-all-fixed", ""); fixAll.disabled = true; // 엑셀을 다 읽은 뒤 켬 const result = el("div", { className: "m02-hopyo-sheet__result" }); const move = createJudgeMove({ file: opt.scope, key: opt.key, target, state, readOnly: !opt.canWrite, onMoved: () => void refresh(), }); const right = el("div", { className: "m02-hopyo-sheet__side", children: [ searchField.root, el("div", { className: "ui-toolbar m02-hopyo__bar", children: [count, ...(opt.canWrite ? [fixAll, go] : [])], }), list, result, move.root, ], }); const finish = (): void => { if (closed) return; closed = true; opened.delete(id); closeLines(); move.destroy(); sk?.destroy(); nav?.destroy(); sheet?.destroy(); opt.onClose?.(); }; const paintGo = (): void => { go.querySelector(".ui-btn__label")!.textContent = checked.size ? `저장 (${checked.size})` : "저장"; go.disabled = !checked.size; }; /** 머리 막대 — 시트 · 호표 수 · 검산 · 제안 · 셈 중 + 덧붙임 */ function paintHead(file: HopyoView): void { const wrong = file.검산.어긋남; const c = file.셈 && !file.셈.끝 ? file.셈 : null; head.replaceChildren( note(`시트 ${file.시트}`), note(`호표 ${file.호표수}`), note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"), note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`), ...(c ? [note(`숫자 판정 셈 중 ${c.한}/${c.모두 ?? "?"}${c.까닭 ? ` · ${c.까닭}` : ""}`)] : []), ...(opt.headExtra ?? []), ); opt.onRead?.(file); warn.textContent = wrong.join("\n"); warn.hidden = !wrong.length; } /** 목록 강조 · 보이게(시트 고름은 그대로) */ 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); } opened.set(id, (o) => { above = Nav.raise(left); // 모달이 아니면 [] — 그 자리로만 뜀 if (o.단산행 !== undefined && detail) nav?.jumpTo(detail, o.단산행); else if (o.호표 !== undefined) focus(o.호표); }); /** [수정] — 그 호표 자리 + 시트 아래 줄 판(줄 누름과 따로) */ const editButton = (h: HopyoBrief): HTMLElement => { const b = createButton({ label: "수정", variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); focus(h.번); openLines((host) => createJudgeRows({ host, file: opt.scope, key: opt.key, target, state, no: h.번, readOnly: !opt.canWrite, onClose: closeLines, onSheet: (r, role) => nav?.jumpTo(role === "일위대가표" ? ilwi : detail, r), }), ); }, }); b.setAttribute("data-hopyo-edit", String(h.번)); return b; }; 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 = createCheckbox("", { checked: checked.has(h.번), onChange: (on) => { if (on) checked.add(h.번); else checked.delete(h.번); paintGo(); }, }); box.root.hidden = !opt.canWrite; box.root.addEventListener("click", (e) => e.stopPropagation()); const linked = note(linkText(h)); linked.classList.toggle("is-linked", allLinked(h)); 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.root, el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }), editButton(h), el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }), linked, ], }); row.addEventListener("click", () => focus(h.번)); items.set(h.번, row); return row; }), ); count.textContent = q ? `${shown.length} / ${briefs.length}` : `호표 ${briefs.length}`; } async function mountSheet(grid: HopyoGrid): Promise { const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet"); const { fillMissingWidths } = await import("../A00_Common/spreadsheet/spreadsheet_autofit"); if (closed) return; const book = structuredClone(grid.책); fillMissingWidths(book); // 엑셀 폭이 없는 열 = 글 길이로 처음 폭 const ids = Nav.nameTabs(book, grid); // 탭 글 통일(779-4) const first = book.시트.find((s) => s.id === ids.일위대가표 || s.이름 === ILWI) ?? book.시트[0]; if (!first) return void left.replaceChildren(note("시트 없음")); ilwi = first.id; detail = ids.단가산출상세 ?? ""; last = lastColumn(first); book.활성 = first.id; left.replaceChildren(); const lastOf = (sid: string): string => lastColumn(book.시트.find((x) => x.id === sid) ?? first); const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left }; nav = ids.일위대가표 ? Nav.createSheetNav(more) : null; if (nav) warn.after(nav.root); sk = nav ? createSheetSk({ ...opt, target, nav, state, side: right, openLines, closeLines, ...(opt.onSkChange ? { onChange: opt.onSkChange } : {}) }) : null; // prettier-ignore sheet = createSpreadsheet(left, book, { readOnly: true, 칠: nav?.fill, onSelect: (s) => { at = s; nav?.at(s); sk?.at(s); if (s.시트 !== ilwi) return; const no = hopyoAtRow(spans, Number(/\d+/.exec(s.칸)?.[0])); if (no !== null && no !== current) mark(no); }, }); nav?.marks(briefs); // 머리 행 칸을 눌러 뗌 → 그 호표 영역 고름(끌기 · 방향키 도중엔 안 바꿈 · 줄 행은 그 줄 그대로) left.querySelector(".ss-grid-host")?.addEventListener("mouseup", (e) => { if ((e as MouseEvent).button !== 0 || at.시트 !== ilwi) return; const row = Number(/\d+/.exec(at.칸)?.[0]); const no = hopyoAtRow(spans, row); if (no !== null && spans.find((h) => h.번 === no)?.행[0] === row) 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.호표; paintHead(next); paintList(); nav?.marks(briefs); poll(next); }, POLL_MS); } /** 옮긴 뒤 — 목록 · 칠 · 단가산출 목록 다시 읽음 */ async function refresh(): Promise { const next = await readHopyo(opt.scope, opt.key).catch(() => null); if (!next || closed) return; briefs = next.호표; paintHead(next); paintList(); nav?.marks(briefs); sk?.reload(); opt.onSkChange?.(); } async function load(): Promise { try { const [file, grid] = await Promise.all([ readHopyo(opt.scope, opt.key), Nav.gridOf(id, opt.scope, opt.key).catch((error: unknown) => new Error(why(error))), ]); if (closed) return; briefs = file.호표; fixAll.disabled = !briefs.length; spans = grid instanceof Error ? [] : grid.호표; paintHead(file); paintList(); poll(file); if (grid instanceof Error) return void left.replaceChildren(note(grid.message)); await mountSheet(grid); const rows = opt.단산행 === undefined ? null : rowsText(opt.단산행); if (rows) [detail, ...DANSAN].some((name) => name && sheet?.select(rows, name)); else if (current !== null) focus(current); } catch (error) { if (!closed) { head.replaceChildren(note(`못 읽음 — ${why(error)}`)); left.replaceChildren(note(`못 읽음 — ${why(error)}`)); } } } wireMake({ scope: opt.scope, key: opt.key, 파일: opt.파일, target, ...(opt.onImport ? { onImport: opt.onImport } : {}), checked, briefs: () => briefs, closed: () => closed, go, fixAll, result, paintGo, }); void load(); return { parts: [head, warn, el("div", { className: "m02-hopyo-sheet", children: [col, right] })], destroy: finish, lower: () => Nav.lower(left, above.splice(0)), skCount: () => sk?.count() ?? null, showSk: (번) => sk?.show(번), }; }