Files
Aislo/M01_MasterData/M01_MasterData_UI_Cells.ts
T
eomsangdonandClaude Opus 5 260c9c99cb knowledge(마스터): 옛 직종 이름 적용 — 준용대상 16 직종을 현행 줄 「옛이름」 으로 옮김 · 엔진 통합 차례 없앰
- 인력.json — 원문 확인 대응만 현행 줄에 옛이름 칸 + 짧은 이력 비고 · 옮긴 준용대상 16 줄 삭제(48 → 32) · 절단공은 철근 작업 한 줄만 철근공으로 가름
- _키대장.json — 옮긴 16 키에 「폐기 → 새 키」
- 로직 5 파일 89 호표 줄 — 현행 직종 키로 갈음 · 줄 비고에 「옛이름: ○○」
- 엔진·틀·빌더·M01 — 값 → 준용 → 산정 차례 · 옛이름 칸 읽기 전용 · 이름 찾기에 옛이름 걸림

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
2026-09-20 09:38:53 +09:00

135 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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 const deep = (row: Row, path: string[]): unknown =>
path.reduce<unknown>((o, k) => (o as Row | null | undefined)?.[k], row);
export function withDeep(row: Row, path: string[], value: unknown): Row {
const [head, ...rest] = path;
if (!rest.length) return { ...row, [head]: value };
return { ...row, [head]: withDeep((row[head] ?? {}) as Row, rest, value) };
}
export function show(v: unknown): string {
if (v === null || v === undefined) return "";
if (Array.isArray(v)) {
const sep = v.every((x) => typeof x === "string") ? " · " : " ~ "; // 글 목록(옛이름) · 수 범위
return v.map((x) => (x === null ? "" : String(x))).join(sep);
}
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;
}