/* ============================================================================= * M02_MasterTemplete_Labor_Rows.ts * 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀. * 담은 로직 = 차례 · 구분(키 머리에서 읽음 · 50-4) · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) · * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · * 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조). * 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3). * 금액은 calc 답(없으면 「…」). * 줄 누름 · 줄 단추(위로 · 아래로 · 옵션 · 삭제 · 묶음 `ui-btn-group`)는 `on` 이 있을 때만(보기 갈래 = 없음) · * 상세는 줄 누름 = 고름(`selected` 줄 하이라이트) · 넣을 값 모달은 [옵션](47-5). * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; import type { LaborCalc, LaborCalcLine, LaborCalcMat, LaborCost, LaborMat, LaborRow, } from "./M02_MasterTemplete_Labor_Api"; import { lineId } from "./M02_MasterTemplete_Labor_Calc"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "@ui/ui_template_general_layout.css"; import "./M02_MasterTemplete_Labor.css"; import { valueLine } from "./M02_Labor_Values_Model"; const COSTS = ["노무비", "재료비", "경비", "계"] as const; const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { 노무비: "Prev_Labor", 재료비: "Prev_Material", 경비: "Prev_Expense", 계: "Prev_Sum", }; /** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */ const SPAN = 5 + COSTS.length; /** 구분(50-4 · 저장 안 함) — 줄 모양(참조 키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */ export const kindOf = (row: Pick): string => { const key = row.참조?.키 ?? row.로직; return tc( key.startsWith("SP-") ? "Kind_Promote" : key.startsWith("SC-") ? "Kind_Combo" : "Kind_Logic", ); }; const num = (v: number): string => Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); /** 수량 글 — 「수량 손 3 개소」 · 「수량 12.5 ㎡(B6)」(칸 = 그 알약을 이은 면적산출 칸 · 44-6) · * 이은 칸이 비었으면 「수량 빈 칸(A1)」(수량 없음 · 서버와 같음) · 아니면 빈 글 */ export function qtyText( line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, unit: string, cells: ReadonlyMap = new Map(), ): string { if (!line) return ""; if (line.수량 === null || line.수량 === undefined) { const at = line.알약 ? cells.get(line.알약) : undefined; return at ? `${tc("Labor_Qty")} ${tc("In_QtyEmpty")}(${at})` : ""; } const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`; if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`; const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell"); return `${tc("Labor_Qty")} ${v}(${cell})`; } /** 결과단위 기준 수량 — 분모 앞 수 · 분모(「10 ㎡」 · 「1 ㎥」) · 분모 없으면 빈 글 */ export function baseQtyText( line: { 배?: number | null; 분모?: string | null } | undefined, ): string { if (!line?.분모) return ""; return `${num(line.배 || 1)} ${line.분모}`; } /** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「12.5(B6)」 · 「빈 칸(B6)」 · 없으면 「—」 */ export function qtyShort( line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, unit: string, cells: ReadonlyMap = new Map(), ): string { const at = line?.알약 ? cells.get(line.알약) : undefined; if (!line || line.수량 === null || line.수량 === undefined) return at ? `${tc("In_QtyEmpty")}(${at})` : "—"; if (line.수량출처 === "손") return `${tc("Labor_Hand")} ${num(line.수량)}${unit ? ` ${unit}` : ""}`; return `${num(line.수량)}(${at ?? tc("In_QtyCell")})`; } /** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「12.5(B6)」 · 「—」) · * `open` 이 있으면 단추(줄 누름과 따로) */ function qtyCell( line: LaborCalcLine | undefined, cells: ReadonlyMap | undefined, open: (() => void) | null, ): HTMLElement { const parts = [ el("small", { className: "m02-labrow__base", text: baseQtyText(line) || "—" }), el("span", { className: "m02-labrow__applied", text: qtyShort(line, line?.분모 ?? "", cells) }), ]; const body = open ? el("button", { className: "m02-labrow__qtybtn", attrs: { type: "button", title: tc("Labor_QtyPick"), "data-labor-qty-open": "" }, children: parts, }) : el("span", { children: parts }); if (open) body.addEventListener("click", (e) => { e.stopPropagation(); // 줄 누름(고름)과 따로 open(); }); return el("td", { className: "m02-labrow__qty", attrs: { "data-label": tc("Labor_QtyCol") }, children: [body], }); } /** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */ function moneyCell( base: number | null | undefined, applied: number | null | undefined, wait: boolean, label: string, ): HTMLElement { const show = (v: number | null | undefined): string => wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); return el("td", { className: "m02-labrow__money", attrs: { "data-label": label }, children: [ el("small", { className: "m02-labrow__base", text: show(base), attrs: { title: tc("Labor_Base") }, }), el("span", { className: "m02-labrow__applied", text: show(applied), attrs: { title: tc("Labor_Applied") }, }), ], }); } /** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */ const titled = (td: HTMLElement): HTMLElement => { for (const c of td.children) c.setAttribute("title", c.textContent ?? ""); return td; }; const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement => el("div", { className: "ui-general-block__table-wrap", children: [ el("table", { className: "ui-general-block__table m02-labrow", children: [ el("thead", { children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], }), el("tbody", { children: rows }), ...(foot.length ? [el("tfoot", { children: foot })] : []), ], }), ], }); /** 줄 누름 · 순서 · 옵션 · 삭제 — 셋 다 없으면 그 칸 없음 */ export interface RowHandlers { open: (i: number) => void; /** [옵션] 단추(47-5 · 넣을 값 모달) — 없으면 단추 없음 */ option?: (i: number) => void; /** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */ qty?: (i: number) => void; move?: (i: number, d: number) => void; remove?: (i: number) => void; /** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */ alias?: (i: number, value: string | null) => void; } /** 별칭 칸(44-6) — 고칠 수 있으면 칸 안 글상자(누르면 바로 고침 · 줄 누름과 따로) · 아니면 글 · 비면 빈 칸 */ function aliasCell( value: string | null | undefined, set: ((v: string | null) => void) | null, ): HTMLElement { if (!set) return el("td", { className: "m02-labrow__alias", text: value ?? "", attrs: { "data-label": tc("Col_Alias") }, }); const input = el("input", { className: "m02-labrow__aliasin", attrs: { type: "text", maxlength: "40", // 서버 `ALIAS_MAX` 와 같음 title: tc("Alias_Edit"), "aria-label": tc("Col_Alias"), "data-labor-alias": "", }, }); input.value = value ?? ""; input.addEventListener("click", (e) => e.stopPropagation()); // 줄 누름(고름)과 따로 input.addEventListener("input", () => set(input.value.trim() || null)); input.addEventListener("keydown", (e) => { if (e.key === "Enter") input.blur(); }); return el("td", { className: "m02-labrow__alias", attrs: { "data-label": tc("Col_Alias") }, children: [input], }); } export interface LogicTableData { rows: LaborRow[]; logics: Map; /** calc 답 — null = 계산 중 · 못 셈은 `fail` */ calc: LaborCalc | null; fail?: string | null; /** 알약 키 → 이은 면적산출 칸(「B6」) */ cells?: ReadonlyMap; /** 고른 줄 로직 키(47-5 · 하이라이트) */ selected?: string | null; } /** 담은 로직 표 — 보기 갈래는 `on` 없이 */ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement { const wait = !data.calc && !data.fail; const { move, remove, option } = on ?? {}; // 옵션 · 삭제만(구조물 조합 카드 · 43-4)이어도 끝 칸 — 순서 단추는 `move` 가 있을 때만 const tools = !!(move || remove || option); const lineOf = (key: string): LaborCalcLine | undefined => data.calc?.줄.find((l) => lineId(l) === key); const trs = data.rows.flatMap((row, i) => { const id = lineId(row) ?? ""; const logic = data.logics.get(row.로직); const line = lineOf(id); // 참조 줄 — 키 · 이름 · 단위 = 가리킨 문서 것(calc 답) · 넣을 값 없음(승격 때 고정) const unit = row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? ""); const values = valueLine(logic?.입력 ?? [], row.입력값); const note = values.text; const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); const small = ( label: string, run: () => void, variant: "ghost" | "danger" = "ghost", ): HTMLElement => createButton({ label, variant, onClick: (e) => { e.stopPropagation(); // 줄 누름(고름)과 따로 run(); }, }); const sel = data.selected === id ? " is-selected" : ""; const tr = el("tr", { className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel, attrs: { "data-labor-logic": id }, children: [ el("td", { className: "m02-labrow__no", text: String(i + 1) }), el("td", { className: "m02-labrow__kind", text: kindOf(row) }), titled( el("td", { className: "m02-labrow__name", children: [ el("span", { className: "m02-labrow__key", text: row.참조?.키 ?? row.로직 }), el("strong", { text: (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직, }), ...(values.empty ? [ el("small", { className: "m02-labrow__empty", text: tc("Labor_Empty").replace("{n}", String(values.empty)), }), ] : []), ...(row.메모 ? [el("small", { className: "m02-labrow__note", text: row.메모 })] : []), ...(why ? [ el("small", { className: "m02-labrow__empty", text: `${tc("Labor_Calc_Fail")}: ${why}`, }), ] : []), ], }), ), aliasCell(row.별칭, on?.alias ? (v) => on.alias?.(i, v) : null), qtyCell(line, data.cells, on?.qty ? () => on.qty?.(i) : null), el("td", { className: "m02-labrow__unit", text: unit }), ...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait, tc(HEAD[c]))), ...(tools ? [ el("td", { className: "m02-labrow__move", children: [ el("div", { className: "ui-btn-group", children: [ ...(move ? [small(tc("Up"), () => move(i, -1)), small(tc("Down"), () => move(i, 1))] : []), ...(option && !row.참조 ? [small(tc("Labor_Option"), () => option(i))] : []), ...(remove ? [small(tc("Remove"), () => remove(i), "danger")] : []), ], }), ], }), ] : []), ], }); if (on) tr.addEventListener("click", () => on.open(i)); if (!note) return [tr]; // 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐 const opt = el("tr", { className: (on ? "m02-labrow__optrow is-open" : "m02-labrow__optrow") + sel, attrs: { "data-labor-opt": id }, children: [ el("td", { className: "m02-labrow__no", text: "" }), el("td", { className: "m02-labrow__opt", text: note, attrs: { colspan: String(SPAN), title: note }, }), ...(tools ? [el("td", { text: "" })] : []), ], }); if (on) opt.addEventListener("click", () => on.open(i)); return [tr, opt]; }); const sum = (part: Partial | undefined, c: (typeof COSTS)[number]): number | null => part?.[c] ?? null; const foot = data.rows.length ? [ el("tr", { className: "m02-labrow__sum", children: [ el("td", { className: "m02-labrow__no", text: "" }), el("td", { className: "m02-labrow__kind", text: "" }), el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }), el("td", { className: "m02-labrow__alias", text: "" }), el("td", { className: "m02-labrow__qty", text: "" }), el("td", { className: "m02-labrow__unit", text: "" }), ...COSTS.map((c) => { const td = moneyCell( sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait, tc(HEAD[c]), ); if (c === "계") td.classList.add("m02-labrow__total"); // 전체 합계 강조(44-6) return td; }), ...(tools ? [el("td", { text: "" })] : []), ], }), ] : []; const heads = [ tc("Col_Order"), tc("Sub"), tc("Col_Name"), tc("Col_Alias"), tc("Labor_QtyCol"), tc("Col_Unit"), ...COSTS.map((c) => tc(HEAD[c])), ...(tools ? [tc("Col_Move")] : []), ]; return data.rows.length ? table(heads, trs, foot) : el("p", { className: "m02-labrow__muted", text: tc("NoLogic") }); } export interface MatTableData { mats: LaborMat[]; calc: LaborCalc | null; fail?: string | null; cells?: ReadonlyMap; } /** M01 자재품목 값 열 다섯(이름 그대로 · 43-3) — 서버 calc 답 `가격` 열쇠와 같음 */ export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] as const; /** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금 * 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) · * 보기 갈래는 `on` 없이 */ export function matTable(data: MatTableData, on?: Omit): HTMLElement { const wait = !data.calc && !data.fail; const remove = on?.remove; const show = (v: number | null | undefined): string => wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); const trs = data.mats.map((mat, i) => { const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드); // 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1 const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine; const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => { const base = !!line?.기준칸 && line.기준칸 === slot; return el("td", { className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`, text: show(line?.가격?.[slot]), attrs: { "data-label": slot, "data-price-slot": slot, ...(base ? { title: tc("Labor_BasePrice") } : {}), }, }); }; const tr = el("tr", { className: on ? "m02-labrow__row is-open" : "m02-labrow__row", attrs: { "data-labor-mat": mat.코드 }, children: [ el("td", { className: "m02-labrow__code", text: mat.코드 }), titled( el("td", { className: "m02-labrow__name", children: [ el("strong", { text: mat.이름 ?? "" }), ...(line?.까닭 ? [el("small", { className: "m02-labrow__empty", text: line.까닭 })] : []), ], }), ), el("td", { className: "m02-labrow__spec", text: mat.규격 ?? "", attrs: { title: mat.규격 ?? "" }, }), qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null), el("td", { className: "m02-labrow__unit", text: mat.단위 ?? "", attrs: { "data-label": tc("Labor_Unit") }, }), ...PRICE_SLOTS.map(priceCell), moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")), ...(remove ? [ el("td", { className: "m02-labrow__move", children: [ createButton({ label: tc("Remove"), variant: "danger", onClick: (e) => { e.stopPropagation(); remove(i); }, }), ], }), ] : []), ], }); if (on) tr.addEventListener("click", () => on.open(i)); return tr; }); const heads = [ tc("Labor_Code"), tc("Col_Name"), tc("Labor_Spec"), tc("Labor_QtyCol"), tc("Labor_Unit"), ...PRICE_SLOTS, `${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`, ...(remove ? [tc("Col_Move")] : []), ]; return data.mats.length ? table(heads, trs, []) : el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") }); }