From d92cbf588276511637b01fddbb6c0221b2556e77 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 11:48:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01-=EB=A1=9C=EC=A7=81=ED=8C=90):=20?= =?UTF-8?q?=EA=B0=80=EC=9A=B4=EB=8D=B0=20=EB=AC=B8=EC=9E=A5=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20Steps=20=EC=B6=94=EA=B0=80=20-=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EA=B0=92=20=C2=B7=20=ED=91=9C=20=EC=A1=B0=ED=9A=8C=20=C2=B7=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=C2=B7=20=ED=95=AD=EB=AA=A9=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=C2=B7=20=EA=B2=B0=EA=B3=BC=EA=B0=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- M01_MasterData/LogicBuilder/M01_LB_Steps.css | 201 ++++++++++ M01_MasterData/LogicBuilder/M01_LB_Steps.ts | 370 ++++++++++++++++++ .../LogicBuilder/M01_LB_Steps_Calc.ts | 201 ++++++++++ .../LogicBuilder/M01_LB_Steps_Cell.ts | 284 ++++++++++++++ 4 files changed, 1056 insertions(+) create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Steps.css create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Steps.ts create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts create mode 100644 M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps.css b/M01_MasterData/LogicBuilder/M01_LB_Steps.css new file mode 100644 index 000000000..6ae219619 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps.css @@ -0,0 +1,201 @@ +.m01-lbsteps { + display: flex; + flex-direction: column; + gap: var(--spacing-16); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m01-lbsteps-banner { + margin: 0; + padding: var(--spacing-8) var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + font-weight: var(--font-weight-medium); +} + +.m01-lbsteps-banner.is-warn { + border-left: 4px solid var(--color-warning); +} + +.m01-lbsteps-banner p { + margin: 0; +} + +.m01-lbsteps-why { + color: var(--color-text-secondary); + font-weight: var(--font-weight-regular); +} + +.m01-lbsteps-sec { + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + padding: var(--spacing-8) var(--spacing-16) var(--spacing-16); +} + +.m01-lbsteps-title { + margin: 0 0 var(--spacing-8); + font-size: var(--text-subheading); + font-weight: var(--font-weight-bold); +} + +.m01-lbsteps-list, +.m01-lbsteps-branches { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + margin: 0; + padding: 0; + list-style: none; +} + +.m01-lbsteps-row { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.m01-lbsteps-row:first-child { + padding-top: 0; + border-top: 0; +} + +.m01-lbsteps-sentence { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4) var(--spacing-8); + min-width: 0; +} + +.m01-lbsteps-ref, +.m01-lbsteps-cond, +.m01-lbsteps-chips { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} + +.m01-lbsteps-shapes, +.m01-lbsteps-shape { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-left: var(--spacing-16); + border-left: 2px solid var(--color-border); +} + +.m01-lbsteps-branch { + padding-left: var(--spacing-8); +} + +.m01-lbsteps-order { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.6em; + height: 1.6em; + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + font-weight: var(--font-weight-bold); +} + +.m01-lbsteps-name, +.m01-lbsteps-tag { + font-weight: var(--font-weight-bold); +} + +.m01-lbsteps-chip { + display: inline-block; + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); +} + +.m01-lbsteps-note { + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01-lbsteps-value, +.m01-lbsteps-sum { + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + white-space: nowrap; +} + +.m01-lbsteps-amount, +.m01-lbsteps-total { + margin-left: auto; + font-weight: var(--font-weight-bold); + white-space: nowrap; +} + +.m01-lbsteps-amount.is-wait { + opacity: 0.5; + font-weight: var(--font-weight-regular); +} + +.m01-lbsteps-pick { + min-height: 44px; + padding: 0 var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: inherit; + font: inherit; + font-weight: var(--font-weight-medium); + cursor: pointer; +} + +.m01-lbsteps-pick:focus-visible { + outline: 2px solid var(--color-focus-ring); +} + +.m01-lbsteps .ui-input, +.m01-lbsteps .ui-select, +.m01-lbsteps .ui-btn { + min-height: 44px; + font-size: var(--text-body); +} + +.m01-lbsteps-num { + flex: none; + width: 6em; +} + +.m01-lbsteps-text { + flex: none; + width: 9em; +} + +.m01-lbsteps-select { + flex: none; + width: auto; + max-width: 100%; +} + +.m01-lbsteps .ui-input.is-bad { + border-color: var(--color-danger); + border-style: dashed; +} + +.m01-lbsteps-add { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + margin-top: var(--spacing-8); +} + +@media (max-width: 640px) { + .m01-lbsteps-amount, + .m01-lbsteps-total { + margin-left: 0; + } +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps.ts new file mode 100644 index 000000000..3c7d266db --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps.ts @@ -0,0 +1,370 @@ +/* ============================================================================= + * 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 type { CalcAnswer, CalcLine, 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, + 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; + +/* ------------------------------ ① 입력값 ------------------------------ */ + +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.qty !== null && 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)] })]), + ); +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts new file mode 100644 index 000000000..213b1ebcd --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts @@ -0,0 +1,201 @@ +/* ============================================================================= + * M01_LB_Steps_Calc.ts + * 로직 만들기 판 문장 보기 ③ 계산 — 값 · 곱나눔 · 할증 · 조건 목록 · 최소/최대 · 올림 + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import type { CondOp, CondRead, CondShape, CondSlot } from "../EasyCalc/M01_EasyCalc_Cond"; +import { setCalc, type LbCalc } from "./M01_LB_Blocks"; +import { + chip, + middleOf, + note, + numBox, + refBox, + selectBox, + sentence, + span, + textBox, + tx, + valueNote, + type Ctx, + type TEXT, +} from "./M01_LB_Steps_Cell"; + +const OPS: [CondOp, keyof typeof TEXT][] = [ + ["==", "OpEq"], + ["!=", "OpNe"], + [">", "OpGt"], + [">=", "OpGe"], + ["<", "OpLt"], + ["<=", "OpLe"], +]; + +function slotBox(ctx: Ctx, slot: CondSlot, put: (s: CondSlot) => void): HTMLElement { + if (slot.kind === "num") return numBox(ctx, slot.value, (n) => put({ kind: "num", value: n })); + if (slot.kind === "text") return textBox(ctx, slot.value, (v) => put({ kind: "text", value: v })); + if (slot.kind === "input") { + const options = ctx.lb.inputs.map((i) => ({ value: i.이름, text: i.이름 })); + return selectBox(ctx, options, slot.name, (v) => put({ kind: "input", name: v }), tx("S1")); + } + return chip(tx(slot.kind === "table" ? "SlotTable" : "SlotCalc")); +} + +function shapeView(ctx: Ctx, shape: CondShape, put: (s: CondShape) => void): HTMLElement { + if (shape.type === "pick") { + const items: (HTMLElement | string)[] = []; + shape.items.forEach((s, i) => { + if (i) items.push(tx("And")); + items.push( + slotBox(ctx, s, (v) => + put({ ...shape, items: shape.items.map((x, j) => (j === i ? v : x)) }), + ), + ); + }); + const fn = [ + { value: "최소", text: tx("Small") }, + { value: "최대", text: tx("Big") }, + ]; + return sentence( + ...items, + selectBox(ctx, fn, shape.fn, (v) => put({ ...shape, fn: v as "최소" | "최대" }), tx("S3")), + ); + } + if (shape.type === "round") { + const fns = [ + { value: "올림", text: tx("Up") }, + { value: "버림", text: tx("Down") }, + { value: "반올림", text: tx("Half") }, + ]; + return sentence( + slotBox(ctx, shape.value, (v) => put({ ...shape, value: v })), + tx("RoundA"), + numBox(ctx, shape.places, (n) => put({ ...shape, places: n, hadPlaces: true })), + tx("RoundB"), + selectBox( + ctx, + fns, + shape.fn, + (v) => put({ ...shape, fn: v as "올림" | "버림" | "반올림" }), + tx("S3"), + ), + ); + } + const ops = OPS.map(([value, key]) => ({ value, text: tx(key) })); + const rows = shape.branches.map((b, i) => { + const edit = (patch: Partial): void => + put({ ...shape, branches: shape.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) }); + return el("li", { + className: "m01-lbsteps-branch", + children: [ + sentence( + span("m01-lbsteps-order", String(i + 1)), + slotBox(ctx, b.test.left, (v) => edit({ test: { ...b.test, left: v } })), + tx("Subject"), + slotBox(ctx, b.test.right, (v) => edit({ test: { ...b.test, right: v } })), + selectBox( + ctx, + ops, + b.test.op, + (v) => edit({ test: { ...b.test, op: v as CondOp } }), + tx("S3"), + ), + "→", + slotBox(ctx, b.value, (v) => edit({ value: v })), + ), + ], + }); + }); + const last = el("li", { + className: "m01-lbsteps-branch", + children: [ + sentence( + tx("Otherwise"), + slotBox(ctx, shape.otherwise, (v) => put({ ...shape, otherwise: v })), + ), + ], + }); + return el("div", { + className: "m01-lbsteps-shape", + children: [ + note(tx("Cond1")), + el("ol", { className: "m01-lbsteps-branches", children: [...rows, last] }), + ], + }); +} + +function placeText(ctx: Ctx, calc: LbCalc): string { + if (calc.name !== null) return calc.name; + const { part, index } = calc.at; + if (part === "결과") return tx("PlaceResult"); + const card = ctx.lb.doc.cards.find( + (c) => c.source?.index === index && c.source.part === (part === "수량" ? "호표" : "덧줄"), + ); + return `${card?.name ?? ""} ${tx(part === "수량" ? "PlaceQty" : "PlaceRate")}`.trim(); +} + +export function calcBody(ctx: Ctx, calc: LbCalc): HTMLElement { + const put = (patch: Partial): void => ctx.commit(() => setCalc(ctx.lb, calc, patch)); + const refAt = (i: number): HTMLElement => + refBox(ctx, calc.parts[i], calc, (r) => + put({ parts: calc.parts.map((p, j) => (j === i ? r : p)) }), + ); + if (calc.cond && calc.cond.blocks.length) { + const read: CondRead = calc.cond; + return el("div", { + className: "m01-lbsteps-shapes", + children: read.blocks.map((b, bi) => + shapeView(ctx, b.shape, (shape) => + put({ + cond: { ...read, blocks: read.blocks.map((x, j) => (j === bi ? { ...x, shape } : x)) }, + }), + ), + ), + }); + } + if (calc.kind === "값" && calc.parts.length) return sentence(refAt(0)); + if (calc.kind === "할증" && calc.parts.length >= 2) { + return sentence(refAt(0), tx("Mark1"), refAt(1), tx("Mark2")); + } + if ((calc.kind === "곱나눔" || calc.kind === "더하기") && calc.parts.length) { + const ops = calc.kind === "곱나눔" ? ["*", "/"] : ["+", "-"]; + const glyph = (o: string): string => + tx(o === "*" ? "Mul" : o === "/" ? "Div" : o === "+" ? "Plus" : "Minus"); + const out: (HTMLElement | string)[] = [refAt(0)]; + calc.parts.slice(1).forEach((_, i) => { + const op = calc.ops?.[i] ?? ops[0]; + const choices = ops.map((o) => ({ value: o, text: glyph(o) })); + out.push( + selectBox( + ctx, + choices, + op, + (v) => + put({ + ops: calc.parts + .slice(1) + .map((_, j) => (j === i ? v : (calc.ops?.[j] ?? ops[0])) as "*" | "/" | "+" | "-"), + }), + tx("S3"), + ), + refAt(i + 1), + ); + }); + return sentence(...out); + } + return sentence(note(tx("Steps"))); +} + +export function calcView(ctx: Ctx, calc: LbCalc, n: number): HTMLElement { + return el("li", { + className: "m01-lbsteps-row", + children: [ + sentence( + span("m01-lbsteps-name", `${tx("Calc")}${n}`), + chip(placeText(ctx, calc)), + valueNote(middleOf(ctx, calc.name)), + ), + calcBody(ctx, calc), + ], + }); +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts new file mode 100644 index 000000000..e3b1ae55d --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts @@ -0,0 +1,284 @@ +/* ============================================================================= + * M01_LB_Steps_Cell.ts + * 로직 만들기 판 문장 보기 공용 — 글 [한, 영] 짝 · 작은 칸(숫자 · 글 · 고르기) · 값이 오는 곳 고르기 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; +import type { LbCalc, LbExpr, LbLogic, LbLookup, LbRef } from "./M01_LB_Blocks"; + +export const TEXT = { + Blocked: ["이 화면에서 읽지 못하는 줄이 있음", "Some lines cannot be read on this screen"], + Cannot: ["이 고침은 지금 할 수 없음", "This change cannot be made now"], + BaseNote: ["표준 로직은 읽기만 — 복사해서 고침", "Standard logic is read-only — copy to edit"], + S1: ["① 입력값", "① Input values"], + S2: ["② 표 조회", "② Table lookup"], + S3: ["③ 계산", "③ Calculation"], + S4: ["④ 항목 연결", "④ Items"], + S5: ["⑤ 결과값", "⑤ Result"], + None: ["없음", "none"], + PickList: ["선택 목록", "choose from"], + Number: ["숫자", "number"], + Range: ["범위", "range"], + Unit: ["단위", "unit"], + Eg: ["예: 3", "e.g. 3"], + NotNumber: ["숫자만 넣음", "Numbers only"], + LkFrom: [" 에서 ", " : "], + LkIs: [" 이 ", " is "], + LkRow: [" 인 줄의 ", " in the row, take "], + LkNum: ["표 조회 ", "Lookup "], + SrcInput: ["입력값 · ", "input · "], + SrcCalc: ["계산 · ", "calc · "], + SrcFixed: ["고정값", "fixed value"], + SrcAny: ["무관(조건에서 뺌)", "any (remove condition)"], + Other: ["다른 일위대가 결과", "Other unit price result"], + Hard: ["이 화면에서 못 고치는 값", "Value not editable here"], + Mul: ["×", "×"], + Div: ["÷", "÷"], + Plus: ["+", "+"], + Minus: ["−", "−"], + Mark1: ["에 ", ": add "], + Mark2: [" % 를 더 얹음", " % on top"], + Steps: ["여러 단계로 된 계산 — 읽기만", "Multi-step calculation — read only"], + Cond1: ["위에서 처음 맞는 줄의 값을 씀", "The first matching line from the top is used"], + Subject: ["가", "is"], + Otherwise: ["그 외 →", "Otherwise →"], + Big: ["가운데 큰 값", "the larger"], + Small: ["가운데 작은 값", "the smaller"], + And: ["와", "and"], + RoundA: ["을 소수 ", "round to "], + RoundB: [" 자리까지 ", " decimals, "], + Up: ["올림", "round up"], + Down: ["버림", "round down"], + Half: ["반올림", "round off"], + OpEq: ["이면", "equal to"], + OpNe: ["이 아니면", "not equal to"], + OpGt: ["보다 크면", "greater than"], + OpGe: ["이상이면", "at least"], + OpLt: ["보다 작으면", "less than"], + OpLe: ["이하이면", "at most"], + SlotTable: ["표에서 찾은 값", "Value from table"], + SlotCalc: ["계산한 값", "Calculated value"], + PlaceQty: ["수량", "amount"], + PlaceRate: ["비율", "rate"], + PlaceResult: ["결과값", "result"], + Calc: ["계산 ", "Calc "], + Tag_person: ["노무", "Labor"], + Tag_material: ["재료", "Material"], + Tag_machine: ["장비", "Equipment"], + Tag_logic: ["일위대가", "Unit price"], + Tag_percent: ["비율", "Rate"], + Tag_locked: ["기타", "Other"], + Pre_person: [" 이 ", " works "], + Pre_pick: [" 를 ", " uses "], + Pre_percent: [" 의 ", " of "], + Post_percent: [" % ", " % "], + Repick: ["다른 것으로 다시 고르기", "Choose another"], + Remove: ["지움", "Remove"], + Pending: ["계산 중", "calculating"], + Won: ["원", "KRW"], + Add_person: ["+ 노무", "+ Labor"], + Add_material: ["+ 재료", "+ Material"], + Add_machine: ["+ 장비", "+ Equipment"], + Add_logic: ["+ 일위대가", "+ Unit price"], + Add_percent: ["+ 비율", "+ Rate"], + Base_노무비: ["노무비", "labor cost"], + Base_재료비: ["재료비", "material cost"], + Base_경비: ["경비", "expenses"], + Base_사람줄합: ["노무 줄의 합", "sum of labor lines"], + ResultMoney: ["결과 = 금액", "Result = amount"], + ResultValue: [ + "결과 = 값 — 다른 일위대가가 불러 쓸 수 있음", + "Result = value — other unit prices can use it", + ], + Total: ["합계", "Total"], +} as const; + +export const tx = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +export type Ref = LbRef | LbExpr; + +export interface Ctx { + lb: LbLogic; + ans: CalcAnswer | null; + ro: boolean; + on: { change(lb: LbLogic): void; tableName?(table: string): string | null }; + lines: Map; + /** 바꾸기 — 못 하면 쉬운 말 한 줄 */ + commit(make: () => LbLogic): void; +} + +/* ------------------------------ 작은 칸 ------------------------------ */ + +export const span = (cls: string, text: string): HTMLSpanElement => + el("span", { className: cls, text }); +export const chip = (text: string, title?: string): HTMLSpanElement => { + const c = span("m01-lbsteps-chip", text); + if (title) c.title = title; + return c; +}; +export const note = (text: string): HTMLSpanElement => span("m01-lbsteps-note", text); +export const sentence = (...parts: (HTMLElement | string)[]): HTMLElement => + el("div", { className: "m01-lbsteps-sentence", children: parts }); + +export const parseNum = (text: string): number | null => { + const t = text.replace(/[,\s]/g, ""); + return /^[+-]?(\d+\.?\d*|\.\d+)$/.test(t) ? Number(t) : null; +}; + +export function numBox(ctx: Ctx, value: number | null, commit: (n: number) => void): HTMLElement { + if (ctx.ro) return chip(value === null ? "" : String(value)); + const input = el("input", { + className: "ui-input m01-lbsteps-num", + attrs: { type: "text", inputmode: "decimal", placeholder: tx("Eg") }, + }); + input.value = value === null ? "" : String(value); + input.addEventListener("change", () => { + const n = parseNum(input.value); + input.classList.toggle("is-bad", n === null); + input.title = n === null ? tx("NotNumber") : ""; + if (n !== null && n !== value) commit(n); + }); + return input; +} + +export function textBox(ctx: Ctx, value: string, commit: (v: string) => void): HTMLElement { + if (ctx.ro) return chip(value); + const input = el("input", { className: "ui-input m01-lbsteps-text", attrs: { type: "text" } }); + input.value = value; + input.addEventListener("change", () => input.value !== value && commit(input.value.trim())); + return input; +} + +export function selectBox( + ctx: Ctx, + options: { value: string; text: string }[], + current: string, + commit: (v: string) => void, + aria: string, +): HTMLElement { + const hit = options.find((o) => o.value === current); + if (ctx.ro) return chip(hit?.text ?? ""); + const select = el("select", { + className: "ui-select m01-lbsteps-select", + attrs: { "aria-label": aria }, + }); + for (const o of options) select.append(el("option", { attrs: { value: o.value }, text: o.text })); + select.value = hit ? current : (options[0]?.value ?? ""); + select.addEventListener("change", () => commit(select.value)); + return select; +} + +export const show = (v: unknown): string => (v === undefined || v === null ? "" : formatNumber(v)); +export const valueNote = (v: unknown): HTMLElement | string => + v === undefined || v === null ? "" : span("m01-lbsteps-value", `= ${show(v)}`); + +/* ------------------------------ 값 찾기 ------------------------------ */ + +export const middleOf = (ctx: Ctx, name: string | null): unknown => + name !== null && ctx.ans?.ok ? ctx.ans.middle[name] : undefined; + +/** 표 조회 값 — 그 값 하나만 이름 붙은 계산이 되어 있을 때만 앎 */ +export function lookupValue(ctx: Ctx, lk: LbLookup): unknown { + const calc = ctx.lb.calcs.find( + (c) => + c.kind === "값" && + c.name !== null && + c.parts.length === 1 && + isRef(c.parts[0], "lookup", lk.id), + ); + return middleOf(ctx, calc?.name ?? null); +} + +export const isRef = (r: Ref, k: "lookup", id: string): boolean => r.k === k && r.id === id; + +export const lookupNo = (ctx: Ctx, id: string): number => + ctx.lb.lookups.findIndex((l) => l.id === id) + 1; + +export function refText(ctx: Ctx, r: Ref): string { + switch (r.k) { + case "input": + return r.name; + case "fixed": + return String(r.value); + case "calc": + return r.name; + case "lookup": { + const lk = ctx.lb.lookups.find((l) => l.id === r.id); + return `${tx("LkNum")}${lookupNo(ctx, r.id)}${lk ? ` · ${lk.col}` : ""}`; + } + case "logic": + return `${tx("Other")} ${r.key}${r.field ? ` · ${r.field}` : ""}`; + case "cost": + return tx(`Base_${r.name}`); + default: + return tx("Hard"); + } +} + +export function refValue(ctx: Ctx, r: Ref): unknown { + if (r.k === "calc") return middleOf(ctx, r.name); + if (r.k === "fixed") return r.value; + if (r.k === "lookup") { + const lk = ctx.lb.lookups.find((l) => l.id === r.id); + return lk ? lookupValue(ctx, lk) : undefined; + } + return undefined; +} + +/** 값이 오는 곳 고르기 — 입력값 · 표 조회 · 다른 계산 · 고정값 */ +export function refBox( + ctx: Ctx, + ref: Ref, + self: LbCalc | null, + pick: (r: LbRef) => void, +): HTMLElement { + const label = refText(ctx, ref); + const editable = ref.k === "input" || ref.k === "calc" || ref.k === "lookup" || ref.k === "fixed"; + if (ctx.ro || !editable) { + return el("span", { + className: "m01-lbsteps-ref", + children: [chip(label), valueNote(refValue(ctx, ref))], + }); + } + const options = [ + ...ctx.lb.inputs.map((i) => ({ value: `i:${i.이름}`, text: `${tx("SrcInput")}${i.이름}` })), + ...ctx.lb.lookups.map((l) => ({ + value: `l:${l.id}`, + text: refText(ctx, { k: "lookup", id: l.id }), + })), + ...ctx.lb.calcs + .filter((c) => c.name !== null && c !== self) + .map((c) => ({ value: `c:${c.name}`, text: `${tx("SrcCalc")}${c.name}` })), + { value: "f", text: tx("SrcFixed") }, + ]; + const current = + ref.k === "input" + ? `i:${ref.name}` + : ref.k === "calc" + ? `c:${ref.name}` + : ref.k === "lookup" + ? `l:${ref.id}` + : "f"; + const toRef = (v: string): LbRef => + v === "f" + ? { k: "fixed", value: 0 } + : v.startsWith("i:") + ? { k: "input", name: v.slice(2) } + : v.startsWith("l:") + ? { k: "lookup", id: v.slice(2) } + : { k: "calc", name: v.slice(2) }; + const parts: (HTMLElement | string)[] = [ + selectBox(ctx, options, current, (v) => pick(toRef(v)), tx("S3")), + ]; + if (ref.k === "fixed") { + parts.push( + numBox(ctx, typeof ref.value === "number" ? ref.value : null, (n) => + pick({ k: "fixed", value: n }), + ), + ); + } else parts.push(valueNote(refValue(ctx, ref))); + return el("span", { className: "m01-lbsteps-ref", children: parts }); +}