Files
Aislo/M01_MasterData/LogicBuilder/M01_LB_Steps.ts
T

371 lines
12 KiB
TypeScript

/* =============================================================================
* 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)] })]),
);
}