/* ============================================================================= * M02_MasterTemplete_Hopyo_CheckRows.ts * 확인 창 줄 표(PLAN 359-3) — 구조물 조합 일위대가 상세 꼴(`Labor_Rows` logicTable · matTable)에 품 비교 · 단추만 더함. * · 로직 표 = [✓] · 차례 · 구분(엑셀 갈래 + 상태) · 이름(우리 로직 · 배 · 옵션 글) · 별칭(엑셀 품명 = 만들 때 별칭) · * 수량산출(위 엑셀 / 아래 우리 = 엑셀 × 배) · 결과 단위 · 노무비 · 재료비 · 경비 · 합계 · 품 비교 · 단추 * · 자재 표 = [✓] · 차례 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(자재구분 · 별도계상 · 운반비 · 글만) · * 가격 다섯 · 엑셀 / 우리 금액 · 단추 * · 금액 칸 = 위 작게 엑셀 · 아래 우리(서버 calc 미리 셈 · 저장 안 함) · 셈 중 「…」 * · 단추 = [고르기] · [원문 보기] · [엑셀 보기] · [옵션](로직 대상마다) · 드롭다운 추가 · 로직없음 · 사용안함 * (화면 글만 · 저장 값 = 빈 값 · 「우리에 없음」 · 「안 씀」 그대로) * 줄 표 = `tableOf`(대상이 모두 자재 · 대상 없는 자재 · 별도계상 줄 = 자재 표). * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import type { HopyoLine, HopyoPart } from "./M02_MasterTemplete_Hopyo_Api"; import { checkable, compareText, isChecked, partsOf, type CheckDraft, } from "./M02_MasterTemplete_Hopyo_Check"; import { inputsText, sourceText } from "./M02_MasterTemplete_Hopyo_Choose"; import type { LaborCalc } from "./M02_MasterTemplete_Labor_Api"; import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { tableOf } from "./logic/M02_MasterTemplete_Hopyo_Draft"; /** 드롭다운 — 저장 값(서버 뺌 까닭) → 화면 글 */ export const OUT_TEXT: [string, string][] = [ ["", "추가"], ["우리에 없음", "로직없음"], ["안 씀", "사용안함"], ]; const COSTS = ["노무비", "재료비", "경비", "계"] as const; const COST_HEAD = { 노무비: "Prev_Labor", 재료비: "Prev_Material", 경비: "Prev_Expense", 계: "Prev_Sum", } as const; /** 줄 하나 미리 셈 — 셈 중 = 둘 다 없음 */ export interface LinePrice { calc?: LaborCalc; fail?: string; } export interface RowsContext { canWrite: boolean; d: CheckDraft; price: (line: HopyoLine) => LinePrice | null; check: (line: HopyoLine, on: boolean) => void; out: (line: HopyoLine, value: string) => void; times: (line: HopyoLine, at: number, value: number | null) => void; choose: (line: HopyoLine) => void; source: (line: HopyoLine) => void; sheet: (행: [number, number]) => void; options: (line: HopyoLine, at: number) => void; } const num = (v: number): string => Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); const small = (text: string, cls = "m02-labrow__note"): HTMLElement => el("small", { className: cls, text, attrs: { title: text } }); /** 금액 칸 — 위 엑셀(작게) · 아래 우리 */ function pairCell( excel: number | null | undefined, ours: number | null | undefined, wait: boolean, label: string, ): HTMLElement { const td = moneyCell(excel, ours, wait, label); td.querySelector(".m02-labrow__base")?.setAttribute("title", "엑셀"); td.querySelector(".m02-labrow__applied")?.setAttribute("title", "우리(미리 셈)"); return td; } /** 수량산출 — 위 엑셀 수량 · 아래 우리(엑셀 × 배 · 대상마다) */ function qtyCell(line: HopyoLine, parts: HopyoPart[]): HTMLElement { const ours = parts .map((p) => (p.배 === null || line.수량 === null ? "—" : num(line.수량 * p.배))) .join(" · "); return el("td", { className: "m02-labrow__qty", attrs: { "data-label": tc("Labor_QtyCol") }, children: [ el("small", { className: "m02-labrow__base", text: line.수량 === null ? "—" : `${num(line.수량)} ${line.단위}`.trim(), attrs: { title: "엑셀" }, }), el("span", { className: "m02-labrow__applied", text: ours || "—", attrs: { title: "우리" } }), ], }); } const stateChip = (line: HopyoLine): HTMLElement => { const m = line.맞춤; const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : ""; const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n"); return el("span", { className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`, text: `${m.상태}${alias}`, attrs: { title: tags }, }); }; /** 대상마다 한 줄 — 키 · 이름 · × 배 칸 · 몫 */ function partLines(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement[] { return parts.map((p, at) => { const times = el("input", { className: "ui-input ui-input--sm m02-hopyo__times", attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) }, }) as HTMLInputElement; times.title = "배 — 우리 수량 = 엑셀 수량 × 배"; times.disabled = !ctx.canWrite; times.addEventListener("change", () => ctx.times(line, at, times.value === "" ? null : Number(times.value)), ); const key = p.로직 ?? p.자재 ?? (p.호표 !== undefined ? `제${p.호표}호표` : p.전국 ? "전국" : ""); return el("div", { className: "m02-hopyo__part", attrs: { title: p.어디 ?? "" }, children: [ el("span", { className: "m02-labrow__key", text: key }), el("strong", { text: p.이름 ?? "" }), times, ...(p.몫 != null ? [small(`몫 ${p.몫}`)] : []), ], }); }); } /** 이름 칸 덧글 — 옵션(칸=값 · 출처) · 못 가름 · 셈 못 함 까닭 */ function nameNotes(line: HopyoLine, parts: HopyoPart[], price: LinePrice | null): HTMLElement[] { const opts = parts .filter((p) => p.로직) .map((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처)) .filter(Boolean); if (line.맞춤.갈리는칸?.length) opts.push(`⚠ 못 가름: ${line.맞춤.갈리는칸.join(", ")}`); const why = [ price?.fail, ...(price?.calc?.줄 ?? []).map((l) => l.까닭), ...(price?.calc?.자재 ?? []).map((m) => m.까닭), ].filter(Boolean) as string[]; return [ ...opts.map((t) => small(t, "m02-labrow__note m02-hopyo__opts")), ...why.map((t) => small(`셈 못 함: ${t}`, "m02-labrow__empty")), ...(parts.some((p) => p.호표 !== undefined) ? [small("하위 호표 = 만든 뒤 셈")] : []), ]; } function checkCell(line: HopyoLine, ctx: RowsContext): HTMLElement { const box = el("input", { attrs: { type: "checkbox", "data-check": String(line.차례) }, }) as HTMLInputElement; box.checked = isChecked(line, ctx.d) && checkable(line, ctx.d); box.disabled = !ctx.canWrite || !checkable(line, ctx.d); box.addEventListener("change", () => ctx.check(line, box.checked)); return el("td", { children: [box] }); } function actsCell(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement { const m = line.맞춤; const ghost = (label: string, run: () => void): HTMLElement => createButton({ label, variant: "ghost", size: "sm", onClick: run }); const out = el("select", { className: "ui-input ui-input--sm", attrs: { title: "로직없음 · 사용안함 — 사전조합에 안 넣고 장부에 남김", "data-out": "" }, children: OUT_TEXT.map(([value, text]) => el("option", { text, attrs: { value } })), }) as HTMLSelectElement; out.value = ctx.d.뺌[line.차례] ?? ""; out.disabled = !ctx.canWrite || m.상태 === "%"; out.addEventListener("change", () => ctx.out(line, out.value)); const options = parts.flatMap((p, at) => { if (!p.로직) return []; const b = ghost("옵션", () => ctx.options(line, at)); b.title = `${p.로직} 옵션`; return [b]; }); return el("td", { className: "m02-hopyo__acts", children: [ ...(ctx.canWrite && m.상태 !== "%" ? [ghost("고르기", () => ctx.choose(line))] : []), ghost("원문 보기", () => ctx.source(line)), ...(line.근거 ? [ghost("엑셀 보기", () => ctx.sheet(line.근거!.행))] : []), ...options, out, ], }); } const rowOf = (line: HopyoLine, ctx: RowsContext, cells: HTMLElement[]): HTMLElement => { const on = isChecked(line, ctx.d) && checkable(line, ctx.d); return el("tr", { className: `m02-labrow__row m02-hopyo__line is-${on ? "in" : "out"}`, attrs: { "data-line": String(line.차례), "data-state": line.맞춤.상태 }, children: [checkCell(line, ctx), ...cells, actsCell(line, partsOf(line, ctx.d), ctx)], }); }; function logicRow(line: HopyoLine, ctx: RowsContext): HTMLElement { const parts = partsOf(line, ctx.d); const price = ctx.price(line); const wait = !!price && !price.calc && !price.fail; const ours = price?.calc?.반영합; const unit = price?.calc?.줄[0]?.분모 ?? parts[0]?.단위 ?? line.단위; const compare = compareText(ctx.d.품[line.차례] ?? line.맞춤.품); return rowOf(line, ctx, [ el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }), el("td", { className: "m02-labrow__kind", children: [line.갈래, stateChip(line)] }), el("td", { className: "m02-labrow__name m02-hopyo__parts", children: [ ...(parts.length ? partLines(line, parts, ctx) : [small("—", "m02-labrow__muted")]), ...nameNotes(line, parts, price), ], }), el("td", { className: "m02-labrow__alias", attrs: { title: `${line.이름} ${line.규격}`.trim() }, children: [line.이름, ...(line.규격 ? [small(line.규격)] : [])], }), qtyCell(line, parts), el("td", { className: "m02-labrow__unit", text: unit ?? "" }), ...COSTS.map((c) => pairCell(line.금액[c], price?.calc ? (ours?.[c] ?? null) : null, wait, tc(COST_HEAD[c])), ), el("td", { className: "m02-hopyo__compare", text: compare.split(" · ").join("\n") }), ]); } function matRow(line: HopyoLine, ctx: RowsContext): HTMLElement { const parts = partsOf(line, ctx.d); const price = ctx.price(line); const wait = !!price && !price.calc && !price.fail; const mats = price?.calc?.자재 ?? []; const first = mats[0]; const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null; const show = (v: number | null | undefined): string => wait ? "…" : v === null || v === undefined ? "—" : v.toLocaleString("ko-KR"); const p = parts[0]; return rowOf(line, ctx, [ el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }), el("td", { className: "m02-labrow__code", text: parts.map((x) => x.자재 ?? (x.전국 ? "전국" : "")).join(" · ") || "—", }), el("td", { className: "m02-labrow__name m02-hopyo__parts", children: [ stateChip(line), ...(parts.length ? partLines(line, parts, ctx) : []), small(`엑셀 ${line.이름}`.trim()), ...nameNotes(line, parts, price), ], }), el("td", { className: "m02-labrow__spec", text: p?.규격 ?? line.규격, attrs: { title: p?.규격 ?? line.규격 }, }), qtyCell(line, parts), el("td", { className: "m02-labrow__unit", text: p?.단위 ?? line.단위 }), ...markCells(p?.세칸 ?? { 별도계상: line.갈래 === "별도계상" ? "별도계상" : "직접계산" }), ...PRICE_SLOTS.map((slot) => el("td", { className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`, text: show(first?.가격?.[slot]), attrs: { "data-label": slot, "data-price-slot": slot }, }), ), pairCell(line.금액.계, price?.calc ? ours : null, wait, tc("Labor_AppliedSum")), ]); } const grid = (heads: string[], rows: HTMLElement[], kind: string): HTMLElement => el("div", { className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", children: [ el("table", { className: "ui-general-block__table m02-labrow m02-hopyo__table m02-hopyo__lines", attrs: { "data-hopyo-table": kind }, children: [ el("thead", { children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], }), el("tbody", { children: rows }), ], }), ], }); /** 줄 표 둘 — 로직 표 · 자재 표(줄 없는 표는 안 그림) */ export function checkTables(lines: HopyoLine[], ctx: RowsContext): HTMLElement[] { const logic = lines.filter((l) => tableOf(l, partsOf(l, ctx.d)) === "logic"); const mat = lines.filter((l) => !logic.includes(l)); const out: HTMLElement[] = []; if (logic.length) out.push( grid( [ "", tc("Col_Order"), tc("Sub"), tc("Col_Name"), tc("Col_Alias"), tc("Labor_QtyCol"), tc("Col_Unit"), ...COSTS.map((c) => tc(COST_HEAD[c])), "품 비교", "", ], logic.map((l) => logicRow(l, ctx)), "logic", ), ); if (mat.length) out.push( el("h4", { className: "m02-hopyo__subhead", text: "자재" }), grid( [ "", tc("Col_Order"), tc("Labor_Code"), tc("Col_Name"), tc("Labor_Spec"), tc("Labor_QtyCol"), tc("Labor_Unit"), ...markHeads(), ...PRICE_SLOTS, `엑셀 / 우리 ${tc("Labor_AppliedSum")}`, "", ], mat.map((l) => matRow(l, ctx)), "mat", ), ); return out; }