/* ============================================================================= * M02_CF_InputPanel_Table.ts * 입력 상자 판 — 소요량 · 계수 표 고르기(절 · 이름 찾기 → 미리 보기 → 표 통째 또는 줄 하나) * `M02_CF_InputPanel.ts` 가 부름 · M01 Api 길(`/sections` · `/tables` · `/table`)만 읽음 · 글 [한, 영] 짝은 아래 * ========================================================================== */ 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 type { CfGroup, CfLogic } from "./M02_CF_Model"; import { setTable } from "./M02_CF_InputPanel_Build"; const TEXT = { Book: ["적용 품셈", "Standard"], Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"], Loading: ["찾는 중…", "Searching…"], NoSection: ["찾는 절 없음", "No section"], SecLine: ["표 {t} 개", "{t} tables"], Tables: ["이 절의 표 — 하나를 고름", "Pick a table"], NoTable: ["이 절에는 이 그룹의 표가 없음", "No table of this group here"], TableLine: [ "{b} · 조건 {c} · 결과 {v} · {n} 줄", "{b} · {c} conditions · {v} results · {n} rows", ], Chosen: ["고른 표", "Chosen table"], Whole: [ "표 통째 — 함수 상자가 조건에 맞는 줄을 찾음", "Whole table — a function box looks it up", ], OneRow: ["줄 하나 고정", "Fix one row"], RowNo: ["몇 번째 줄", "Row number"], Preview: ["미리 보기", "Preview"], Notes: ["주(註)", "Notes"], Remove: ["표 빼기", "Remove table"], Change: ["다른 표 고르기", "Pick another 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 Error(String(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; 조건: Record; 값칸: Record; 줄: Record[]; 주?: string[]; } const BOOKS = ["산림품셈", "건설품셈"]; const files = new Map(); const tables = new Map(); /** 표 한 벌 — 키로 찾아 받음(받은 것은 기억) */ export async function loadTable(group: CfGroup, key: string): Promise { const hit = tables.get(key); if (hit) return hit; let file = files.get(key); if (!file) { 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 raw = (await get<{ table: RawTable }>("/table", { file, key })).table; tables.set(key, raw); return raw; } const cell = (v: unknown): string => Array.isArray(v) ? v.map((x) => x ?? "").join("~") : v === null || v === undefined ? "" : String(v); /** 표 미리 보기 — 앞 8 줄 · `row`(0 부터) 줄은 칠함 */ function previewOf(tb: RawTable, row?: number): HTMLElement { const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)]; const body = tb.줄.slice(0, Math.max(8, (row ?? 0) + 1)); return el("div", { className: "m02-cfip__preview", children: [ el("table", { children: [ el("tr", { children: cols.map((c) => el("th", { text: c })) }), ...body.map((r, i) => el("tr", { className: i === row ? "is-hit" : "", children: cols.map((c) => el("td", { text: cell(r[c]) })), }), ), ], }), ...(tb.줄.length > body.length ? [el("p", { className: "m02-cfip__muted", text: `… ${tb.줄.length} 줄` })] : []), ...(tb.주?.length ? [ el("details", { children: [ el("summary", { text: t("Notes") }), ...tb.주.map((n) => el("p", { text: n })), ], }), ] : []), ], }); } /** 소요량 · 계수 표 고르기 화면 */ export function renderTablePick( host: HTMLElement, lg: CfLogic, boxId: string, group: CfGroup, on: { change(lg: CfLogic): void }, ): void { const box = lg.boxes.find((b) => b.id === boxId); const cur = box?.input?.table; if (cur) return void showChosen(host, lg, boxId, group, cur, on); 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: "m02-cfip__list" }); const tableList = el("div", { className: "m02-cfip__list" }); let seq = 0; const load = async (): Promise => { const mine = ++seq; list.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("Loading") })); try { const r = await get<{ sections: SectionItem[] }>("/sections", { book, q: q.input.value.trim(), limit: "60", }); if (mine !== seq) return; list.replaceChildren( ...(r.sections.length ? r.sections.map((s) => { const b = el("button", { className: "m02-cfip__row", attrs: { type: "button" }, children: [ el("strong", { text: `${s.section} ${s.title}` }), el("span", { className: "m02-cfip__muted", text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`, }), ], }); b.addEventListener("click", () => void pickSection(s)); return b; }) : [el("p", { className: "m02-cfip__muted", text: t("NoSection") })]), ); } catch (e) { if (mine === seq) showToast(e instanceof Error ? e.message : t("Failed"), "error"); } }; const pickSection = async (s: SectionItem): Promise => { try { const heads = ( await get<{ tables: TableHead[] }>("/tables", { group, section: s.section, sub: book, size: "100", }) ).tables; tableList.replaceChildren( el("h5", { text: t("Tables") }), ...(heads.length ? heads.map((h) => { const b = el("button", { className: "m02-cfip__row", attrs: { type: "button" }, children: [ el("strong", { text: h.이름 }), el("span", { className: "m02-cfip__muted", text: t("TableLine", { b: h.기준 || "—", c: Object.keys(h.조건).length, v: Object.keys(h.값칸).length, n: h.count, }), }), ], }); b.addEventListener("click", () => { files.set(h.키, h.file); void loadTable(group, h.키).then( (tb) => on.change(setTable(lg, boxId, { key: h.키 }, tb.이름)), (e) => showToast(e instanceof Error ? e.message : t("Failed"), "error"), ); }); return b; }) : [el("p", { className: "m02-cfip__muted", text: t("NoTable") })]), ); } catch (e) { showToast(e instanceof Error ? e.message : t("Failed"), "error"); } }; let timer = 0; q.input.addEventListener( "input", () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), ); host.replaceChildren(bookBox.root, q.root, list, tableList); void load(); } /** 고른 표 — 통째 · 줄 하나 · 미리 보기 */ function showChosen( host: HTMLElement, lg: CfLogic, boxId: string, group: CfGroup, cur: { key: string; row?: number }, on: { change(lg: CfLogic): void }, ): void { const body = el("div", { className: "m02-cfip__stack" }); host.replaceChildren(body); void loadTable(group, cur.key).then( (tb) => { const whole = cur.row === undefined; const modeWhole = createButton({ label: t("Whole"), variant: whole ? "filled" : "ghost", onClick: () => on.change(setTable(lg, boxId, { key: cur.key })), }); const modeOne = createButton({ label: t("OneRow"), variant: whole ? "ghost" : "filled", onClick: () => on.change(setTable(lg, boxId, { key: cur.key, row: cur.row ?? 0 })), }); const rowBox = createSelectField({ label: t("RowNo"), options: tb.줄.map((r, i) => ({ value: String(i), text: `${i + 1} · ${[...Object.keys(tb.조건), ...Object.keys(tb.값칸)] .slice(0, 4) .map((c) => cell(r[c])) .join(" / ")}`, })), value: String(cur.row ?? 0), compact: true, onChange: (v) => on.change(setTable(lg, boxId, { key: cur.key, row: Number(v) })), }); body.replaceChildren( el("h5", { text: `${t("Chosen")} — ${tb.이름} · ${tb.원문번호} · ${tb.기준 || "—"}` }), el("div", { className: "m02-cfip__chips", children: [modeWhole, modeOne] }), ...(whole ? [] : [rowBox.root]), el("h5", { text: t("Preview") }), previewOf(tb, cur.row), el("div", { className: "m02-cfip__chips", children: [ createButton({ label: t("Change"), variant: "ghost", onClick: () => on.change(setTable(lg, boxId, null)), }), ], }), ); }, (e) => body.replaceChildren( el("p", { className: "m02-cfip__muted", text: e instanceof Error ? e.message : t("Failed"), }), ), ); }