diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index 35b74d67..92a996e0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -78,3 +78,37 @@ padding: var(--spacing-16); color: var(--color-text-muted); } + +/* 조회 후 선택 창 — 찾기 칸 + 목록(번호 · 이름) */ +.m02-pick__list { + display: flex; + flex-direction: column; + max-height: 55vh; + overflow: auto; + margin: var(--spacing-8) 0; +} + +.m02-pick__item { + display: flex; + gap: var(--spacing-12); + padding: var(--spacing-8); + border: none; + border-radius: var(--radius-sm, 4px); + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.m02-pick__item:hover, +.m02-pick__item:focus-visible { + background: var(--color-surface-sunken); +} + +.m02-pick__key { + flex: none; + min-width: 6rem; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} \ No newline at end of file diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 84b123e5..ad0a7ce5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -9,13 +9,25 @@ * 구조물 도면은 집계표 열마다 하나(`resources/master_template/structure/<열 id>.json`) · * 서버가 열마다 미리 만들어 둠(도번 자동 · 새 열도 집계표 저장 때 서버가 같이 만듦 — `M02_MasterTemplete_Store.py`). * 이 칸은 표 부품 host 밖 형제 자리에 붙음 — 집계표는 그대로 두고 여기만 열마다 다시 그림. + * 집계표 일위대가 · 도면 · 산출근거 줄의 조회 후 선택(`pick`)도 여기 — 일위대가 = M01 일위대가 목록에서 고름 · + * 도면 · 산출근거 = 다른 열 것을 골라 내용만 복사해 가져옴(번호 · 열은 그대로 · 서버가 복사). * 글자는 이 파일에 둠(도면 부품 `M02_MasterTemplete_Drawing.ts` 와 같은 방식). * ========================================================================== */ -import { createButton, el, showToast } from "@ui/ui_template_elements"; -import { attachCollapsible } from "@ui/ui_template_collapsible"; -import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { + createButton, + createInputField, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { openModal } from "@ui/ui_template_modal"; +import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import { type ComboSummary, fetchCombos } from "../M01_MasterData/M01_MasterData_UI_Combo_Api"; +import { + copyBasis, + copyStructure, fetchStructureNumbers, readTemplate, type StructureDoc, @@ -41,6 +53,20 @@ const TEXT = { noBasis: "산출근거 없음", noSheet: "스프레드시트를 못 띄움 — {value}", readFailed: "구조물 도면을 못 읽음 — {value}", + close: "닫기", + find: "찾기", + clear: "비움", + none: "고를 것이 없음", + pickPrice: "일위대가 고르기", + pickDrawing: "도면 복사해 가져오기", + pickBasis: "산출근거 복사해 가져오기", + combosFailed: "일위대가 목록을 못 받음 — {value}", + saveFirst: "새 열은 집계표를 먼저 저장해야 번호가 생김", + overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로", + drawing: "도면", + basisWord: "산출근거", + copied: "복사해 가져옴", + copyFailed: "복사 실패 — {value}", }; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); @@ -48,6 +74,93 @@ const fill = (text: string, value: string): string => text.replace("{value}", va /** 스프레드시트는 산출근거 카드를 열 때만 받음 */ const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet"); +/** `{열 id: {도번, 표번}}` — 집계표 도면 · 산출근거 줄(없는 열은 키 없음 = 「없음」) */ +export type ColumnNumbers = Record; + +/** 일위대가 목록 — 처음 한 번만 받음(못 받으면 다음에 다시) */ +let combos: Promise | null = null; +const loadCombos = (): Promise => + (combos ??= fetchCombos().catch((error: unknown) => { + combos = null; + throw error; + })); + +interface Choice { + value: string; + key: string; + label: string; +} + +/** 찾기 칸 + 목록 창 — 고른 값(「비움」 = 빈 글) · 그냥 닫으면 null */ +function choose(title: string, items: Choice[], clearable: boolean): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (value: string | null): void => { + if (!done) resolve(value); + done = true; + }; + openModal({ + title, + closeLabel: TEXT.close, + dialogClass: "m02-modal m02-pick", + mount: (body, close) => { + const search = createInputField({ placeholder: TEXT.find, type: "search" }); + const list = el("div", { className: "m02-pick__list" }); + const paint = (): void => { + const words = search.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean); + const hits = items.filter((it) => + words.every((w) => `${it.key} ${it.label}`.toLowerCase().includes(w)), + ); + list.replaceChildren( + ...(hits.length + ? hits.map((it) => + el("button", { + className: "m02-pick__item", + attrs: { type: "button", "data-value": it.value }, + children: [el("span", { className: "m02-pick__key", text: it.key }), it.label], + }), + ) + : [el("p", { className: "m02-struct__empty", text: TEXT.none })]), + ); + }; + list.addEventListener("click", (event) => { + const item = (event.target as HTMLElement).closest("[data-value]"); + if (!item) return; + finish(item.dataset.value!); + close(); + }); + search.input.addEventListener("input", paint); + search.input.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + list.querySelector("[data-value]")?.click(); // 첫 줄 + }); + const foot = clearable + ? [ + createButton({ + label: TEXT.clear, + variant: "ghost", + onClick: () => { + finish(""); + close(); + }, + }), + ] + : []; + body.append(search.root, list, ...foot); + paint(); + search.input.focus(); + }, + }); + // 닫기 단추 · Esc · 바깥 누르기 — 창이 빠지면 null + const watch = new MutationObserver(() => { + if (document.querySelector(".m02-pick")) return; + watch.disconnect(); + finish(null); + }); + watch.observe(document.body, { childList: true }); + }); +} + /** 집계표 열 — 머리 글만 씀 */ export interface StructureColumn { id: string; @@ -70,17 +183,21 @@ export interface StructurePanelOptions { export interface StructurePanelHandle { root: HTMLElement; - /** `{열 id: 도번}` — 집계표 `drawingLabel` 이 씀 */ - numbers: () => Record; - /** 도번 목록 다시 받기 */ + /** `{열 id: {도번, 표번}}` — 집계표 `drawingLabel` · `basisLabel` 이 씀 */ + numbers: () => ColumnNumbers; + /** 도번 · 표번 목록 다시 받기 */ reload: () => Promise; /** 고른 열이 바뀜 — 같은 열이면 아무것도 안 함(CAD 를 다시 안 띄움) */ show: (colId: string | null, columns: StructureColumn[]) => void; + /** 집계표 조회 후 선택(`onPick`) — 일위대가 = 고른 키(「비움」 = 빈 글 · 닫으면 null) · + * 도면 · 산출근거 = 다른 열 것을 복사해 가져옴(돌려주는 글 없음) */ + pick: (row: string, colId: string | null, columns: StructureColumn[]) => Promise; destroy: () => void; } export function createStructurePanel(opts: StructurePanelOptions): StructurePanelHandle { - let numbers: Record = {}; + let numbers: ColumnNumbers = {}; + let shown: StructureColumn | null = null; let column: string | null = null; let columnLabel = ""; let doc: StructureDoc | null = null; @@ -162,9 +279,9 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane /** 미리보기 · 산출근거 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */ const paintBody = (mine: number): void => { drop(); - number.textContent = doc?.도번 || numbers[column ?? ""] || TEXT.noNumber; + number.textContent = doc?.도번 || numbers[column ?? ""]?.도번 || TEXT.noNumber; edit.hidden = !opts.isAdmin; - basisNo.textContent = basisNumber || TEXT.noTableNumber; + basisNo.textContent = basisNumber || numbers[column ?? ""]?.표번 || TEXT.noTableNumber; editBasis.hidden = !opts.isAdmin; basisHost.replaceChildren(); void paintBasis(mine); @@ -186,6 +303,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane async function load(col: StructureColumn | null): Promise { const mine = ++seq; drop(); + shown = col; if (!col) { root.hidden = true; column = null; @@ -196,7 +314,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane column = col.id; columnLabel = columnHead(col); columnName.textContent = columnLabel; - number.textContent = numbers[col.id] ?? TEXT.noNumber; + number.textContent = numbers[col.id]?.도번 ?? TEXT.noNumber; doc = null; basisDoc = null; basisNumber = ""; @@ -222,6 +340,69 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane void load(colId ? (columns.find((c) => c.id === colId) ?? null) : null); }; + const pickPrice = async (colId: string, columns: StructureColumn[]): Promise => { + let list: ComboSummary[]; + try { + list = await loadCombos(); + } catch (error) { + showToast(fill(TEXT.combosFailed, why(error)), "error"); + return null; + } + const col = columns.find((c) => c.id === colId); + const items = list.map((c) => ({ + value: c.키, + key: c.키, + label: [c.이름, c.단위 && `(${c.단위})`, c.구분].filter(Boolean).join(" "), + })); + return choose(col ? `${TEXT.pickPrice} — ${columnHead(col)}` : TEXT.pickPrice, items, true); + }; + + /** 다른 열의 도면 · 산출근거를 골라 내용만 복사 — 덮어쓰기 확인 · 보고 있던 열이면 다시 읽음 */ + const pickCopy = async ( + kind: "structure" | "basis", + colId: string, + columns: StructureColumn[], + ): Promise => { + const field = kind === "structure" ? "도번" : "표번"; + const to = columns.find((c) => c.id === colId); + const toNo = numbers[colId]?.[field]; + if (!to || !toNo) return void showToast(TEXT.saveFirst, "error"); + const items = columns + .filter((c) => c.id !== colId && numbers[c.id]?.[field]) + .map((c) => ({ value: c.id, key: numbers[c.id][field]!, label: columnHead(c) })); + const from = await choose( + `${kind === "structure" ? TEXT.pickDrawing : TEXT.pickBasis} — ${toNo} ${columnHead(to)}`, + items, + false, + ); + const src = columns.find((c) => c.id === from); + if (!from || !src) return; + const ask = TEXT.overwrite + .replace("{from}", `${numbers[from][field]} ${columnHead(src)}`) + .replace("{what}", kind === "structure" ? TEXT.drawing : TEXT.basisWord) + .replace("{to}", `${toNo} ${columnHead(to)}`); + if (!(await showConfirmDialog(ask))) return; + try { + await (kind === "structure" ? copyStructure : copyBasis)(colId, from); + showToast(TEXT.copied, "success"); + if (column === colId) void load(shown); + } catch (error) { + showToast(fill(TEXT.copyFailed, why(error)), "error"); + } + }; + + const pick = async ( + row: string, + colId: string | null, + columns: StructureColumn[], + ): Promise => { + if (!opts.isAdmin || !colId) return null; + if (row === "s:price") return pickPrice(colId, columns); + if (row === "s:drawing" || row === "s:basis") + await pickCopy(row === "s:drawing" ? "structure" : "basis", colId, columns); + return null; + }; + edit.addEventListener("click", async () => { const at = column; if (!at) return; @@ -243,6 +424,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane numbers = await fetchStructureNumbers().catch(() => ({})); }, show, + pick, destroy: () => { seq += 1; drop(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 53f66bbd..f226ae1c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -161,18 +161,26 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { "createSheet", ); if (!create) return empty(L("M02_NoSheet")); - // 구조물집계표(시스템 층)만 — 도면 줄에 도번 · 칸을 고르면 아래 컨테이너 + // 구조물집계표(시스템 층)만 — 도면 · 산출근거 줄에 도번 · 표번 · 칸을 고르면 아래 컨테이너 · + // 고칠 수 있으면 일위대가 · 도면 · 산출근거 칸은 조회 후 선택 const struct = at.layer === "system" && at.name === STRUCTURE_TABLE ? buildPanel() : null; if (struct) await struct.reload(); panel = struct; + const columns = (): StructureColumn[] => sheet?.getDoc().열 ?? []; editor = create(host, doc, { mode: at.layer === "project" ? "project" : "master", onChange: () => (dirty = !readOnly), ...(struct ? { - drawingLabel: (colId: string) => struct.numbers()[colId] ?? "", - onSelect: (s: { colId: string | null }) => - struct.show(s.colId, sheet?.getDoc().열 ?? []), + drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "", + basisLabel: (colId: string) => struct.numbers()[colId]?.표번 ?? "", + onSelect: (s: { colId: string | null }) => struct.show(s.colId, columns()), + ...(readOnly + ? {} + : { + onPick: (s: { row: string; colId: string | null }) => + struct.pick(s.row, s.colId, columns()), + }), } : {}), });