/* ============================================================================= * M01_MasterData_UI_Logic_Calc.ts * 시험 계산 — 받을 값을 넣고 [계산] → 줄별 금액 · 비목 합 · 멈춘 까닭(엔진 글 그대로) * 고친 로직은 저장 전 줄(`row`)을 같이 보내 메모리에서 셈(`POST /calc`) — 파일에 안 씀 * 셈 구동(입력 → 서버 → 답) = `_Logic_Drive.ts` — 로직 상세는 이 칸 없이 그 구동만 씀(799-2) · 이 칸은 로직 복사 화면이 씀 * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; import { pctView, searchElements, type CalcAnswer, type CalcLine, type ElementBrief, type LogicRow, } from "./M01_MasterData_UI_Logic_Api"; import { isMaterialKeyInput, machineControl, materialRows, showItems, } from "./M01_MasterData_UI_Logic_CalcPick"; import { autoFill, driveRun } from "./M01_MasterData_UI_Logic_Drive"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { formatLineMoney, formatMoney } from "./M01_MasterData_UI_Logic_Money"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { createBadge } from "@ui/ui_template_badge"; import { createDivider } from "@ui/ui_template_divider"; /** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */ const KEY_RE = /^[A-Z]{2}\d{6}$/; const KEY_GROUP: Record = { MT: "재료", EQ: "기계", LB: "인력" }; /** 로직마다 다시 그려도 남는 이름 캐시 — 같은 키를 두 번 묻지 않음 */ const nameCache = new Map(); async function keyName(key: string): Promise { if (nameCache.has(key)) return nameCache.get(key)!; const group = KEY_GROUP[key.slice(0, 2)]; if (!group) return null; let name: string | null = null; try { const found = await searchElements(group, key); // q = 키 그대로 — 「키」 칸 부분일치라 그 줄만 걸림 const hit = found.items.find((it) => it.ref === key); name = hit ? `${hit.이름 ?? ""}${hit.규격 ? ` ${hit.규격}` : ""}`.trim() || null : null; } catch { name = null; } nameCache.set(key, name); return name; } export interface CalcContext { /** 저장된 키 — 새 로직은 null */ savedKey: string | null; /** 저장 전 새 초안이면 비움 — 서버가 임시 자리에 얹어 셈 */ file?: string; row: LogicRow; /** 잡힌 품목을 보이는 데 씀 — 재료 줄의 단가 요소 · 조건 글 → 요소 */ prices?: Record; dirty: () => boolean; /** 로직마다 넣은 값 — 다시 그려도 남음 */ values: Record; onLines?: (lines: CalcLine[] | null) => void; } const grid = (children: HTMLElement[]): HTMLElement => el("div", { className: "m01-logic__head", children }); const SUMS = ["노무비", "재료비", "경비", "계"]; /** 로직마다 첫 셈을 한 번만(다시 그려도 안 부름) */ const autoAsked = new WeakSet>(); export function buildCalc(host: HTMLElement, ctx: CalcContext): void { // 그릇 폭 280px 고정 칸은 옛 테스트 컨테이너(UI_Test*, 걷힘)의 죽은 CSS 규칙 — // 지금 쓰는 LogicLab 화면은 그 칸에 안 걸려 폭이 이미 넓음(실측 730px) · 따로 안 풂 const optionText = (o: string | number): string => (typeof o === "string" && nameCache.get(o)) || String(o); const again = (): void => host.querySelector("[data-calc-run]")?.click(); const fields = (ctx.row.입력 ?? []) .filter((spec) => !isMaterialKeyInput(spec)) // 재료 키 목록은 표 모달로 바뀌어 걷음 .map((spec) => { const name = spec.이름; let control: HTMLElement; const picks = spec.고르기; const machine = machineControl(spec, ctx.values, again); if (machine) { control = machine; } else if (picks?.length) { const pick = createSelectField({ options: ["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })), value: ctx.values[name] ?? "", compact: true, onChange: (v) => { ctx.values[name] = v; }, }); control = pick.root; const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o)); if (keys.length) { void Promise.all(keys.map(keyName)).then(() => { pick.setOptions( ["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })), ctx.values[name] ?? "", ); }); } } else { const box = el("input", { className: "ui-input ui-input--sm m01-logic__input", attrs: { type: "text", inputmode: "decimal" }, }); if (spec.범위) box.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`; box.value = ctx.values[name] ?? ""; box.addEventListener("input", () => { ctx.values[name] = box.value; }); control = box; } const label = spec.단위 ? `${name} (${spec.단위})` : name; // 긴 이름 줄만 두 칸 차지 — 짧으면 4 열에 맞춰 나란히 const wide = label.length > 10; return el("label", { className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`, children: [el("span", { text: label }), control], }); }); const materials = materialRows(ctx.row, ctx.prices ?? {}, ctx.values, again); const out = el("div", { className: "m01-logic__calc-out" }); if (!fields.length && !autoAsked.has(ctx.values)) { autoAsked.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 계산 queueMicrotask(again); } else if (ctx.savedKey !== null && !autoAsked.has(ctx.values)) { autoAsked.add(ctx.values); void autoFill(ctx).then((filled) => { if (!filled) return; buildCalc(host, ctx); // 채운 값을 새로 그리고 바로 계산 again(); }); } const money = (ctx.row.결과단위 ?? "").startsWith("원"); const run = async (): Promise => { try { const { answer, draft } = await driveRun(ctx); if (answer.ok && answer.lines) showItems(host, ctx.values, answer.lines); out.replaceChildren(...answerView(answer, draft, money)); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); } }; const runButton = createButton({ label: tx("Calc_Run"), onClick: () => void run() }); runButton.setAttribute("data-calc-run", ""); host.replaceChildren( el("h3", { text: tx("Calc_Title") }), // 옵션 고르기 · 자재 고르기 두 격자(기본 4 열) — 사이 수평 구분선(709-1 부품) ...(fields.length || materials.length ? [ ...(fields.length ? [grid(fields)] : []), ...(fields.length && materials.length ? [createDivider()] : []), ...(materials.length ? [grid(materials)] : []), ] : [el("p", { className: "ui-text ui-text--muted", text: tx("Calc_NoInputs") })]), runButton, out, ); } /** 중간 값 이름이 돈 꼴(경비 · 노무비 · 공구손료 · 금액 · 단가 · 노임) — 1,000 미만이어도 돈 꼴 */ const MONEY_NAME = /(비|료|액|가|임)$/; /** `money` = 결과가 돈(원) — 그때만 결과 값도 정수 · 수량·비율 결과는 소수 그대로 */ export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] { const note = draft ? [el("p", { className: "ui-text ui-text--muted", text: tx("Calc_Draft") })] : []; if (!answer.ok) { return [ ...note, el("div", { className: "ui-banner ui-banner--danger m01-logic__reasons", children: [ el("strong", { text: tx("Calc_Stopped") }), el("div", { text: safeReason(answer.reason) }), ], }), ]; } const parts: HTMLElement[] = [...note]; if (answer.lines) { const visible = answer.lines.filter((line) => !line.옵션꺼짐); // 753-2 옵션으로 빠진 줄은 안 그림 const rows = visible.map((line) => el("tr", { children: [ el("td", { children: [ el("span", { text: line.출처 ? `${pctView(line).name} (${line.출처.replace(/[A-Z]{2}\d{6}\./g, "")})` : pctView(line).name, }), ...(line.단위경고 ? [createBadge(`${tx("Calc_UnitWarn")} · ${line.단위경고}`, "warning")] : []), ], }), el("td", { text: `${formatNumber(pctView(line).qty)} ${pctView(line).unit}` }), el("td", { className: "m01-logic__money", text: formatLineMoney(line.금액) }), ], }), ); parts.push( el("table", { className: "ui-table ui-table--data m01-logic__grid", children: [ el("thead", { children: [ el("tr", { children: [tx("Head_Name"), tx("Ho_Qty"), tx("Ho_Amount")].map((h) => el("th", { text: h }), ), }), ], }), el("tbody", { children: rows }), ], }), ); } if (answer.sums) { parts.push( el("dl", { className: "m01-logic__sums", children: SUMS.flatMap((k) => [ el("dt", { text: k === "계" ? tx("Calc_Sum") : k }), el("dd", { className: "m01-logic__money", text: formatMoney(answer.sums?.[k] ?? 0) }), ]), }), ); } if (answer.result !== undefined) { parts.push( el("dl", { className: "m01-logic__sums", children: [ el("dt", { text: tx("Calc_Result") }), el("dd", { text: money ? formatMoney(answer.result) : formatNumber(answer.result) }), ], }), ); } // 줄 묶음(객체)은 위 상세와 겹쳐 숨김 · 돈 로직의 큰 수는 정수 const middle = Object.entries(answer.middle ?? {}).filter(([, v]) => typeof v !== "object"); if (middle.length) { parts.push( el("h4", { text: tx("Middle_Title") }), el("dl", { className: "m01-logic__sums", children: middle.flatMap(([k, v]) => [ el("dt", { text: k }), el("dd", { text: money && typeof v === "number" && (Math.abs(v) >= 1000 || MONEY_NAME.test(k)) ? formatLineMoney(v) // 751-3 중간 돈 = 버린 줄 합(0.1 원) : formatNumber(v), }), ]), }), ); } return parts; }