Files
Aislo/M01_MasterData/M01_MasterData_UI_Cells.ts
eomsangdonandClaude Opus 5.5 022eff69f5 refactor(M01): 표 · 모달 · 띠 알림 · 배지 · 알약 · 보조 글 · 빈 상태를 부품으로 (PLAN 553-5)
- 표 둘 = 표 부품(m01-master__grid = 일반표 · m01-logic__grid = 데이터표) · 페이지 CSS 는 폭 · 머리 고정 · 칸 상태만
- 손 모달 다섯(조달 찾기 · 요소 고르기 · 표 바꾸기 · 복사 시트 · 표 고르기) = 모달 부품 openModal
- 알림 띠 · 막힘 까닭 = 띠 알림 · 태그 = 배지 · 알약 · × · 삭제 · 로직 키 = 단추 부품 · muted 세 벌 = 보조 글 · 빈 줄 = 빈 상태
- M02 가 빌린 M01 클래스(표 · muted · 고르기 창)도 같은 부품으로 · 죽은 CSS 걷음 · 지킴 시험 test_553_5_m01_m02

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:57:33 +09:00

135 lines
5.1 KiB
TypeScript
Raw Permalink 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: "ui-toolbar 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;
}