Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
393 lines
12 KiB
TypeScript
393 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 {
|
|
fetchLogics,
|
|
type CalcAnswer,
|
|
type CalcLine,
|
|
type 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,
|
|
logicNames,
|
|
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;
|
|
let namesLoading = 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.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)] })]),
|
|
);
|
|
// 다른 일위대가 이름 — 목록을 한 번 받은 뒤 다시 그림
|
|
if (
|
|
!logicNames.size &&
|
|
!namesLoading &&
|
|
JSON.stringify([lb.calcs, lb.lookups]).includes('"k":"logic"')
|
|
) {
|
|
namesLoading = true;
|
|
fetchLogics()
|
|
.then((all) => {
|
|
for (const l of all) logicNames.set(l.키, l.이름);
|
|
renderSteps(host, lb, ans, on);
|
|
})
|
|
.catch(() => undefined)
|
|
.finally(() => (namesLoading = false));
|
|
}
|
|
}
|