diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 7a00414cc..134403225 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -149,3 +149,81 @@ gap: var(--spacing-4); margin-bottom: var(--spacing-8); } + +/* 엑셀 보기 창(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 · 불러오기 결과 */ +.m02-hopyo-sheet__dialog > .ui-modal__body { + overflow: hidden; +} + +.m02-hopyo-sheet { + display: grid; + grid-template-columns: minmax(0, 1fr) 22rem; + gap: var(--spacing-8); + flex: 1; + min-height: 0; +} + +.m02-hopyo-sheet__grid { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.m02-hopyo-sheet__side { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-height: 0; +} + +.m02-hopyo-sheet__list { + flex: 1; + min-height: 0; + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + +.m02-hopyo-sheet__item { + display: flex; + align-items: center; + gap: var(--spacing-4); + padding: var(--spacing-4) var(--spacing-8); + border-left: 3px solid transparent; + cursor: pointer; +} + +.m02-hopyo-sheet__item:hover { + background: var(--color-surface); +} + +.m02-hopyo-sheet__item.is-current { + border-left-color: var(--color-accent); + background: var(--color-mist-violet); +} + +.m02-hopyo-sheet__no { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.m02-hopyo-sheet__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m02-hopyo-sheet__result { + max-height: 40%; + overflow: auto; +} + +.m02-hopyo-sheet__result:empty { + display: none; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index e11e7dcec..c4ef47641 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -5,6 +5,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; @@ -34,6 +35,22 @@ export interface HopyoBrief { 금액: Money; /** [들어감 수, 줄 수] */ 맞춤: [number, number]; + /** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */ + 행?: [number, number]; +} + +/** 엑셀 보기 창 목록 한 줄 — 호표 · 단가산출 블록 행 범위 */ +export interface HopyoSpan { + 번: number; + 이름: string; + 행: [number, number]; +} + +/** 엑셀 보기 창 격자(329) — 시트 「일위대가」 · 「단가산출」 */ +export interface HopyoGrid { + 책: Workbook; + 호표: HopyoSpan[]; + 단산: HopyoSpan[]; } export interface HopyoView extends HopyoFile { @@ -136,6 +153,10 @@ export const listHopyo = async (scope: HopyoScope): Promise => export const readHopyo = (scope: HopyoScope, key: string): Promise => call(at(scope, `/hopyo/${enc(key)}`)); +/** 격자 — 없으면 404 「격자 없음 — 다시 올리면 보임」(옛 파일) */ +export const readHopyoGrid = (scope: HopyoScope, key: string): Promise => + call(at(scope, `/hopyo-grid/${enc(key)}`)); + export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise => call(at(scope, `/hopyo/${enc(key)}/${no}`)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts index 04cdd4c89..c460ce6a3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts @@ -2,7 +2,8 @@ * M02_MasterTemplete_Hopyo_Pick.ts * [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) · * 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로 - * 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림. 프로젝트 층엔 없음. + * 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림 · 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 · + * 고른 호표마다 참조 줄). 프로젝트 층엔 없음. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; @@ -25,7 +26,42 @@ export function withHopyo(lib: LibraryPick): LibraryPick { { value: "precombo", text: tc("Kind_Combo") }, { value: HOPYO_KIND, text: "실무 호표" }, ], - more: async () => (await listHopyoPicks(scope)).map((row) => hopyoExtra(row, lib)), + more: async () => { + const rows = await listHopyoPicks(scope); + const files = new Map(); + for (const row of rows) { + const key = `${row.층}|${row.주인 ?? ""}|${row.키}`; + files.set(key, [...(files.get(key) ?? []), row]); + } + return [ + ...rows.map((row) => hopyoExtra(row, lib)), + ...[...files].map(([key, list]) => sheetExtra(key, list[0], list.length, lib)), + ]; + }, + }; +} + +/** 파일 하나 — 엑셀 보기 창에서 여러 호표를 골라 한 번에(만드는 층 = 문서 층) */ +export function sheetExtra(key: string, row: HopyoPickRow, n: number, lib: LibraryPick): PickExtra { + return { + kind: HOPYO_KIND, + key: `${key}|엑셀`, + tag: "실무 호표", + name: "엑셀로 보기 · 여러 호표", + sub: `${row.파일} · ${LAYER_TEXT[row.층] ?? row.층}`, + unit: `호표 ${n}`, + onPick: () => + void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => + m.openHopyoSheet({ + scope: { layer: row.층, owner: row.주인 }, + key: row.키, + 파일: row.파일, + canWrite: true, + target: { layer: lib.layer, owner: lib.owner }, + onImport: (got, h) => + lib.pick(got.참조, got.이름, h.이름.slice(0, ALIAS_MAX).trim() || null), + }), + ), }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts new file mode 100644 index 000000000..e547de48e --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -0,0 +1,282 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Sheet.ts + * 일위대가 실무자료 엑셀 보기 창(PLAN 329) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 + * 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 맞춤 n/m) + [고른 n개 불러오기]. + * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름 + * · 불러오기 = 고른 호표마다 차례로 사전조합 만들기(`/hopyo-build`) → 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · + * `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄) + * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 불러오기는 됨 + * ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게). + * ========================================================================== */ + +import { createButton, el, 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, + type HopyoBrief, + type HopyoBuilt, + type HopyoGrid, + type HopyoScope, + type HopyoSpan, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { builtWord } from "./M02_MasterTemplete_Hopyo_View"; +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; +} + +/** 단가산출 행 → 행 전체 범위 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); + }); + } + + 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(); + 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)}`)); + } + } + + async function importPicked(): Promise { + const nos = [...checked].sort((a, b) => a - b); + const target = opt.target ?? opt.scope; + const rows: HTMLElement[] = []; + let ok = 0; + go.disabled = true; + for (const no of nos) { + const h = briefs.find((x) => x.번 === no); + if (!h || closed) continue; + 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); + ok += 1; + opt.onImport?.(got, h); + rows.push( + el("tr", { + attrs: { "data-built": got.참조.키 }, + children: [ + head, + el("td", { text: `${got.참조.키} · ${builtWord(got, false)}` }), + 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)}` })] })); + } + result.replaceChildren( + el("table", { + className: "ui-general-block__table m02-hopyo__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: ["호표", "사전조합", "빠진 줄", "값 차이"].map((t) => + el("th", { text: t }), + ), + }), + ], + }), + el("tbody", { children: rows }), + ], + }), + ); + } + paintGo(); + showToast(`불러옴 ${ok} / ${nos.length}`, ok === nos.length ? "success" : "warning"); + } + go.addEventListener("click", () => void importPicked()); + + void load(); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index 74ce15703..3bd583efb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -2,7 +2,8 @@ * M02_MasterTemplete_Hopyo_Side.ts * 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) · * [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면 - * (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만. + * (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 · + * 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶. * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; @@ -66,6 +67,8 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success"); await refresh(); await show(got); + const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet"); + sheet.openHopyoSheet({ scope, key: got.키, 파일: got.파일, canWrite: opt.canWrite }); }) .catch((error) => showToast(why(error), "error")) .finally(() => (upload.disabled = !opt.canWrite)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts index 5601885cf..3aa5fe2c8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -2,7 +2,8 @@ * M02_MasterTemplete_Hopyo_View.ts * 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · * [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 · - * [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기]. + * [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] · + * [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채). * 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만. * ========================================================================== */ @@ -25,6 +26,7 @@ import { type HopyoTarget, type HopyoView, } from "./M02_MasterTemplete_Hopyo_Api"; +import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { pickMaterial } from "./M02_Material_Pick"; import "./M02_MasterTemplete_Hopyo.css"; @@ -52,6 +54,9 @@ const why = (error: unknown): string => (error instanceof Error ? error.message const won = (v: number | null | undefined): string => v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error"); +/** 엑셀 보기 창(329) — 열 때 불러옴 */ +const openSheet = (o: HopyoSheetOptions): void => + void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail); const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement => el("div", { @@ -176,6 +181,12 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement note(`호표 ${file.호표수}`), note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"), note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`), + createButton({ + label: "엑셀로 보기", + size: "sm", + onClick: () => + openSheet({ scope: opt.scope, key, 파일: file.파일, canWrite: opt.canWrite }), + }), ...(opt.canWrite ? [dropButton(file)] : []), ], }), @@ -240,6 +251,18 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement note(h.단위), note(`엑셀 단가 ${won(h.금액.계)}`), note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`), + createButton({ + label: "엑셀에서 보기", + size: "sm", + onClick: () => + openSheet({ + scope: opt.scope, + key, + 파일: got.파일, + canWrite: opt.canWrite, + 호표: no, + }), + }), ...tools, ], }), diff --git a/resources/tester/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs index a061c282c..1a389f69f 100644 --- a/resources/tester/helper_m02_hopyo_pick.cjs +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -1,6 +1,7 @@ /* M02 일위대가 실무자료(324-4) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) · - * `M02_MasterTemplete_Hopyo_View.ts` 글 도우미를 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · - * stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view} JSON. */ + * `M02_MasterTemplete_Hopyo_View.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를 + * 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · + * stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view, sheet} JSON. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); @@ -55,6 +56,13 @@ function loadTs(file) { await pick.pickHopyo(input.rows[0], lib); const project = pick.withHopyo({ ...lib, layer: "project" }); const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts")); + const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts")); + const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts")); + await api.readHopyoGrid({ layer: "company", owner: "7" }, "HP-000001"); + const spans = [ + { 번: 3, 이름: "임도표지석", 행: [20, 24] }, + { 번: 4, 이름: "배수관", 행: [25, 30] }, + ]; const out = { calls, kinds: wrapped.kindFilter.map((k) => k.value), @@ -80,6 +88,12 @@ function loadTs(file) { view.builtWord({ 다시씀: true }, true), ], }, + sheet: { + at: [19, 20, 22, 24, 25, 30, 31].map((row) => sheet.hopyoAtRow(spans, row)), + span: sheet.spanText([20, 24], sheet.lastColumn({ 칸: { A1: {}, N24: {} }, 열: { O: {} } })), + last: [sheet.lastColumn({ 칸: { Z9: {}, AA1: {} } }), sheet.lastColumn({ 칸: {} })], + rows: [sheet.rowsText(4345), sheet.rowsText([93, 101])], + }, }; process.stdout.write(JSON.stringify(out)); })().catch((error) => { diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 46345114b..804e09399 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -3,6 +3,7 @@ - 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 · 고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 · 본 화면 글 도우미 +- 엑셀 보기 창(329): 행 → 호표 · 호표 영역 A1 · 격자 길 · 여는 자리 넷 · 늦게 불러옴 - 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 · 숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰 """ @@ -15,7 +16,7 @@ from pathlib import Path HERE = os.path.dirname(os.path.abspath(__file__)) M02 = Path(HERE).parents[1] / "M02_MasterTemplete" -NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View") +NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View", "Hopyo_Sheet") def _text(name: str) -> str: @@ -68,7 +69,15 @@ def test_고르기_창_실무_호표_줄과_고름(): "name": "임도표지석 910×600×150", "sub": "북부청.xls · 제3호표 · 마스터", "unit": "개소 · 맞춤 2/4", - } + }, + { + "kind": "hopyo", + "key": "system||HP-000001|엑셀", + "tag": "실무 호표", + "name": "엑셀로 보기 · 여러 호표", + "sub": "북부청.xls · 마스터", + "unit": "호표 1", + }, ] build = got["calls"][1] assert build["url"] == "/api/m02/layers/company/hopyo-build?owner=7" # 만드는 층 = 문서 층 @@ -145,3 +154,37 @@ def test_로직_추가_한_곳과_파일_규칙(): css = _text("M02_MasterTemplete_Hopyo.css") assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css) assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_View.ts") + + +def test_엑셀_보기_창_행과_호표(): + got = _run() + assert got["calls"][2] == { + "url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7", + "method": "GET", + "body": None, + } + sheet = got["sheet"] + assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음 + assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O) + assert sheet["last"] == ["AA", "A"] + assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체 + + +def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): + lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")' + side = _text("M02_MasterTemplete_Hopyo_Side.ts") + assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동 + view = _text("M02_MasterTemplete_Hopyo_View.ts") + assert lazy in view and '"엑셀로 보기"' in view and '"엑셀에서 보기"' in view + assert "호표: no" in view # 호표 화면 = 그 호표 고른 채 + pick = _text("M02_MasterTemplete_Hopyo_Pick.ts") + assert lazy in pick and "lib.pick(got.참조" in pick + static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"' + for text in (side, view, pick): + assert not re.search(static, text, re.M) # 정적 import 없음(타입만) + sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts") + assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet + assert not re.search( + r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M + ) + assert "readOnly: true" in sheet and "wide: true" in sheet