/* ============================================================================= * 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 { saveView, type Pick } from "./M01_MasterData_UI_Side"; 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, flatten, isScalar, SEP, show, withCell, } from "./M01_MasterData_UI_Cells"; const SIZE = 50; const MARKET = "재료_자재품목.json"; const LINKED = "재료_품셈재료.json"; const JOB = ["인력.json"]; const NOTE = "비고"; // 끝 열(인력은 준용 앞) · 줄에 없으면 빈 칸 · 눌러 적음 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.split(SEP)[0] === "면수"; // 면수는 접어 둠 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 = { [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(); 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, pick: Pick, q: string): () => void { const { sub, detail, state } = pick; const file = pick.file.file; let page = pick.view?.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 heads = (HEADS[file] ?? []).map((h) => el("th", { text: h.startsWith("M01_") ? L(h as "M01_PriceCol") : h }), ); const head = el("tr", { children: [ el("th"), ...cols.map((c) => el("th", { text: c })), ...(JOB.includes(file) ? [] : heads), el("th", { text: NOTE }), ...(JOB.includes(file) ? heads : []), ], }); 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)), }), ); if (JOB.includes(file)) tr.append(el("td")); 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 === LINKED) tr.append(linkedCell(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)), }), ); if (JOB.includes(file)) tr.append(wageCell(row, cur, put)); 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 => { try { data = await fetchRows(file, page, SIZE, q, unlinked, sub, detail, state); for (const [key, name] of Object.entries(data.refs ?? {})) names.set(key, name); saveView(pick.group, { page: data.page }); 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 { return el("td", { children: [createButton({ label, variant: "ghost", onClick })] }); } type Put = (row: Row) => void; const rowBtn = (label: string): HTMLButtonElement => createButton({ label, variant: "ghost" }); /** 품셈재료 — 가격 연결(자재품목 키 · 후보 조건 · 없음) · 고르기 모달. */ 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) { const btn = rowBtn(L("M01_JobFind")); btn.addEventListener("click", () => openPickModal({ kind: "job", title: `${L("M01_JobTitle")} · ${cur["이름"] ?? ""}`, current: text, seed: "", onPick: (ref, name, value) => { remember(ref, name); // 준용을 고르면 값도 그 직종 값으로 · 비우면 원래 값 (저장 때 서버가 다시 읽음) put(withCell(withCell(cur, "준용", ref), "값", ref ? value : row["값"])); }, }), ); td.append(btn); } return td; }