Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
122 lines
4.5 KiB
TypeScript
122 lines
4.5 KiB
TypeScript
/* =============================================================================
|
||
* 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<string, unknown> {
|
||
const out: Record<string, unknown> = {};
|
||
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;
|
||
}
|