Files
Aislo/M01_MasterData/M01_MasterData_UI_Tables.ts
T
eomsangdonandClaude Sonnet 5 e41855d76a feat(M01): 소요량·계수·로직·기계 컨테이너를 인력과 같은 모양으로 — 전체 + 드롭다운 거름 · 가운데 표 목록 · 트리 걷음
- 왼쪽: 「전체」 한 줄 + 구분 → 상세구분 공용 드롭다운(기계는 세부분류 한 단) · 목록은 서버 /subs
- 가운데: 표 목록(키·원문번호·구분·상세구분·이름·기준·출처·비고) · 로직은 파일을 가로질러 한 목록 · 쪽 나눔 · 누르면 상세
- 표 목록 응답에 비고 칸 추가 · 관급 시험을 조달청 값 이은 줄 기준으로

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
2026-09-20 17:00:56 +09:00

210 lines
7.3 KiB
TypeScript

/* =============================================================================
* M01_MasterData_UI_Tables.ts
* 표형 그룹(소요량·계수) — 구분·상세구분·찾기로 거른 표 목록(파일을 가로지름 · 쪽 나눔)
* → 표 하나(조건 칸 + 값 칸의 긴 줄) · 눌러 고침(노랑) · 줄 더하기·지우기
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { fetchTable, fetchTables, type Row, type TableHead } from "./M01_MasterData_Api_Fetch";
import type { Pick } from "./M01_MasterData_UI_Side";
import {
onDraftChange,
peek,
same,
setTable,
tableItems,
type TableItem,
} from "./M01_MasterData_Draft";
import {
buildCell,
buildPager,
coerce,
show,
toNumberish,
toRange,
} from "./M01_MasterData_UI_Cells";
const LIST_SIZE = 30;
const LIST_COLS = ["키", "원문번호", "구분", "상세구분", "이름", "기준", "출처", "비고"] as const;
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
export function renderTables(host: HTMLElement, pick: Pick, q: string): () => void {
let stop = (): void => {};
const list = (page = 1): void => {
stop();
stop = (): void => {};
void showList(host, pick, q, page, (file, key) => {
stop = openTable(host, file, key, () => list(page));
});
};
list();
const off = onDraftChange(() => host.querySelector(".m01-master__tables") && markChanged(host));
return () => {
stop();
off();
};
}
/** 목록 위 「고침」 표시만 다시 — 목록을 다시 받지 않음. */
function markChanged(host: HTMLElement): void {
host.querySelectorAll<HTMLElement>(".m01-master__table-row").forEach((row) => {
const changed = peek(row.dataset.file!)?.tables ?? {};
row.classList.toggle("is-changed", row.dataset.key! in changed);
});
}
async function showList(
host: HTMLElement,
pick: Pick,
q: string,
page: number,
open: (file: string, key: string) => void,
): Promise<void> {
let got: { total: number; tables: TableHead[] };
try {
got = await fetchTables(pick.group, pick.sub, pick.detail, q, page, LIST_SIZE);
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
return;
}
const rows = got.tables.map((t) => {
const tr = el("tr", {
className: "m01-master__table-row",
attrs: { "data-key": t., "data-file": t.file },
children: LIST_COLS.map((c) =>
el("td", { text: String(t[c] ?? ""), attrs: { title: String(t[c] ?? "") } }),
),
});
tr.classList.toggle("is-changed", t. in (peek(t.file)?.tables ?? {}));
tr.addEventListener("click", () => open(t.file, t.));
return tr;
});
host.replaceChildren(
buildPager(got.total, LIST_SIZE, page, (p) => void showList(host, pick, q, p, open)),
el("div", {
className: "m01-master__grid-wrap m01-master__tables",
children: [
el("table", {
className: "m01-master__grid",
children: [
el("thead", {
children: [el("tr", { children: LIST_COLS.map((c) => el("th", { text: c })) })],
}),
el("tbody", { children: rows }),
],
}),
],
}),
);
}
/** 표 하나. 돌려받은 함수 = 해제. */
function openTable(host: HTMLElement, file: string, key: string, back: () => void): () => void {
let off = (): void => {};
void (async () => {
let version: string;
let table: Row & { : Row[] };
try {
({ version, table } = await fetchTable(file, key));
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
return;
}
const { : orig, ...base } = table;
const conds = (table.조건 ?? {}) as Record<string, string>;
const values = (table.값칸 ?? {}) as Record<string, string>;
const cols = [...Object.keys(conds), ...Object.keys(values)];
for (const row of orig) for (const k of Object.keys(row)) if (!cols.includes(k)) cols.push(k);
const items = (): TableItem[] => tableItems(file, key) ?? orig.map((r, o) => ({ o, r }));
const commit = (next: TableItem[]): void => setTable(file, version, key, base, next, orig);
const parse = (col: string, text: string, prev: unknown): unknown => {
if (conds[col] === "범위") return toRange(text);
if (conds[col] === "고르기") return text;
return col in values || conds[col] === "수" ? toNumberish(text) : coerce(text, prev);
};
const headText = (c: string): string =>
c in conds ? `${c} (${conds[c]})` : c in values ? `${c} (${values[c]})` : c;
const paint = (): void => {
const now = items();
const body = el("tbody");
now.forEach((item, i) => {
const tr = el("tr");
tr.classList.toggle("is-new", item.o === null);
const del = el("button", {
className: "m01-master__row-btn",
text: L("M01_RowRemove"),
attrs: { type: "button" },
});
del.addEventListener("click", () => commit(now.filter((_, j) => j !== i)));
tr.append(el("td", { children: [del] }));
for (const c of cols) {
const was = item.o === null ? undefined : orig[item.o][c];
tr.append(
buildCell(show(item.r[c]), {
changed: item.o === null || !same(item.r[c], was),
onEdit: (text) => {
const value = parse(c, text, item.r[c]);
const r = { ...item.r };
if (value === null || value === "")
delete r[c]; // 원문 「-」 = 칸을 뺌
else r[c] = value;
commit(now.map((it, j) => (j === i ? { ...it, r } : it)));
},
}),
);
}
body.append(tr);
});
const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" });
add.addEventListener("click", () => commit([...now, { o: null, r: {} }]));
const backButton = createButton({ label: L("M01_BackToTables"), variant: "ghost" });
backButton.addEventListener("click", () => {
off();
back();
});
const notes = ((table. ?? []) as string[]).map((n) =>
el("p", { className: "m01-master__muted", text: n }),
);
host.replaceChildren(
el("div", {
className: "m01-master__bar",
children: [
backButton,
el("strong", { text: String(table.이름 ?? key) }),
el("span", {
className: "m01-master__muted",
text: `${key} · ${table.기준 || "—"} · ${table.출처 || ""}`,
}),
add,
],
}),
el("div", {
className: "m01-master__grid-wrap",
children: [
el("table", {
className: "m01-master__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [el("th"), ...cols.map((c) => el("th", { text: headText(c) }))],
}),
],
}),
body,
],
}),
],
}),
...notes,
);
};
off = onDraftChange(paint);
paint();
})();
return () => off();
}