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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user