/* ============================================================================= * M02_MasterTemplete_Hopyo_Sheet.ts * 일위대가 실무자료 엑셀 보기 몸(PLAN 329 · 338-3 · 359-1 · 359-2 · 785-4) — 본 칸 불러오기 보기(`Hopyo_Import`)가 바로 담음 · * 머리 막대 = 시트 · 호표 수 · 검산 · 제안 n / 줄 m(1차 판정) · 블록 풀이 셈 중 + 부르는 쪽 덧붙임(`headExtra` — 층 · [끝내기]) · * 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 셋 일위대가표 · 단가산출표 · 단가산출 상세 · * 칠 · 범례 · 뛰기 · 뒤로 = `Hopyo_SheetNav` · 779-4 · 같은 파일 격자 다시 씀 · 779-6) · * 오른쪽 = 호표 목록(제N호표 [수정] · 찾기 · 제안 n/m — 다 제안이면 녹색 · 뒤 셈 중이면 3 초마다 다시) · * 단가산출표 · 상세 탭이면 단가산출 목록으로 바뀜(780-2 · `Hopyo_SheetSk`). * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 머리 행이면 영역 고름 * · [수정] = 모달 「확인 — 제N호표」(786-2 `Hopyo_Check` · 줄 누름과 따로) · 단가산출 탭 블록 = 시트 아래 블록 판 · * 오른쪽 판 아래 [옮기기](확인한 줄 · 블록 · 고름 캐시 `createJudgeState` 한 벌) * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록은 됨 * · `buildHopyoSheet` = 몸(머리 · 띠 · 시트 + 목록) · 옛 [저장] · [모두 고정으로 들여오기] · 모달 껍데기는 785-7 에서 지움 * (만들기 = [옮기기] · 모두 고정 서버 길은 B09 · 붙여넣기가 씀) * ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게). * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import { POLL_MS, readHopyo, type HopyoBrief, type HopyoGrid, type HopyoSpan, type HopyoView, type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; import { openHopyoCheck } from "./M02_MasterTemplete_Hopyo_Check"; import { createJudgeMove, createJudgeState } from "./M02_MasterTemplete_Hopyo_Rows"; import { lineOf } from "./logic/M02_MasterTemplete_Hopyo_Picks"; import * as Nav from "./M02_MasterTemplete_Hopyo_SheetNav"; import { createSheetSk, type SheetSk } from "./M02_MasterTemplete_Hopyo_SheetSk"; import "./M02_MasterTemplete_Hopyo.css"; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const note = (text: string): HTMLElement => el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } }); export interface HopyoSheetOptions { /** 실무자료 파일 층 */ scope: HopyoScope; key: string; 파일: string; /** 만들 층 쓰기 권한 — 없으면 옮기기 없음(보기만) */ canWrite: boolean; /** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */ target?: HopyoScope; /** 처음 고를 호표 번(확인 창 머리 [엑셀 보기]) */ 호표?: number; /** 처음 보일 단가산출 행(330-3 근거 [엑셀 보기]) */ 단산행?: number | [number, number]; /** 창을 닫았음(좌측 파일 줄 고름 표시) */ onClose?: () => void; /** 머리 막대 끝에 덧붙일 것(본 칸 불러오기 보기 — 층 · 안 이은 줄 · [끝내기]) */ headExtra?: HTMLElement[]; /** 단가산출 판정 · 옮김 뒤 */ onSkChange?: () => void; /** 엑셀 결과를 읽었음 · 다시 읽었음(뒤 셈 중 3 초 다시 읽기 포함) — 호표 번 목록을 바로 넘김 */ onRead?: (file: HopyoView) => void; } /** 몸 — 머리 막대 · 어긋남 글 · 시트 + 목록(세로로 쌓는 자리에 이어 붙임) */ export interface HopyoSheetBody { parts: HTMLElement[]; /** 몸을 걷음(마지막 창 손잡이 · 격자 · 스프레드시트) */ destroy: () => void; /** 안 옮긴 단가산출 블록 수(판정 전 null) */ skCount: () => { 블록: number; 밖: number; 줄: number } | null; /** 그 단가산출 블록으로 뜀 + 블록 판 엶 */ showSk: (번: number) => void; } const ILWI = "일위대가"; const DANSAN = ["단가산출", "단가산출근거"]; // 형식별 블록 시트(EST · STmate · 역할 id 없는 옛 격자) 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 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 closed = false; let briefs: HopyoBrief[] = []; let spans: HopyoSpan[] = []; let ilwi = ""; // 일위대가 시트 id let last = "O"; let at = { 시트: "", 칸: "", 범위: "" }; // 시트 고름 let current: number | null = opt.호표 ?? null; 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 offPaint = state.on(() => nav?.repaint()); // 확인 바뀜 = 다시 칠 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": "" }, }); // 시트 아래 블록 판 자리(단가산출 탭 · SheetSk) 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 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], }), list, move.root, ], }); const finish = (): void => { if (closed) return; closed = true; closeLines(); offPaint(); move.destroy(); sk?.destroy(); nav?.destroy(); sheet?.destroy(); opt.onClose?.(); }; /** 머리 막대 — 시트 · 호표 수 · 검산 · 제안 · 셈 중 + 덧붙임 */ function paintHead(file: HopyoView): void { const wrong = file.검산.어긋남; const busy = !!file.셈 && !file.셈.끝; head.replaceChildren( note(`시트 ${file.시트}`), note(`호표 ${file.호표수}`), note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"), note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`), ...(busy ? [note(`${file.셈?.까닭 ?? "블록 풀이"} 셈 중`)] : []), ...(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); } /** [수정] — 그 호표 자리 + 모달 「확인 — 제N호표」(786-2 · 줄 누름과 따로) */ const editButton = (h: HopyoBrief): HTMLElement => { const b = createButton({ label: "수정", variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); focus(h.번); openHopyoCheck({ file: opt.scope, key: opt.key, target, state, no: h.번, brief: h, canWrite: opt.canWrite, onSheet: (r, role) => nav?.jumpTo(role === "일위대가표" ? ilwi : detail, r), onHopyo: () => focus(h.번), onMoved: () => void refresh(), }); }, }); 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 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: [ 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 judged = (no: number, at: number): boolean => !!lineOf(state.get(), no, at)?.확인; const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left, judged }; nav = ids.일위대가표 ? Nav.createSheetNav(more) : null; if (nav) warn.after(nav.root); sk = nav ? createSheetSk({ ...opt, target, nav, state, side: right, openLines, closeLines, onMoved: () => void refresh(), ...(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.호표; 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)}`)); } } } void load(); return { parts: [head, warn, el("div", { className: "m02-hopyo-sheet", children: [col, right] })], destroy: finish, skCount: () => sk?.count() ?? null, showSk: (번) => sk?.show(번), }; }