Files
Aislo/M01_MasterData/M01_MasterData_UI_Rows.ts
T
eomsangdonandClaude Sonnet 5 c051fbf337 feat(M01): 왼쪽 패널 트리(전체 › 조사 · 원문 › 부문 › 장) · 표 끝 열 비고
- 인력·기계: 전체가 부모 · 조사/세부분류가 자식 · 줄 수 표시 (전체 408 = 묶음 합 · 기계 654 = 묶음 합)
- 소요량·계수·로직: 원문 › 부문 › 장 트리 · 접기/펴기
- 로직 목록: 원문/장 선택칸 대신 트리로 범위 고름
- 표: 비고 열을 항상 끝에 보이게 · 눌러 고침

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

342 lines
11 KiB
TypeScript

/* =============================================================================
* M01_MasterData_UI_Rows.ts
* 요소 파일(인력·재료·기계·환율·요율) 표 — 한 줄 = 요소 · 이름 찾기 · 쪽 나눔 · 눌러 고침(노랑)
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { openPickModal } from "./M01_MasterData_UI_Pick";
import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch";
import {
addRow,
editRow,
onDraftChange,
peek,
removeAdd,
same,
setAdd,
toggleDelete,
} from "./M01_MasterData_Draft";
import {
buildCell,
buildPager,
coerce,
deep,
flatten,
isScalar,
SEP,
show,
withCell,
withDeep,
} from "./M01_MasterData_UI_Cells";
const SIZE = 50;
const MARKET = "재료_시중물가.json";
const LINKED = "재료_품셈재료.json";
const JOB = ["인력.json"];
const NOTE = "비고";
const SUPPLY = ["값", "조달"];
const SOURCE = [...SUPPLY, "출처"];
const NARA = "나라장터:";
const FALLBACK: Row = { : "", 원문번호: "", 이름: "", 규격: "", 단위: "", : null, 출처: "" };
/** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */
function blank(sample: Row): Row {
const out: Row = {};
for (const [k, v] of Object.entries(sample)) {
if (v && typeof v === "object" && !Array.isArray(v)) out[k] = blank(v as Row);
else out[k] = typeof v === "string" && k !== "값" ? "" : null;
}
return out;
}
/** 비고 칸 고침 — 빈 글이면 칸을 지움 */
function withNote(row: Row, text: string): Row {
const out = { ...row };
if (text.trim()) out[NOTE] = text;
else delete out[NOTE];
return out;
}
/** 요소별 전용 칸으로 그리는 칸 — 일반 칸에서 뺌. */
function hidden(file: string, col: string): boolean {
if (file === MARKET) return col === `값${SEP}조달`;
if (file === LINKED) return col.startsWith("연결");
return JOB.includes(file) && col === "준용";
}
/** 키(한 번 주면 안 바뀜) · 빌더가 원문에서 채운 칸(통합 · 산정 · 후보) — 읽기 전용. */
const fixed = (file: string, col: string): boolean =>
col === "키" || (JOB.includes(file) && ["통합", "산정", "후보"].includes(col.split(SEP)[0]));
const HEADS: Record<string, string[]> = {
[MARKET]: ["조달 값", "조달 기준", "M01_ProcureCol"],
[LINKED]: ["M01_PriceCol"],
[JOB[0]]: ["M01_JobCol"],
};
const linkText = (link: unknown): string =>
link && typeof link === "object"
? `${L("M01_PriceCondUsed")}: ${(link as Row)["이름"]} ${(link as Row)["규격"] ?? ""}`.trim()
: String(link ?? "");
/** 키 → 이름(서버가 준 것 + 방금 고른 것) — 참조 칸에 키 옆에 같이 보임 */
const names = new Map<string, string>();
const KEY = /[A-Z]{2}\d{6}/;
const named = (text: string): string => {
const name = names.get(KEY.exec(text)?.[0] ?? "");
return name ? `${text} · ${name}` : text;
};
const remember = (ref: string | null, name?: string): void => {
const key = KEY.exec(ref ?? "")?.[0];
if (key && name) names.set(key, name);
};
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
export function renderRows(host: HTMLElement, file: string, q: string, sub = ""): () => void {
let page = 1;
let data: RowsPage | null = null;
let unlinked = false;
const paint = (): void => {
if (!data) return;
const d = data;
const draft = peek(file);
const adds = draft?.adds ?? [];
const cols: string[] = [];
for (const row of [...adds, ...d.rows]) {
for (const k of Object.keys(flatten(row)))
if (!hidden(file, k) && k !== NOTE && !cols.includes(k)) cols.push(k);
}
const head = el("tr", {
children: [
el("th"),
...cols.map((c) => el("th", { text: c })),
...(HEADS[file] ?? []).map((h) =>
el("th", { text: h.startsWith("M01_") ? L(h as "M01_PriceCol") : h }),
),
el("th", { text: NOTE }), // 비고 — 항상 끝 열(줄에 없으면 빈 칸 · 눌러 적음)
],
});
const body = el("tbody");
adds.forEach((row, i) => {
const flat = flatten(row);
const tr = el("tr", { className: "is-new" });
tr.append(actionCell(L("M01_RowRemove"), () => removeAdd(file, i)));
for (const c of cols) {
tr.append(
buildCell(show(flat[c]), {
changed: true,
onEdit: (text) => setAdd(file, i, withCell(row, c, coerce(text, flat[c]))),
}),
);
}
tr.append(
buildCell(show(row[NOTE]), {
changed: true,
onEdit: (text) => setAdd(file, i, withNote(row, text)),
}),
);
body.append(tr);
});
for (const row of d.rows) {
const key = String(row["키"]);
const cur = draft?.edits[key] ?? row;
const deleted = draft?.deletes.includes(key) ?? false;
const orig = flatten(row);
const now = flatten(cur);
const tr = el("tr");
tr.classList.toggle("is-deleted", deleted);
tr.append(
actionCell(deleted ? L("M01_RowRestore") : L("M01_RowDelete"), () =>
toggleDelete(file, d.version, key),
),
);
for (const c of cols) {
const editable = !deleted && isScalar(orig[c]) && !fixed(file, c);
tr.append(
buildCell(show(now[c]), {
changed: !same(now[c], orig[c]),
onEdit: editable
? (text) =>
editRow(file, d.version, key, row, withCell(cur, c, coerce(text, orig[c])))
: undefined,
}),
);
}
const put = deleted
? undefined
: (next: Row): void => editRow(file, d.version, key, row, next);
if (file === MARKET) tr.append(...marketCells(row, cur, put));
if (file === LINKED) tr.append(linkedCell(row, cur, put));
if (JOB.includes(file)) tr.append(wageCell(row, cur, put));
tr.append(
buildCell(show(cur[NOTE]), {
changed: !same(cur[NOTE], row[NOTE]),
onEdit:
deleted || !isScalar(row[NOTE])
? undefined
: (text) => editRow(file, d.version, key, row, withNote(cur, text)),
}),
);
body.append(tr);
}
const only =
file === LINKED
? [
createButton({
label: L("M01_OnlyUnlinked"),
variant: unlinked ? "filled" : "ghost",
onClick: () => {
unlinked = !unlinked;
page = 1;
void load();
},
}),
]
: [];
const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" });
add.addEventListener("click", () => {
const fresh = blank(d.rows[0] ?? FALLBACK);
const col = ["조사", "세부분류"].find((c) => c in fresh);
addRow(file, d.version, sub && col ? { ...fresh, [col]: sub } : fresh);
});
const empty =
d.rows.length || adds.length
? []
: [el("p", { className: "m01-master__empty", text: L("M01_NoRows") })];
host.replaceChildren(
el("div", {
className: "m01-master__bar",
children: [add, ...only, buildPager(d.total, SIZE, d.page, go)],
}),
el("div", {
className: "m01-master__grid-wrap",
children: [
el("table", {
className: "m01-master__grid",
children: [el("thead", { children: [head] }), body],
}),
],
}),
...empty,
);
};
const go = (next: number): void => {
page = next;
void load();
};
const load = async (): Promise<void> => {
try {
data = await fetchRows(file, page, SIZE, q, unlinked, sub);
for (const [key, name] of Object.entries(data.refs ?? {})) names.set(key, name);
paint();
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
}
};
const off = onDraftChange(paint);
void load();
return off;
}
function actionCell(label: string, onClick: () => void): HTMLTableCellElement {
const button = el("button", {
className: "m01-master__row-btn",
text: label,
attrs: { type: "button" },
});
button.addEventListener("click", onClick);
return el("td", { children: [button] });
}
type Put = (row: Row) => void;
const rowBtn = (text: string): HTMLButtonElement =>
el("button", { className: "m01-master__row-btn", text, attrs: { type: "button" } });
/** 시중물가 — 조달 값 · 기준 · 조달 연결(「나라장터:<키>」 · 「가격정보」 줄은 원천이라 그대로). */
function marketCells(row: Row, cur: Row, put?: Put): HTMLTableCellElement[] {
const link = show(deep(cur, SOURCE));
const td = buildCell(named(link), { changed: link !== show(deep(row, SOURCE)) });
if (put && (!link || link.startsWith(NARA))) {
const btn = rowBtn(L("M01_ProcureFind"));
btn.addEventListener("click", () =>
openPickModal({
kind: "procure",
title: L("M01_ProcureTitle"),
current: link,
seed: `${cur["이름"] ?? ""} ${cur["규격"] ?? ""}`.trim(),
onPick: (ref, name) => {
remember(ref, name);
put(withDeep(cur, SOURCE, ref));
},
}),
);
td.append(btn);
}
return [
buildCell(show(deep(cur, [...SUPPLY, "값"])), { changed: false }),
buildCell(show(deep(cur, [...SUPPLY, "기준"])), { changed: false }),
td,
];
}
/** 품셈재료 — 가격 연결(시중물가 키 · 후보 조건 · 없음) · 고르기 모달. */
function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
const link = cur["연결"];
const td = buildCell(named(linkText(link)), {
changed: JSON.stringify(link ?? null) !== JSON.stringify(row["연결"] ?? null),
});
if (put) {
const cond = link && typeof link === "object" ? (link as Row) : null;
const name = String(cond?.["이름"] ?? cur["이름"] ?? "");
const spec = String(cond?.["규격"] ?? cur["규격"] ?? "");
const btn = rowBtn(L("M01_PriceFind"));
btn.addEventListener("click", () =>
openPickModal({
kind: "price",
title: `${L("M01_PriceTitle")} · ${cur["이름"] ?? ""}`,
current: linkText(link),
seed: `${name} ${spec}`.trim(),
onPick: (ref, name) => {
remember(ref, name);
put(withCell(cur, "연결", ref));
},
cond: { 이름: name, 규격: spec, onCond: (c) => put(withCell(cur, "연결", c)) },
}),
);
td.append(btn);
}
return td;
}
/** 값 없는 인력 줄(건설업 미공표 · 준용대상)의 준용 = 공표 직종 키 고르기. */
function wageCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
const text = show(cur["준용"]);
const td = buildCell(named(text), { changed: text !== show(row["준용"]) });
if (put && row["값"] === null) {
const btn = rowBtn(L("M01_JobFind"));
btn.addEventListener("click", () =>
openPickModal({
kind: "job",
title: `${L("M01_JobTitle")} · ${cur["이름"] ?? ""}`,
current: text,
seed: "",
onPick: (ref, name) => {
remember(ref, name);
put(withCell(cur, "준용", ref));
},
}),
);
td.append(btn);
}
return td;
}