feat(M01-로직판): 가운데 문장 보기 Steps 추가 - 입력값 · 표 조회 · 계산 · 항목 연결 · 결과값

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
2026-10-01 11:48:12 +09:00
co-authored by Claude Sonnet 5.5
parent a6da33fe75
commit d92cbf5882
4 changed files with 1056 additions and 0 deletions
@@ -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;
}
}
+370
View File
@@ -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<LogicInput>): 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<string, CalcLine> {
const out = new Map<string, CalcLine>();
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<EasyCard>): void =>
ctx.commit(() => ({ ...ctx.lb, doc: updateCard(ctx.lb.doc, id, patch) }));
async function pickItem(ctx: Ctx, kind: PickResult["kind"], card?: EasyCard): Promise<void> {
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)] })]),
);
}
@@ -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<typeof b>): 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<LbCalc>): 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),
],
});
}
@@ -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<string, CalcLine>;
/** 바꾸기 — 못 하면 쉬운 말 한 줄 */
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 });
}