- 인력.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
135 lines
5.1 KiB
TypeScript
135 lines
5.1 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 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;
|
||
}
|