/* ============================================================================= * M01_SL_Table.ts * 시트 로직 표 상자 손잡이 — 표 상자를 고르면 시트 판 위에 붙음 * · 품셈 표 가져오기(절 · 이름 찾기 → 표) — 표 줄은 바깥 값(사본으로 안 들고 있음) * · 내 표(빈 표 · 엑셀에서 붙여넣기) — 시트에 그대로 * · 조건 칸 · 결과 칸 정하기 → 입력 알약 · 출력 알약 + 「표에서 찾기」 수식(시트 판에서 고칠 수 있음) * · 걸린 줄 칠하기 · 맞는 줄 없음 / 둘 이상 한 줄 * 짜기 = `M01_SL_Table_Build.ts`(DOM 없음) · 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 10 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField, el, showToast, } from "@ui/ui_template_elements"; import { ApiError } from "../M01_MasterData_UI_Logic_Api"; import { plainError } from "../EasyCalc/M01_EasyCalc_Api"; import type { SheetLogic, SlNode } from "./M01_SL_Model"; import { applyTable, blockCells, infoFromSheet, layoutOf, parsePaste, planLookup, type TblInfo, type TblRoles, } from "./M01_SL_Table_Build"; import "./M01_SL_Table.css"; const TEXT = { NotTable: ["표 상자를 고르면 표 손잡이가 뜸", "Pick a table box to see its tools"], Import: ["품셈 표 가져오기", "Import a standard table"], Mine: ["내 표", "My table"], Book: ["적용 품셈", "Standard"], Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"], Loading: ["찾는 중…", "Searching…"], NoSection: ["찾는 절 없음", "No section"], SecLine: ["표 {t} 개", "{t} tables"], Tables: ["이 절의 표 — 하나를 고름", "Pick a table"], TableLine: [ "{b} · 조건 {c} · 결과 {v} · {n} 줄", "{b} · {c} conditions · {v} results · {n} rows", ], ImportNote: [ "표 줄은 열 때 · 저장 때 품셈에서 새로 넣음 — 사본으로 안 들고 있음", "Rows are filled from the standard when opened or saved", ], Empty: ["빈 표", "Empty table"], Paste: ["엑셀에서 붙여넣기", "Paste from Excel"], Heads: ["머리 칸 이름(쉼표로 나눔) — 예: 경사, 할증", "Column names, comma separated"], RowCount: ["줄 수", "Rows"], MakeEmpty: ["빈 표 만들기", "Create"], PasteHint: [ "엑셀에서 머리 줄부터 복사해 여기에 붙임(탭으로 나뉜 글)", "Paste with the header row (tab separated)", ], PutIn: ["시트에 넣기", "Put in sheet"], TableName: ["표 이름", "Table name"], Need2: ["머리 칸이 둘은 있어야 함", "Need at least two columns"], NeedRows: ["줄 수를 1 이상으로", "Rows must be 1 or more"], Source: ["원문번호 {n} · 기준 {b} · 출처 {s}", "No. {n} · base {b} · source {s}"], Notes: ["주(註)", "Notes"], Roles: ["칸 역할 — 누르면 바뀜", "Column roles — press to change"], RoleCond: ["조건 → 입력 알약", "Condition → input pill"], RoleOut: ["결과 → 출력 알약", "Result → output pill"], RoleOff: ["쓰지 않음", "Unused"], Redo: ["역할을 바꾸면 찾기 수식을 다시 씀", "Changing roles rewrites the lookup formulas"], Found: ["{n} 번째 줄이 걸림", "Row {n} matched"], NoRow: ["맞는 줄 없음 — 입력 칸 값에 맞는 줄이 표에 없음", "No matching row"], TwoRows: ["맞는 줄 둘 이상 — 조건 칸을 더 늘리거나 표 줄을 고침", "More than one matching row"], Err: ["찾기 칸에 오류가 있음", "Error in the lookup cells"], NoValues: ["표 줄을 아직 못 받음 — 열 때 품셈에서 넣음", "Rows are not loaded yet"], View: ["걸린 줄 보기", "Matched row"], Formulas: ["찾기 수식 — 시트 판에서 고침", "Lookup formulas — edit in the sheet"], Warn: ["알림", "Note"], Swap: ["다른 표로 바꾸기", "Change table"], Failed: ["불러오지 못함", "Could not load"], } as const; type TKey = keyof typeof TEXT; const t = (key: TKey, v: Record = {}): string => Object.entries(v).reduce( (s, [k, x]) => s.replace(`{${k}}`, String(x)), TEXT[key][currentLanguageIndex] as string, ); /* ── 서버 읽기 — 절 · 표 ── */ async function get(path: string, params: Record): Promise { const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, { credentials: "include", }); const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T; if (!res.ok) throw new ApiError(res.status, data.detail ?? res.statusText); return data; } interface SectionItem { section: string; title: string; chapter: string; tables: number; } interface TableHead { file: string; 키: string; 이름: string; 기준: string; 조건: Record; 값칸: Record; count: number; } interface RawTable { 키: string; 원문번호: string; 이름: string; 기준: string; 출처: string; 조건: Record; 값칸: Record; 범위규칙?: string | null; 줄: TblInfo["rows"]; 주?: string[]; } const BOOKS = ["산림품셈", "건설품셈"]; const cache = new Map(); const fileOf = new Map(); const toInfo = (r: RawTable): TblInfo => ({ key: r.키, name: r.이름, base: r.기준 ?? "", source: r.출처 ?? "", no: r.원문번호 ?? "", conds: r.조건, vals: r.값칸, rule: r.범위규칙 ?? null, rows: r.줄, notes: r.주 ?? [], }); /** 표 키 → 표 한 벌 — 파일은 목록에서 찾음(첫 가져오기 뒤에는 기억) */ async function loadInfo(key: string): Promise { const hit = cache.get(key); if (hit) return hit; let file = fileOf.get(key); if (!file) { const group = /^C[FC]/.test(key) ? "계수" : "소요량"; const found = (await get<{ tables: TableHead[] }>("/tables", { group, q: key })).tables.find( (x) => x.키 === key, ); if (!found) throw new Error(t("Failed")); file = found.file; } const info = toInfo((await get<{ table: RawTable }>("/table", { file, key })).table); cache.set(key, info); return info; } /* ── 역할 · 알약 ── */ const rolesOf = (node: SlNode): TblRoles => ({ cond: node.pillsIn.map((p) => p.name), out: node.pillsOut.filter((p) => !p.outside).map((p) => p.name), }); /** 내 표 읽기 — 시트 블록에서 · 조건 칸은 입력 알약 이름 */ function mineInfo(sl: SheetLogic, node: SlNode): TblInfo { const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); const name = node.name.replace(/^표 · /, ""); const conds = Object.fromEntries(node.pillsIn.map((p) => [p.name, "고르기"])); return sheet ? infoFromSheet(sheet, { conds, name }) : { key: "", name, base: "", source: "", no: "", conds, vals: {}, rule: null, rows: [], notes: [], }; } /** userTables 한 줄을 시트 모양으로 맞춤 */ function syncUser(sl: SheetLogic, id: string, info: TblInfo): SheetLogic { const cols = layoutOf(info); const rows = info.rows.map((r) => cols.map( (c) => (Array.isArray(r[c.of]) ? ((r[c.of] as [number | null, number | null])[c.part === "아래" ? 0 : 1] ?? null) : (r[c.of] ?? null)) as number | string | null, ), ); const entry = { id, name: info.name, cols: cols.map((c) => c.header), rows }; const list = (sl.userTables ?? []).filter((u) => u.id !== id); return { ...sl, userTables: [...list, entry] }; } /** 찾기 판을 임시 통합문서에서 풀어 → 상태 · 걸린 줄(0 부터) · 오류 */ async function solve( sl: SheetLogic, node: SlNode, info: TblInfo, withBlock: boolean, ): Promise<{ status: string; idx: number | null }> { const plan = planLookup(info, rolesOf(node)); const book = structuredClone(sl.workbook); const sheet = book.시트.find((s) => s.id === node.sheetId); if (!sheet) return { status: "", idx: null }; if (withBlock) Object.assign(sheet.칸, blockCells(info)); const { createCalcEngine } = await import("../../A00_Common/spreadsheet/spreadsheet_graph"); const vals = createCalcEngine(book).snapshot()[node.sheetId] ?? {}; const read = (a: string): string | null => { const v = vals[a] as Record | undefined; if (!v) return null; return String(v["수"] ?? v["글"] ?? v["오류"] ?? ""); }; const status = read(plan.statusCell) ?? ""; const idx = Number(read(plan.idxCell)); return { status, idx: Number.isFinite(idx) && idx >= 1 ? idx - 1 : null }; } /* ── 화면 ── */ export function renderTableTools( host: HTMLElement, sl: SheetLogic, nodeId: string, on: { change(sl: SheetLogic): void }, ): void { host.className = "m01-sltbl"; const node = sl.nodes.find((n) => n.id === nodeId); if (!node || node.kind !== "table") { host.replaceChildren(el("p", { className: "m01-sltbl__muted", text: t("NotTable") })); return; } const mine = node.ref ? sl.userTables?.find((u) => u.id === node.ref) : undefined; const state = !node.ref ? "empty" : mine ? "mine" : "base"; const parts: HTMLElement[] = []; if (state !== "empty") parts.push(infoBox(sl, node, state, on)); parts.push( importBox(sl, nodeId, state === "empty", on), mineBox(sl, nodeId, state === "empty", on), ); host.replaceChildren(...parts); } const sub = (title: string, open: boolean, ...kids: HTMLElement[]): HTMLDetailsElement => { const d = el("details", { className: "m01-sltbl__box", children: [el("summary", { text: title }), ...kids], }); d.open = open; return d; }; /** 품셈 표 가져오기 */ function importBox( sl: SheetLogic, nodeId: string, open: boolean, on: { change(sl: SheetLogic): void }, ): HTMLElement { let book = BOOKS[0]; const bookBox = createSelectField({ label: t("Book"), options: BOOKS.map((b) => ({ value: b, text: b })), value: book, compact: true, onChange: (v) => ((book = v), load()), }); const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); const list = el("div", { className: "m01-sltbl__list" }); const tables = el("div", { className: "m01-sltbl__list" }); let seq = 0; const load = async (): Promise => { const mineSeq = ++seq; list.replaceChildren(el("p", { className: "m01-sltbl__muted", text: t("Loading") })); try { const r = await get<{ sections: SectionItem[] }>("/sections", { book, q: q.input.value.trim(), limit: "60", }); if (mineSeq !== seq) return; list.replaceChildren( ...(r.sections.length ? r.sections.map((s) => { const b = el("button", { className: "m01-sltbl__row", attrs: { type: "button" }, children: [ el("strong", { text: `${s.section} ${s.title}` }), el("span", { className: "m01-sltbl__muted", text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`, }), ], }); b.addEventListener("click", () => void pickSection(s)); return b; }) : [el("p", { className: "m01-sltbl__muted", text: t("NoSection") })]), ); } catch (e) { if (mineSeq === seq) showToast(plainError(e), "error"); } }; const pickSection = async (s: SectionItem): Promise => { try { const heads: TableHead[] = []; for (const group of ["소요량", "계수"]) heads.push( ...( await get<{ tables: TableHead[] }>("/tables", { group, section: s.section, sub: book, size: "100", }) ).tables, ); tables.replaceChildren( el("h5", { text: t("Tables") }), ...heads.map((h) => { const b = el("button", { className: "m01-sltbl__row", attrs: { type: "button" }, children: [ el("strong", { text: h.이름 }), el("span", { className: "m01-sltbl__muted", text: t("TableLine", { b: h.기준 || "—", c: Object.keys(h.조건).length, v: Object.keys(h.값칸).length, n: h.count, }), }), ], }); b.addEventListener("click", () => void pickTable(h)); return b; }), ); } catch (e) { showToast(plainError(e), "error"); } }; const pickTable = async (h: TableHead): Promise => { try { fileOf.set(h.키, h.file); const info = await loadInfo(h.키); const roles: TblRoles = { cond: Object.keys(info.conds), out: Object.keys(info.vals) }; on.change( applyTable(sl, nodeId, info, roles, { ref: h.키, fillBlock: false, outside: true }).sl, ); } catch (e) { showToast(plainError(e), "error"); } }; let timer = 0; q.input.addEventListener( "input", () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), ); const box = sub( t("Import"), open, el("p", { className: "m01-sltbl__muted", text: t("ImportNote") }), bookBox.root, q.root, list, tables, ); box.addEventListener("toggle", () => box.open && !list.childElementCount && void load()); if (open) void load(); return box; } /** 내 표 — 빈 표 · 붙여넣기 */ function mineBox( sl: SheetLogic, nodeId: string, open: boolean, on: { change(sl: SheetLogic): void }, ): HTMLElement { const name = createInputField({ type: "text", label: t("TableName"), value: "내 표", compact: true, }); const heads = createInputField({ type: "text", placeholder: t("Heads"), compact: true }); const rows = createInputField({ type: "text", label: t("RowCount"), value: "2", compact: true }); const paste = el("textarea", { className: "m01-sltbl__paste", attrs: { rows: "5", placeholder: t("PasteHint") }, }); const put = (info: TblInfo, roles: TblRoles): void => { const id = `UT${(sl.userTables?.length ?? 0) + 1}`; const done = applyTable( sl, nodeId, { ...info, name: name.input.value.trim() || "내 표" }, roles, { ref: id, fillBlock: true, outside: false }, ); on.change(syncUser(done.sl, id, { ...info, name: name.input.value.trim() || "내 표" })); }; const makeEmpty = createButton({ label: t("MakeEmpty"), onClick: () => { const cols = heads.input.value .split(/[,,\t]/) .map((s) => s.trim()) .filter(Boolean); const n = Math.floor(Number(rows.input.value)); if (cols.length < 2) return heads.setError(t("Need2")); if (!(n >= 1)) return rows.setError(t("NeedRows")); const info: TblInfo = { key: "", name: "", base: "", source: "", no: "", conds: { [cols[0]]: "고르기" }, vals: Object.fromEntries(cols.slice(1).map((c) => [c, ""])), rule: null, rows: Array.from({ length: n }, () => ({})), notes: [], }; put(info, { cond: [cols[0]], out: cols.slice(1) }); }, }); const pasteBtn = createButton({ label: t("PutIn"), onClick: () => { const r = parsePaste(paste.value); if (r.error) return void showToast(r.error, "error"); put(r.info, { cond: Object.keys(r.info.conds), out: Object.keys(r.info.vals) }); }, }); return sub( t("Mine"), open, name.root, el("h5", { text: t("Empty") }), heads.root, rows.root, makeEmpty, el("h5", { text: t("Paste") }), paste, pasteBtn, ); } /** 고른 표 — 머리 · 칸 역할 · 걸린 줄 · 수식 */ function infoBox( sl: SheetLogic, node: SlNode, state: "base" | "mine", on: { change(sl: SheetLogic): void }, ): HTMLElement { const box = el("div", { className: "m01-sltbl__info" }); const meta = el("p", { className: "m01-sltbl__muted" }); const notes = el("details", { className: "m01-sltbl__notes" }); const view = el("div", { className: "m01-sltbl__view" }); const status = el("p", { className: "m01-sltbl__status", attrs: { "aria-live": "polite" } }); const roleBox = el("div", { className: "m01-sltbl__roles" }); const formulas = el("div", { className: "m01-sltbl__formulas" }); const roles = rolesOf(node); const paint = (info: TblInfo): void => { const withBlock = state === "base"; meta.textContent = info.no || info.source ? t("Source", { n: info.no || "—", b: info.base || "—", s: info.source || "—" }) : ""; notes.replaceChildren( ...(info.notes.length ? [el("summary", { text: t("Notes") }), ...info.notes.map((n) => el("p", { text: n }))] : []), ); notes.hidden = !info.notes.length; // 칸 역할 const all = [ ...Object.keys(info.conds).map((c) => ({ c, isCond: true })), ...Object.keys(info.vals).map((c) => ({ c, isCond: false })), ]; const cur = (c: string): "cond" | "out" | "off" => roles.cond.includes(c) ? "cond" : roles.out.includes(c) ? "out" : "off"; roleBox.replaceChildren( el("p", { className: "m01-sltbl__muted", text: `${t("Roles")} · ${t("Redo")}` }), ...all.map(({ c, isCond }) => { const r = cur(c); const b = el("button", { className: `m01-sltbl__role is-${r}`, attrs: { type: "button" }, children: [ el("strong", { text: c }), el("span", { text: r === "cond" ? t("RoleCond") : r === "out" ? t("RoleOut") : t("RoleOff"), }), ], }); b.addEventListener("click", () => { const next: TblRoles = { cond: roles.cond.filter((x) => x !== c), out: roles.out.filter((x) => x !== c), }; const to = r === "off" ? (isCond ? "cond" : "out") : "off"; if (to === "cond") next.cond.push(c); if (to === "out") next.out.push(c); const order = Object.keys(info.conds); next.cond.sort((a, z) => order.indexOf(a) - order.indexOf(z)); const done = applyTable(sl, node.id, info, next, { ref: node.ref ?? "", fillBlock: false, outside: state === "base", }); on.change(state === "mine" ? syncUser(done.sl, node.ref ?? "", info) : done.sl); }); return b; }), ); // 수식 보기 const plan = planLookup(info, roles); const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); const line = (label: string, a1: string): HTMLElement => el("div", { className: "m01-sltbl__formula", children: [ el("span", { className: "m01-sltbl__muted", text: label }), el("code", { text: sheet?.칸[a1]?.식 ? `=${sheet.칸[a1].식}` : String(sheet?.칸[a1]?.값 ?? ""), }), ], }); formulas.replaceChildren( el("h5", { text: t("Formulas") }), ...Object.entries(plan.outCells).map(([c, a1]) => line(`${c} (${a1})`, a1)), ...(plan.warnings.length ? [ el("p", { className: "m01-sltbl__warn", text: `${t("Warn")}: ${plan.warnings.join(" · ")}`, }), ] : []), ); // 걸린 줄 칠하기 const cols = layoutOf(info); const table = el("table"); table.append(el("tr", { children: cols.map((c) => el("th", { text: c.header })) })); info.rows.forEach((r, i) => table.append( el("tr", { attrs: { "data-row": String(i) }, children: cols.map((c) => { const v = r[c.of]; const x = Array.isArray(v) ? (c.part === "아래" ? v[0] : v[1]) : v; return el("td", { text: x === null || x === undefined ? "" : String(x) }); }), }), ), ); view.replaceChildren( el("h5", { text: t("View") }), el("div", { className: "m01-sltbl__scroll", children: [table] }), ); void solve(sl, node, info, withBlock) .then(({ status: s, idx }) => { status.className = `m01-sltbl__status is-${s === "찾음" ? "ok" : "bad"}`; status.textContent = s === "찾음" && idx !== null ? t("Found", { n: idx + 1 }) : s === "맞는 줄 없음" ? t("NoRow") : s === "맞는 줄 둘 이상" ? t("TwoRows") : s ? t("Err") : ""; table .querySelectorAll("tr[data-row]") .forEach((tr) => tr.classList.toggle( "is-hit", idx !== null && s === "찾음" && tr.getAttribute("data-row") === String(idx), ), ); table.querySelector(".is-hit")?.scrollIntoView({ block: "nearest" }); }) .catch(() => (status.textContent = t("Err"))); }; box.append(meta, notes, roleBox, status, view, formulas); if (state === "base") { const key = node.ref ?? ""; void loadInfo(key).then(paint, (e) => showToast(plainError(e), "error")); } else paint(mineInfo(sl, node)); return box; }