/* ============================================================================= * M01_MasterData_UI_Cells.ts * 칸 다루기 — 값 → 글 · 글 → 값 · 눌러 고치기 · 쪽 나눔 띠 (요소·표 화면 공용) * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import type { Row } from "./M01_MasterData_Api_Fetch"; /** 묶음 값(`값 = {가격: …}`)을 「값›가격」 칸으로 펼칠 때의 구분자. */ export const SEP = "›"; export const isScalar = (v: unknown): boolean => v === null || v === undefined || ["string", "number", "boolean"].includes(typeof v); /** 줄 → 칸 이름별 값. 묶음(dict) 칸은 한 단계 펼침. */ export function flatten(row: Row): Record { const out: Record = {}; for (const [k, v] of Object.entries(row)) { if (v && typeof v === "object" && !Array.isArray(v)) { for (const [k2, v2] of Object.entries(v)) out[`${k}${SEP}${k2}`] = v2; } else { out[k] = v; } } return out; } /** 칸 이름(펼친 것 포함)에 값을 넣은 새 줄. */ export function withCell(row: Row, path: string, value: unknown): Row { const [head, sub] = path.split(SEP); if (sub === undefined) return { ...row, [head]: value }; return { ...row, [head]: { ...(row[head] as Row), [sub]: value } }; } export function show(v: unknown): string { if (v === null || v === undefined) return ""; if (Array.isArray(v)) return v.map((x) => (x === null ? "" : String(x))).join(" ~ "); return typeof v === "object" ? JSON.stringify(v) : String(v); } const NUMBER = /^-?\d+(\.\d+)?$/; /** 글 → 수(수 꼴이면) 아니면 글. 빈 글 = null. */ export function toNumberish(text: string): number | string | null { const s = text.replace(/,/g, "").trim(); if (s === "") return null; return NUMBER.test(s) ? Number(s) : text.trim(); } /** 원래 값의 꼴을 따라 글을 값으로 — 글이었으면 글 · 수·빈 칸이었으면 수 꼴이면 수. */ export const coerce = (text: string, orig: unknown): unknown => typeof orig === "string" ? text : toNumberish(text); /** 「아래 ~ 위」 → [아래, 위] (빈 쪽 = null · 「~」 없으면 한 값). */ export function toRange(text: string): unknown { if (!text.includes("~")) return toNumberish(text); return text.split("~").map((s) => (s.trim() === "" ? null : toNumberish(s))); } /** 칸을 눌러 고침 — Enter·칸 밖 누름 = 확정 · Esc = 취소. */ export function startEdit(td: HTMLElement, text: string, commit: (value: string) => void): void { if (td.querySelector("input")) return; const input = el("input", { className: "m01-master__cell-input", attrs: { type: "text" } }); input.value = text; td.textContent = ""; td.append(input); input.focus(); input.select(); let done = false; const finish = (ok: boolean): void => { if (done) return; done = true; if (ok && input.value !== text) commit(input.value); else td.textContent = text; }; input.addEventListener("keydown", (e) => { if (e.key === "Enter") finish(true); else if (e.key === "Escape") finish(false); }); input.addEventListener("blur", () => finish(true)); } /** 쪽 나눔 띠 — 이전·다음 · 「n / N 쪽 · 전체 줄」. */ export function buildPager( total: number, size: number, page: number, goto: (page: number) => void, ): HTMLElement { const last = Math.max(1, Math.ceil(total / size)); const prev = createButton({ label: L("M01_Prev"), variant: "ghost", disabled: page <= 1 }); const next = createButton({ label: L("M01_Next"), variant: "ghost", disabled: page >= last }); prev.addEventListener("click", () => goto(page - 1)); next.addEventListener("click", () => goto(page + 1)); return el("div", { className: "m01-master__pager", children: [ el("span", { text: `${page} / ${last} ${L("M01_Page")} · ${total.toLocaleString("ko-KR")} ${L("M01_Rows")}`, }), prev, next, ], }); } /** 칸 하나 — 노랑(고침) · 눌러 고침. */ export function buildCell( text: string, opts: { changed: boolean; onEdit?: (value: string) => void }, ): HTMLTableCellElement { const td = el("td", { text, attrs: { title: text } }); td.classList.toggle("is-changed", opts.changed); if (opts.onEdit) { const onEdit = opts.onEdit; td.classList.add("is-editable"); td.addEventListener("click", () => startEdit(td, text, onEdit)); } return td; }