/* ============================================================================= * Z01_MasterData_UI_WorkItem_Rows.ts * 공종 편집 아래쪽 — 구성 줄 표(갈래별) · 자원 찾기(기초단가 검색) · 품셈 원문 표 + [주](읽기 전용) * * 칸을 고치면 초안(`WorkItemBody`)만 바꿈 — 서버엔 [저장] 때 전체 줄을 한 번에 보냄(PLAN 1-0). * 못 맞춘 줄(resource_key 없음)은 또렷이 — 자원 찾기로 기초단가 줄을 골라 이음. * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { fetchBasePrices, type ResourceKind, type WorkItemDetail, type WorkItemRow, } from "./Z01_MasterData_Api_Fetch"; import { cellText, latestOnly } from "./Z01_MasterData_UI_Cells"; import { addRow, changedFields, groupByVariant, type DraftRow, type WorkItemBody, } from "./Z01_MasterData_UI_Draft"; type LocaleKey = Parameters[0]; const KIND_LABEL: Record = { labor: "Z01_WorkItem_Kind_Labor", machine: "Z01_WorkItem_Kind_Machine", material: "Z01_WorkItem_Kind_Material", etc: "Z01_WorkItem_Kind_Etc", }; /** * 자원 찾기 — resource_kind 가 기초단가 kind 이름과 같음(노임·기계·자재 · 기타는 기초단가에 없음). * 이름·규격 칸은 서버 `row_label` 과 같은 칸을 씀. */ type Findable = Exclude; const FINDABLE: Record = { labor: { name: "occupation_name" }, machine: { name: "machine_name", spec: "specification" }, material: { name: "classification_name", spec: "specification" }, }; const FIND_SIZE = 20; const SEARCH_DELAY_MS = 300; const COLUMNS: LocaleKey[] = [ "Z01_WorkItem_Col_Kind", "Z01_WorkItem_Col_Resource", "Z01_WorkItem_Col_Spec", "Z01_WorkItem_Col_Key", "Z01_WorkItem_Col_Amount", "Z01_WorkItem_Col_Unit", "Z01_WorkItem_Col_Alt", "Z01_WorkItem_Col_Ratio", "Z01_WorkItem_Col_Raw", "Z01_WorkItem_Col_Table", "Z01_WorkItem_Col_Delete", ]; let tmpSeq = 0; const tmpId = (): string => `new-${Date.now()}-${++tmpSeq}`; /** rebuild = 줄 수·갈래·자원이 바뀜(다시 그림) · false = 칸 값만 바뀜(초점 지킴) */ export type RowsChanged = (rebuild: boolean) => void; export function renderWorkRows( body: WorkItemBody, detail: WorkItemDetail, changed: RowsChanged, ): HTMLElement { const finder = buildFinder(() => changed(true)); const box = el("div", { className: "z01-wi__rows", children: [el("h3", { className: "z01-wi__heading", text: L("Z01_WorkItem_Rows") })], }); const groups = groupByVariant(body.rows); // 줄이 하나도 없는 공종(입력 필요) — 갈래 없는 빈 묶음 하나로 [줄 추가] 를 세움. if (!groups.length) groups.push({ variant: "", indexes: [] }); for (const group of groups) box.append(variantBlock(group.variant, group.indexes)); const variantInput = el("input", { className: "ui-input", attrs: { placeholder: L("Z01_WorkItem_VariantName") }, }); const addVariant = createButton({ label: L("Z01_WorkItem_AddVariant"), variant: "ghost", onClick: () => { const name = variantInput.value.trim(); if (!name) return; addRow(body, name, tmpId()); changed(true); }, }); box.append( el("div", { className: "z01-master__toolbar", children: [variantInput, addVariant] }), finder.root, ); return box; function variantBlock(variant: string, indexes: number[]): HTMLElement { const head = el("tr", { children: COLUMNS.map((key) => el("th", { text: L(key) })) }); const table = el("table", { className: "z01-master__grid z01-wi__grid", children: [ el("thead", { children: [head] }), el("tbody", { children: indexes.map((index) => rowLine(body.rows[index])) }), ], }); const add = createButton({ label: L("Z01_MasterData_AddRow"), variant: "ghost", onClick: () => { addRow(body, variant, tmpId()); changed(true); }, }); return el("div", { className: "z01-wi__group", children: [ el("h4", { className: "z01-wi__variant", text: variant || L("Z01_WorkItem_NoVariant") }), el("div", { className: "z01-master__grid-wrap", children: [table] }), add, ], }); } function rowLine(row: DraftRow): HTMLElement { const tr = el("tr"); const cells: [keyof WorkItemRow, HTMLElement][] = []; // 고친 칸 표시 — 저장 전(초안 · 새 줄은 줄째) · 저장했고 초기값과 다름(서버 changed_columns) const saved = row.changed_columns ?? []; tr.classList.toggle("is-added", Boolean(row.is_added)); const mark = (): void => { const diff = changedFields(row, detail.rows); tr.classList.toggle("is-new", diff === "added"); tr.classList.toggle("is-deleted", Boolean(row.deleted)); for (const [field, td] of cells) { const draft = diff !== "added" && diff.has(field); td.classList.toggle("is-draft", draft); td.classList.toggle("is-changed", !draft && saved.includes(field)); } }; const cell = (field: keyof WorkItemRow, ...children: (HTMLElement | string)[]) => { const td = el("td", { children }); cells.push([field, td]); tr.append(td); return td; }; const set = (field: keyof WorkItemRow, value: unknown): void => { (row as unknown as Record)[field] = value; mark(); changed(false); }; const text = (field: "resource_name" | "resource_spec" | "amount_unit") => { const input = el("input", { className: "ui-input" }); input.value = row[field] ?? ""; input.addEventListener("change", () => set(field, input.value.trim())); return input; }; const number = ( field: "amount" | "alternative_amount" | "group_ratio_pct", ): HTMLInputElement => { const input = el("input", { className: "ui-input", attrs: { type: "number", min: "0", step: "any" }, }); input.value = row[field] == null ? "" : String(row[field]); input.addEventListener("change", () => { const raw = input.value.trim(); set(field, raw === "" ? null : Number(raw)); }); return input; }; const kind = el("select", { className: "ui-select" }); for (const each of Object.keys(KIND_LABEL) as ResourceKind[]) { kind.append(new Option(L(KIND_LABEL[each]), each)); } kind.value = row.resource_kind; // 종류가 바뀌면 이은 열쇠는 다른 표의 것 — 풀어 두고 다시 찾게 함. kind.addEventListener("change", () => { row.resource_kind = kind.value as ResourceKind; row.resource_key = null; changed(true); }); cell("resource_kind", kind); cell("resource_name", text("resource_name")); cell("resource_spec", text("resource_spec")); const key = row.resource_key ? el("span", { text: row.resource_key }) : el("span", { className: "z01-wi__unmatched", text: L("Z01_WorkItem_Unmatched") }); const find = createButton({ label: L("Z01_WorkItem_Find"), variant: "ghost", disabled: row.resource_kind === "etc", onClick: () => finder.open(row), }); cell("resource_key", key, find); cell("amount", number("amount")); cell("amount_unit", text("amount_unit")); cell("alternative_amount", number("alternative_amount")); cell("group_ratio_pct", number("group_ratio_pct")); cell( "raw_cell", el("span", { text: row.raw_cell ?? "", attrs: { title: row.raw_cell ?? "" } }), ); cell("pum_table_id", row.pum_table_id ?? ""); const drop = createButton({ label: L(row.deleted ? "Z01_MasterData_UndeleteRow" : "Z01_MasterData_DeleteRow"), variant: "ghost", onClick: () => { // 새 줄은 바로 뺌 · 서버 줄은 줄 긋고 [저장] 때 뺌(되살릴 수 있게) if (row.id === undefined) body.rows.splice(body.rows.indexOf(row), 1); else row.deleted = !row.deleted; changed(true); }, }); tr.append(el("td", { children: [drop] })); mark(); return tr; } } /** 자원 찾기 상자 — 기초단가 검색 API 로 찾아 한 줄을 고르면 그 줄에 열쇠·이름·규격을 넣음. */ function buildFinder(picked: () => void): { root: HTMLElement; open: (row: DraftRow) => void } { let target: DraftRow | null = null; let timer = 0; const search = latestOnly(fetchBasePrices); const title = el("strong"); const kind = el("select", { className: "ui-select" }); for (const each of Object.keys(FINDABLE) as Findable[]) { kind.append(new Option(L(KIND_LABEL[each]), each)); } const input = el("input", { className: "ui-input", attrs: { type: "search", placeholder: L("Z01_MasterData_Search") }, }); const results = el("div", { className: "z01-wi__found" }); const close = createButton({ label: L("Z01_WorkItem_FindClose"), variant: "ghost", onClick: () => { root.hidden = true; }, }); const root = el("div", { className: "z01-wi__finder", children: [ title, el("div", { className: "z01-master__toolbar", children: [kind, input, close] }), results, ], }); root.hidden = true; kind.addEventListener("change", () => void run()); input.addEventListener("input", () => { window.clearTimeout(timer); timer = window.setTimeout(() => void run(), SEARCH_DELAY_MS); }); async function run(): Promise { const from = kind.value as Findable; let data; try { data = await search(from, { page: 1, size: FIND_SIZE, q: input.value.trim() }); } catch (error) { showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); return; } if (!data) return; const fields = FINDABLE[from]; results.replaceChildren( ...(data.rows.length ? data.rows.map((row) => { const name = cellText(row[fields.name]); const spec = fields.spec ? cellText(row[fields.spec]) : ""; const button = el("button", { className: "z01-wi__item", attrs: { type: "button" }, children: [ el("span", { className: "z01-wi__item-name", text: `${name} ${spec}`.trim() }), el("span", { className: "z01-master__key", text: cellText(row["@id"]) }), ], }); button.addEventListener("click", () => { if (!target) return; target.resource_kind = from; target.resource_key = String(row["@id"]); target.resource_name = name; target.resource_spec = spec; root.hidden = true; picked(); }); return button; }) : [el("p", { className: "z01-master__note", text: L("Z01_WorkItem_FindNone") })]), ); } function open(row: DraftRow): void { target = row; kind.value = row.resource_kind === "etc" ? "material" : row.resource_kind; input.value = row.resource_name; title.textContent = `${L("Z01_WorkItem_FindTitle")} — ${row.resource_name || row.raw_cell || ""}`; results.replaceChildren(); root.hidden = false; root.scrollIntoView({ block: "nearest" }); void run(); } return { root, open }; } /** 품셈 원문 표 + [주] — 읽기 전용(값을 고칠 때 옆에 두고 봄). */ export function renderSource(detail: WorkItemDetail): HTMLElement { const box = el("div", { className: "z01-wi__source", children: [el("h3", { className: "z01-wi__heading", text: L("Z01_WorkItem_Source") })], }); if (!detail.source.length) { box.append(el("p", { className: "z01-master__note", text: L("Z01_WorkItem_NoSource") })); } for (const table of detail.source) { const caption = [table.pum_table_id, table.section, table.pum_form].filter(Boolean).join(" · "); const heads = cells(table.condition_note); const rows = Array.isArray(table.raw_row) ? (table.raw_row as unknown[]) : []; const grid = el("table", { className: "z01-master__grid" }); if (heads.length) { grid.append( el("thead", { children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })], }), ); } grid.append( el("tbody", { children: rows.map((row) => el("tr", { children: cells(row).map((c) => el("td", { text: c, attrs: { title: c } })), }), ), }), ); box.append( el("h4", { className: "z01-wi__variant", text: caption }), el("div", { className: "z01-master__grid-wrap", children: [grid] }), ); } const notes = detail.notes ?? []; if (notes.length) { box.append( el("h4", { className: "z01-wi__variant", text: L("Z01_WorkItem_Notes") }), el("ul", { className: "z01-wi__notes", children: notes.map((note) => el("li", { text: noteText(note) })), }), ); } return box; } /** 원문 칸 — 목록이면 칸마다 · 글자 하나면 한 칸. */ function cells(value: unknown): string[] { if (Array.isArray(value)) return value.map((each) => cellText(each)); return value == null ? [] : [cellText(value)]; } function noteText(note: unknown): string { if (note && typeof note === "object" && "text" in note) return cellText(note.text); return cellText(note); }