/* ============================================================================= * M01_LB_Steps.ts * 로직 만들기 판 가운데 문장 보기 — ① 입력값 ② 표 조회 ③ 계산 ④ 항목 연결 ⑤ 결과값 * 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · `로직판_A_설계초안.md` 7-1 그대로 · 모두 고르기 · 숫자 칸 · 바뀐 lb 는 on.change 로만 알림 * 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { fetchLogics, type CalcAnswer, type CalcLine, type LogicInput, } from "../M01_MasterData_UI_Logic_Api"; import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import { addCard, addPercent, removeCard, updateCard, withInputs, type CostKind, type EasyCard, type PickResult, } from "../EasyCalc/M01_EasyCalc_Model"; import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick"; import { setCond, type LbLogic, type LbLookup, type LbRef } from "./M01_LB_Blocks"; import { calcBody, calcView } from "./M01_LB_Steps_Calc"; import { chip, logicNames, lookupValue, note, numBox, parseNum, refText, selectBox, sentence, span, textBox, tx, valueNote, type Ctx, type TEXT, } from "./M01_LB_Steps_Cell"; import "./M01_LB_Steps.css"; type Kind = "person" | "material" | "machine" | "logic" | "percent" | "locked"; let picking = false; let namesLoading = false; /* ------------------------------ ① 입력값 ------------------------------ */ function inputView(ctx: Ctx, input: LogicInput, at: number): HTMLElement { const put = (patch: Partial): void => ctx.commit(() => { const inputs = ctx.lb.inputs.map((x, i) => (i === at ? { ...x, ...patch } : x)); return { ...ctx.lb, inputs, doc: { ...ctx.lb.doc, inputs } }; }); const parts: (HTMLElement | string)[] = [span("m01-lbsteps-name", input.이름)]; if (input.고르기) { parts.push( note(tx("PickList")), el("span", { className: "m01-lbsteps-chips", children: input.고르기.map((v) => chip(String(v))), }), ); } else if (input.범위) { const [lo, hi] = input.범위; parts.push( note(tx("Range")), numBox(ctx, lo, (n) => put({ 범위: [n, hi] })), "~", numBox(ctx, hi, (n) => put({ 범위: [lo, n] })), ); } else parts.push(note(tx("Number"))); parts.push( note(tx("Unit")), textBox(ctx, input.단위 ?? "", (v) => put({ 단위: v })), ); return el("li", { className: "m01-lbsteps-row", children: [sentence(...parts)] }); } /* ------------------------------ ② 표 조회 ------------------------------ */ function condView(ctx: Ctx, lk: LbLookup, cond: LbLookup["conds"][number]): HTMLElement { const from = cond.from; const set = (next: LbRef | null): void => ctx.commit(() => setCond(ctx.lb, lk.id, cond.col, next)); const parts: (HTMLElement | string)[] = [chip(cond.col), tx("LkIs")]; if (from.k === "input" || from.k === "fixed") { const options = [ ...ctx.lb.inputs.map((i) => ({ value: `i:${i.이름}`, text: `${tx("SrcInput")}${i.이름}` })), { value: "f", text: tx("SrcFixed") }, { value: "n", text: tx("SrcAny") }, ]; const current = from.k === "input" ? `i:${from.name}` : "f"; parts.push( selectBox( ctx, options, current, (v) => set( v === "n" ? null : v === "f" ? { k: "fixed", value: "" } : { k: "input", name: v.slice(2) }, ), cond.col, ), ); if (from.k === "fixed") { parts.push( textBox(ctx, String(from.value), (v) => set({ k: "fixed", value: parseNum(v) ?? v })), ); } } else parts.push(chip(refText(ctx, from))); return el("span", { className: "m01-lbsteps-cond", children: parts }); } function lookupView(ctx: Ctx, lk: LbLookup, n: number): HTMLElement { const name = ctx.on.tableName?.(lk.table) ?? lk.table; return el("li", { className: "m01-lbsteps-row", children: [ sentence( span("m01-lbsteps-name", `${tx("LkNum")}${n}`), chip(name), tx("LkFrom"), ...lk.conds.map((c) => condView(ctx, lk, c)), tx("LkRow"), chip(lk.col), valueNote(lookupValue(ctx, lk)), ), ], }); } /* ------------------------------ ④ 항목 연결 ------------------------------ */ const BASES: (CostKind | "사람줄합")[] = ["노무비", "재료비", "경비", "사람줄합"]; function lineMap(ctx: Ctx): Map { const out = new Map(); const ans = ctx.ans; if (!ans || !ans.ok || !ans.lines) return out; const cards = ctx.lb.doc.cards; const part = (c: EasyCard): string => c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표"; const ordered = [ ...cards.filter((c) => part(c) === "호표"), ...cards.filter((c) => part(c) === "덧줄"), ]; if (ordered.length === ans.lines.length) ordered.forEach((c, i) => out.set(c.id, ans.lines![i])); return out; } const patchItem = (ctx: Ctx, id: string, patch: Partial): void => ctx.commit(() => ({ ...ctx.lb, doc: updateCard(ctx.lb.doc, id, patch) })); async function pickItem(ctx: Ctx, kind: PickResult["kind"], card?: EasyCard): Promise { if (picking) return; picking = true; try { const pick = await openEasyPick(kind); if (!pick) return; ctx.commit(() => { if (!card) return { ...ctx.lb, doc: addCard(ctx.lb.doc, pick) }; const next = updateCard(ctx.lb.doc, card.id, { name: pick.name, spec: pick.spec, unit: pick.unit, element: pick.element, }); return { ...ctx.lb, doc: withInputs(next, pick.needInputs ?? []) }; }); } finally { picking = false; } } function itemView(ctx: Ctx, card: EasyCard): HTMLElement { const kind = card.kind as Kind; const percent = kind === "percent"; const part = card.source?.part === "호표" ? "수량" : "비율"; const calc = card.source ? ctx.lb.calcs.find((c) => c.at.part === part && c.at.index === card.source?.index) : undefined; const line = ctx.lines.get(card.id); const qty: HTMLElement = calc ? el("span", { className: "m01-lbsteps-ref", children: [chip(`${tx("Calc")}${ctx.lb.calcs.indexOf(calc) + 1}`), valueNote(line?.수량)], }) : card.qtyFormula === null ? numBox(ctx, card.qty, (n) => patchItem(ctx, card.id, { qty: n })) : chip(tx("Hard")); const name = ctx.ro || kind === "locked" || percent ? span("m01-lbsteps-name", [card.name, card.spec].filter(Boolean).join(" ")) : (() => { const b = el("button", { className: "m01-lbsteps-pick", text: `${[card.name, card.spec].filter(Boolean).join(" ")} ▾`, attrs: { type: "button", "aria-label": tx("Repick") }, }); b.addEventListener("click", () => void pickItem(ctx, kind as PickResult["kind"], card)); return b; })(); const head: (HTMLElement | string)[] = [span("m01-lbsteps-tag", `【${tx(`Tag_${kind}`)}】`)]; if (percent) { const options = BASES.map((v) => ({ value: v, text: tx(`Base_${v}`) })); head.push( selectBox( ctx, options, card.percentBase ?? "노무비", (v) => patchItem(ctx, card.id, { percentBase: v as EasyCard["percentBase"] }), tx("S4"), ), tx("Pre_percent"), qty, tx("Post_percent"), name, ); } else { head.push( name, kind === "person" ? tx("Pre_person") : tx("Pre_pick"), qty, span("m01-lbsteps-unit", ` ${card.unit}`), ); } head.push( line ? span("m01-lbsteps-amount", `= ${formatMoney(line.금액)} ${tx("Won")}`) : span("m01-lbsteps-amount is-wait", `= ${tx("Pending")}`), ); if (!ctx.ro) { head.push( createButton({ label: tx("Remove"), variant: "ghost", size: "sm", onClick: () => ctx.commit(() => ({ ...ctx.lb, doc: removeCard(ctx.lb.doc, card.id) })), }), ); } return el("li", { className: "m01-lbsteps-row", children: [sentence(...head)] }); } function addBar(ctx: Ctx): HTMLElement { const add = (key: keyof typeof TEXT, run: () => void): HTMLButtonElement => createButton({ label: tx(key), variant: "ghost", size: "sm", onClick: run }); const pick = (kind: PickResult["kind"]) => () => void pickItem(ctx, kind); return el("div", { className: "m01-lbsteps-add", children: [ add("Add_person", pick("person")), add("Add_material", pick("material")), add("Add_machine", pick("machine")), add("Add_logic", pick("logic")), add("Add_percent", () => ctx.commit(() => ({ ...ctx.lb, doc: addPercent(ctx.lb.doc, "", "노무비", null, "경비") })), ), ], }); } /* ------------------------------ ⑤ 결과값 ------------------------------ */ function resultView(ctx: Ctx): HTMLElement { const result = ctx.lb.result; const ans = ctx.ans; if (result.kind === "값") { const value = ans?.ok ? ans.result : undefined; return el("div", { children: [ sentence(note(tx("ResultValue")), chip(result.unit), valueNote(value)), calcBody(ctx, result.calc), ], }); } const sums = ans?.ok ? ans.sums : undefined; const parts: (HTMLElement | string)[] = [note(tx("ResultMoney")), chip(result.unit)]; if (sums) { for (const k of ["노무비", "재료비", "경비"]) { if (k in sums) parts.push( span("m01-lbsteps-sum", `${tx(`Base_${k as CostKind}`)} ${formatMoney(sums[k])}`), ); } parts.push( span("m01-lbsteps-total", `${tx("Total")} ${formatMoney(sums["계"] ?? 0)} ${tx("Won")}`), ); } else parts.push(span("m01-lbsteps-amount is-wait", `= ${tx("Pending")}`)); return sentence(...parts); } /* ------------------------------ 전체 ------------------------------ */ function section(title: string, items: HTMLElement[], extra?: HTMLElement): HTMLElement { const body = items.length ? el("ol", { className: "m01-lbsteps-list", children: items }) : note(tx("None")); return el("section", { className: "m01-lbsteps-sec", children: [ el("h3", { className: "m01-lbsteps-title", text: title }), body, ...(extra ? [extra] : []), ], }); } /** 가운데 문장 보기 그리기 — ans 는 칸마다 곁에 보이는 예시값 · null 이면 「계산 중」 */ export function renderSteps( host: HTMLElement, lb: LbLogic, ans: CalcAnswer | null, on: { change(lb: LbLogic): void; tableName?(table: string): string | null }, ): void { const ctx: Ctx = { lb, ans, ro: lb.doc.isBase, on, lines: new Map(), commit: (make) => { try { on.change(make()); } catch { showToast(tx("Cannot"), "error"); } }, }; ctx.lines = lineMap(ctx); host.classList.add("m01-lbsteps"); const top: HTMLElement[] = []; if (ctx.ro) top.push(el("p", { className: "m01-lbsteps-banner", text: tx("BaseNote") })); if (lb.blocked.length) { top.push( el("div", { className: "m01-lbsteps-banner is-warn", children: [ el("p", { text: `⚠ ${tx("Blocked")}` }), ...lb.blocked.map((b) => el("p", { className: "m01-lbsteps-why", text: b })), ], }), ); } host.replaceChildren( ...top, section( tx("S1"), lb.inputs.map((x, i) => inputView(ctx, x, i)), ), section( tx("S2"), lb.lookups.map((l, i) => lookupView(ctx, l, i + 1)), ), section( tx("S3"), lb.calcs.map((c, i) => calcView(ctx, c, i + 1)), ), section( tx("S4"), lb.doc.cards.map((c) => itemView(ctx, c)), ctx.ro ? undefined : addBar(ctx), ), section(tx("S5"), [el("li", { className: "m01-lbsteps-row", children: [resultView(ctx)] })]), ); // 다른 일위대가 이름 — 목록을 한 번 받은 뒤 다시 그림 if ( !logicNames.size && !namesLoading && JSON.stringify([lb.calcs, lb.lookups]).includes('"k":"logic"') ) { namesLoading = true; fetchLogics() .then((all) => { for (const l of all) logicNames.set(l.키, l.이름); renderSteps(host, lb, ans, on); }) .catch(() => undefined) .finally(() => (namesLoading = false)); } }