/* ============================================================================= * M01_LB_Wizard_Calc.ts * 단계 안내 4단계(계산 — 주 → 문장 틀) · 5단계(항목 연결) 화면 — `M01_LB_Wizard.ts` 가 부름 * 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 9 * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField, el, showToast, } from "@ui/ui_template_elements"; import { fetchPick } from "../M01_MasterData_Api_Fetch"; import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick"; import { plainError } from "../EasyCalc/M01_EasyCalc_Api"; import { colsOf, type Extra, type Link, type LinkKind, type Mod, type WCtx, } from "./M01_LB_Wizard_Build"; const TEXT = { NoteHint: [ "주를 누르면 그 주에 맞는 문장 틀이 뜸 — 안 눌러도 아래에서 바로 고름", "Press a note to see matching sentences — or pick one below", ], NoNote: ["이 표에는 주가 없음", "This table has no notes"], Frames: ["문장 틀", "Sentence"], Surcharge: ["할증(%)", "Surcharge (%)"], Divide: ["나누기", "Divide"], Min: ["작은 값", "Smaller of"], Max: ["큰 값", "Larger of"], Round: ["올림 · 버림", "Round"], RateType: ["율", "Rate"], RNum: ["숫자", "Number"], RInput: ["입력값으로 받음", "Ask as an input"], RCond: ["조건 목록(표 조회)", "Condition list (table)"], RName: ["입력값 이름", "Input name"], Value: ["값", "Value"], Table: ["찾을 표", "Table"], ResultCol: ["결과 칸", "Result column"], Limit: ["이하이면 0 · 넘으면 표에서 찾음", "0 up to the limit · above it use the table"], DivBy: ["나눌 값", "Divide by"], DNum: ["숫자", "Number"], DInput: ["입력값으로 받음", "Ask as an input"], Places: ["소수 자리", "Decimal places"], Add: ["넣기", "Add"], Remove: ["삭제", "Remove"], Added: ["넣은 문장", "Added"], None: ["아직 없음", "None yet"], DSurchargeNum: ["{p} % 를 더함", "add {p} %"], DSurchargeIn: ["「{n}」 % 를 더함(입력값)", "add “{n}” % (input)"], DSurchargeCond: [ "「{i}」 가 {l} {o}이면 0 · 아니면 「{t}」 의 {c} % 를 더함", "if “{i}” is {l} {o} add 0, else “{t}” {c} %", ], OpLe: ["이하", "or less"], OpLt: ["미만", "under"], DDivNum: ["{n} 로 나눔", "divide by {n}"], DDivIn: ["「{n}」 로 나눔(입력값)", "divide by “{n}” (input)"], DMin: ["{n} 보다 작은 값", "smaller of value and {n}"], DMax: ["{n} 보다 큰 값", "larger of value and {n}"], DRound: ["{f} — 소수 {n} 자리", "{f} to {n} places"], Src: ["출처 {s}", "Source {s}"], Pick5: ["어느 항목으로 보낼까요?", "Which item should each result go to?"], Labor: ["노무", "Labor"], Material: ["재료", "Material"], Machine: ["장비", "Machine"], Logic: ["일위대가", "Another job"], Skip: ["연결 안 함", "Skip"], Auto: ["이름이 같아 저절로 연결", "Matched by name"], Choose: ["고르기", "Choose"], Change: ["변경", "Change"], NotLinked: ["아직 연결 안 됨", "Not linked yet"], ExtraHead: ["비율 · 기타 항목", "Rate · other items"], AddRate: ["+ 비율", "+ Rate"], AddOther: ["+ 기타", "+ Other"], ItemName: ["이름", "Name"], Base: ["바탕", "Of"], RatePct: ["비율 %", "Rate %"], Amount: ["금액(원)", "Amount (won)"], Where: ["넣을 자리", "Goes to"], SumLabor: ["사람 줄 노무비 합", "Labor sum"], Labor$: ["노무비", "Labor cost"], Mat$: ["재료비", "Material cost"], Exp$: ["경비", "Expense"], RateLine: ["{b} 의 {r} % — {n}", "{r} % of {b} — {n}"], OtherLine: ["{n} — {a} 원", "{n} — {a} won"], } as const; type TKey = keyof typeof TEXT; const t = (key: TKey, v: Record = {}): string => Object.entries(v).reduce( (s, [k, x]) => s.replace(`{${k}}`, String(x)), TEXT[key][currentLanguageIndex] as string, ); const numOf = (s: string): number | null => s.trim() !== "" && Number.isFinite(Number(s)) ? Number(s) : null; const sel = ( options: [string, string][], value: string, onChange?: (v: string) => void, ): ReturnType => createSelectField({ options: options.map(([value, text]) => ({ value, text })), value, compact: true, onChange, }); const row = (label: string, ...kids: HTMLElement[]): HTMLElement => el("label", { className: "m01-lbw__line", children: [el("span", { className: "m01-lbw__muted", text: label }), ...kids], }); /* ── 4단계 계산 ─────────────────────────────────────────────────────────── */ const describe = (m: Mod): string => { const src = m.src ? ` · ${t("Src", { s: m.src })}` : ""; if (m.k === "할증") { const r = m.rate; const text = r.t === "num" ? t("DSurchargeNum", { p: r.v }) : r.t === "input" ? t("DSurchargeIn", { n: r.name }) : t("DSurchargeCond", { i: r.input, l: r.limit, o: t(r.op === "<=" ? "OpLe" : "OpLt"), t: r.table.이름, c: r.col, }); return `${t("Surcharge")} — ${text}${src}`; } if (m.k === "나누기") return `${t("Divide")} — ${m.by.t === "num" ? t("DDivNum", { n: m.by.v }) : t("DDivIn", { n: m.by.name })}${src}`; if (m.k === "최소" || m.k === "최대") return `${m.k === "최소" ? t("Min") : t("Max")} — ${t(m.k === "최소" ? "DMin" : "DMax", { n: m.v })}${src}`; return `${t("Round")} — ${t("DRound", { f: m.fn, n: m.places })}${src}`; }; /** 주 글에서 맞는 문장 틀 추리기 */ const guess = (note: string): string => /할증|증가|가산|초과/.test(note) ? "할증" : /나누|시공량|당\s*작업/.test(note) ? "나누기" : /이상|이하|최대|최소/.test(note) ? "최대" : /올림|절상|반올림/.test(note) ? "올림" : ""; export function stepCalc(ctx: WCtx, host: HTMLElement): void { const { a } = ctx; let frame = ""; let src = ""; const draw = (): void => { const notes = a.table.주; const noteBox = el("div", { className: "m01-lbw__notes" }); if (!notes.length) noteBox.append(el("p", { className: "m01-lbw__muted", text: t("NoNote") })); notes.forEach((n, i) => { const b = el("button", { className: `m01-lbw__note${src === `주${i}` ? " is-on" : ""}`, attrs: { type: "button" }, text: n, }); b.addEventListener("click", () => { src = `주${i}`; frame = guess(n) || frame; draw(); }); noteBox.append(b); }); const noteSrc = (): string | undefined => { const i = Number(src.slice(1)); const mark = src && notes[i] ? (notes[i].match(/^[①-⑳]/)?.[0] ?? `${i + 1}`) : ""; return mark ? `${a.table.출처} 주${mark}` : undefined; }; const frames: [string, TKey][] = [ ["할증", "Surcharge"], ["나누기", "Divide"], ["최소", "Min"], ["최대", "Max"], ["올림", "Round"], ]; const tabs = el("div", { className: "m01-lbw__chips", children: frames.map(([k, label]) => { const b = el("button", { className: `m01-lbw__chip${frame === k ? " is-on" : ""}`, attrs: { type: "button" }, text: t(label), }); b.addEventListener("click", () => ((frame = k), draw())); return b; }), }); const added = el("div", { className: "m01-lbw__added" }); a.mods.forEach((m, i) => { const rm = createButton({ label: t("Remove"), variant: "ghost", size: "sm", onClick: () => (a.mods.splice(i, 1), ctx.changed(), draw()), }); added.append( el("div", { className: "m01-lbw__item", children: [el("span", { text: describe(m) }), rm], }), ); }); if (!a.mods.length) added.append(el("p", { className: "m01-lbw__muted", text: t("None") })); host.replaceChildren( el("p", { className: "m01-lbw__muted", text: t("NoteHint") }), noteBox, el("h5", { text: t("Frames") }), tabs, frame ? form(frame, noteSrc()) : el("span"), el("h5", { text: t("Added") }), added, ); }; const form = (k: string, from: string | undefined): HTMLElement => { const box = el("div", { className: "m01-lbw__form" }); const push = (m: Mod): void => ( a.mods.push(from ? { ...m, src: from } : m), (frame = ""), (src = ""), ctx.changed(), draw() ); const addBtn = (go: () => void): HTMLElement => createButton({ label: t("Add"), onClick: go }); if (k === "할증") { const others = ctx.sectionTables.filter( (x) => x.key !== a.table.key && Object.keys(x.조건).length === 1 && Object.keys(x.값칸).length, ); const type = sel( [ ["num", t("RNum")], ["input", t("RInput")], ...(others.length ? ([["cond", t("RCond")]] as [string, string][]) : []), ], "num", () => paint(), ); const body = el("div", { className: "m01-lbw__stack" }); const paint = (): void => { const num = createInputField({ type: "text", placeholder: "예: 30", compact: true }); const nm = createInputField({ type: "text", value: "할증률", compact: true }); const tb = sel( others.map((x) => [x.key, x.이름]), others[0]?.key ?? "", () => paint(), ); const tbl = others.find((x) => x.key === tb.select.value) ?? others[0]; const col = sel( Object.keys(tbl?.값칸 ?? {}).map((c) => [c, c]), "", ); const lim = createInputField({ type: "text", placeholder: "예: 3", compact: true }); const op = sel( [ ["<=", "≤ (이하)"], ["<", "< (미만)"], ], "<=", ); body.replaceChildren( ...(type.select.value === "num" ? [row(t("Value"), num.root)] : type.select.value === "input" ? [row(t("RName"), nm.root)] : [ row(t("Table"), tb.root), row(t("ResultCol"), col.root), row(t("Limit"), op.root, lim.root), ]), addBtn(() => { const ty = type.select.value; const n = numOf(num.input.value); const l = numOf(lim.input.value); if (ty === "num" && n !== null) push({ k: "할증", rate: { t: "num", v: n } }); else if (ty === "input" && nm.input.value.trim()) push({ k: "할증", rate: { t: "input", name: nm.input.value.trim() } }); else if (ty === "cond" && tbl && l !== null) push({ k: "할증", rate: { t: "cond", input: Object.keys(tbl.조건)[0], op: op.select.value as "<=" | "<", limit: l, table: tbl, col: col.select.value, }, }); }), ); }; paint(); box.append(row(t("RateType"), type.root), body); } else if (k === "나누기") { const ty = sel( [ ["num", t("DNum")], ["input", t("DInput")], ], "num", () => paint(), ); const f = createInputField({ type: "text", placeholder: "예: 8", compact: true }); const body = el("div", { className: "m01-lbw__stack" }); const paint = (): void => { f.input.value = ty.select.value === "input" ? "시공량" : ""; body.replaceChildren(row(t("DivBy"), f.root)); }; paint(); box.append( row(t("DivBy"), ty.root), body, addBtn(() => { const n = numOf(f.input.value); if (ty.select.value === "num" && n !== null && n !== 0) push({ k: "나누기", by: { t: "num", v: n } }); else if (ty.select.value === "input" && f.input.value.trim()) push({ k: "나누기", by: { t: "input", name: f.input.value.trim() } }); }), ); } else if (k === "최소" || k === "최대") { const f = createInputField({ type: "text", placeholder: "예: 100", compact: true }); box.append( row(t(k === "최소" ? "Min" : "Max"), f.root), addBtn(() => { const n = numOf(f.input.value); if (n !== null) push({ k, v: n }); }), ); } else { const fn = sel( [ ["올림", "올림"], ["버림", "버림"], ["반올림", "반올림"], ], "올림", ); const p = createInputField({ type: "text", value: "2", compact: true }); box.append( row(t("Round"), fn.root), row(t("Places"), p.root), addBtn(() => { const n = numOf(p.input.value); if (n !== null && n >= 0) push({ k: "올림", fn: fn.select.value as "올림", places: Math.floor(n) }); }), ); } return box; }; draw(); } /* ── 5단계 항목 연결 ───────────────────────────────────────────────────── */ /** 처음 들어올 때 — 결과 칸 이름이 노임 · 자재 이름과 같으면 저절로 연결 */ export async function autoLinks(ctx: WCtx): Promise { const { a } = ctx; for (const col of colsOf(a, "결과")) { if (a.links.some((l) => l.col === col && l.kind !== "없음")) continue; const unit = a.table.값칸[col] ?? ""; const link: Link = { col, kind: "없음", name: col, spec: "", unit, element: null }; try { const job = (await fetchPick("job", col)).items.find((i) => i.이름 === col); if (job) Object.assign(link, { kind: "노무", unit: unit || "인", element: job.ref }); else { const mat = (await fetchPick("price", col)).items.find((i) => i.이름 === col); if (mat) Object.assign(link, { kind: "재료", spec: mat.규격, unit: unit || mat.단위, element: { 구분: (mat as { 구분?: string }).구분 ?? "", 검색어: `${mat.이름} ${mat.규격}`.trim(), }, }); } } catch { /* 못 찾으면 연결 안 함으로 둠 */ } a.links = a.links.filter((l) => l.col !== col); a.links.push(link); } ctx.changed(); } const KINDS: [LinkKind, TKey, "person" | "material" | "machine" | "logic"][] = [ ["노무", "Labor", "person"], ["재료", "Material", "material"], ["장비", "Machine", "machine"], ["일위대가", "Logic", "logic"], ]; export function stepLink(ctx: WCtx, host: HTMLElement): void { const { a } = ctx; const draw = (): void => { const rows = colsOf(a, "결과").map((col) => { const link = a.links.find((l) => l.col === col) ?? { col, kind: "없음" as LinkKind, name: col, spec: "", unit: a.table.값칸[col] ?? "", element: null, }; if (!a.links.includes(link)) a.links.push(link); const pick = async ( kind: LinkKind, pk: "person" | "material" | "machine" | "logic", ): Promise => { const r = await openEasyPick(pk, { qty: 1 }); if (!r) return; Object.assign(link, { kind, name: r.name, spec: r.spec, unit: a.table.값칸[col] || r.unit, element: r.element, needInputs: r.needInputs, }); ctx.changed(); draw(); }; const btns = KINDS.map(([kind, label, pk]) => { const b = el("button", { className: `m01-lbw__chip${link.kind === kind ? " is-on" : ""}`, attrs: { type: "button" }, text: t(label), }); b.addEventListener( "click", () => void pick(kind, pk).catch((e) => showToast(plainError(e), "error")), ); return b; }); const none = el("button", { className: `m01-lbw__chip${link.kind === "없음" ? " is-on" : ""}`, attrs: { type: "button" }, text: t("Skip"), }); none.addEventListener( "click", () => (Object.assign(link, { kind: "없음", element: null }), ctx.changed(), draw()), ); const state = link.kind === "없음" ? t("NotLinked") : `${link.name} ${link.spec}`.trim(); return el("div", { className: "m01-lbw__link", children: [ el("strong", { text: `${col} (${a.table.값칸[col] || "—"})` }), el("div", { className: "m01-lbw__chips", children: [...btns, none] }), el("span", { className: "m01-lbw__muted", text: state }), ], }); }); host.replaceChildren( el("p", { className: "m01-lbw__muted", text: t("Pick5") }), ...rows, extras(ctx, draw), ); }; draw(); } /** 비율 · 기타 덧줄 */ function extras(ctx: WCtx, redraw: () => void): HTMLElement { const { a } = ctx; const costs: [string, string][] = [ ["노무비", t("Labor$")], ["재료비", t("Mat$")], ["경비", t("Exp$")], ]; const bases: [string, string][] = [["사람줄합", t("SumLabor")], ...costs]; const list = a.extras.map((e: Extra, i) => { const text = e.k === "비율" ? t("RateLine", { b: bases.find(([v]) => v === e.base)?.[1] ?? e.base, r: e.rate, n: e.name, }) : t("OtherLine", { n: e.name, a: e.amount }); return el("div", { className: "m01-lbw__item", children: [ el("span", { text }), createButton({ label: t("Remove"), variant: "ghost", size: "sm", onClick: () => (a.extras.splice(i, 1), ctx.changed(), redraw()), }), ], }); }); const name = createInputField({ type: "text", placeholder: t("ItemName"), compact: true }); const base = sel(bases, "사람줄합"); const val = createInputField({ type: "text", placeholder: t("RatePct"), compact: true }); const where = sel(costs, "재료비"); const add = (k: "비율" | "기타"): void => { const n = numOf(val.input.value); const nm = name.input.value.trim(); if (n === null || !nm) return; const cost = where.select.value as "노무비"; a.extras.push( k === "비율" ? { k, name: nm, base: base.select.value as "노무비", rate: n, cost } : { k, name: nm, amount: n, cost }, ); ctx.changed(); redraw(); }; return el("div", { className: "m01-lbw__extras", children: [ el("h5", { text: t("ExtraHead") }), ...list, el("div", { className: "m01-lbw__form", children: [ row(t("ItemName"), name.root), row(t("Base"), base.root), row(`${t("RatePct")} / ${t("Amount")}`, val.root), row(t("Where"), where.root), el("div", { className: "m01-lbw__chips", children: [ createButton({ label: t("AddRate"), onClick: () => add("비율") }), createButton({ label: t("AddOther"), variant: "ghost", onClick: () => add("기타") }), ], }), ], }), ], }); }