Files
Aislo/M01_MasterData/LogicBuilder/M01_LB_Wizard_Calc.ts
T

559 lines
19 KiB
TypeScript

/* =============================================================================
* M01_LB_Wizard_Calc.ts
* 단계 안내 4단계(계산 — 주 → 문장 틀) · 5단계(항목 연결) 화면 — `M01_LB_Wizard.ts` 가 부름
* 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 9
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { fetchPick } from "../M01_MasterData_Api_Fetch";
import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick";
import { plainError } from "../EasyCalc/M01_EasyCalc_Api";
import {
colsOf,
type Extra,
type Link,
type LinkKind,
type Mod,
type WCtx,
} from "./M01_LB_Wizard_Build";
const TEXT = {
NoteHint: [
"주를 누르면 그 주에 맞는 문장 틀이 뜸 — 안 눌러도 아래에서 바로 고름",
"Press a note to see matching sentences — or pick one below",
],
NoNote: ["이 표에는 주가 없음", "This table has no notes"],
Frames: ["문장 틀", "Sentence"],
Surcharge: ["할증(%)", "Surcharge (%)"],
Divide: ["나누기", "Divide"],
Min: ["작은 값", "Smaller of"],
Max: ["큰 값", "Larger of"],
Round: ["올림 · 버림", "Round"],
RateType: ["율", "Rate"],
RNum: ["숫자", "Number"],
RInput: ["입력값으로 받음", "Ask as an input"],
RCond: ["조건 목록(표 조회)", "Condition list (table)"],
RName: ["입력값 이름", "Input name"],
Value: ["값", "Value"],
Table: ["찾을 표", "Table"],
ResultCol: ["결과 칸", "Result column"],
Limit: ["이하이면 0 · 넘으면 표에서 찾음", "0 up to the limit · above it use the table"],
DivBy: ["나눌 값", "Divide by"],
DNum: ["숫자", "Number"],
DInput: ["입력값으로 받음", "Ask as an input"],
Places: ["소수 자리", "Decimal places"],
Add: ["넣기", "Add"],
Remove: ["삭제", "Remove"],
Added: ["넣은 문장", "Added"],
None: ["아직 없음", "None yet"],
DSurchargeNum: ["{p} % 를 더함", "add {p} %"],
DSurchargeIn: ["「{n}」 % 를 더함(입력값)", "add “{n}” % (input)"],
DSurchargeCond: [
"「{i}」 가 {l} {o}이면 0 · 아니면 「{t}」 의 {c} % 를 더함",
"if “{i}” is {l} {o} add 0, else “{t}” {c} %",
],
OpLe: ["이하", "or less"],
OpLt: ["미만", "under"],
DDivNum: ["{n} 로 나눔", "divide by {n}"],
DDivIn: ["「{n}」 로 나눔(입력값)", "divide by “{n}” (input)"],
DMin: ["{n} 보다 작은 값", "smaller of value and {n}"],
DMax: ["{n} 보다 큰 값", "larger of value and {n}"],
DRound: ["{f} — 소수 {n} 자리", "{f} to {n} places"],
Src: ["출처 {s}", "Source {s}"],
Pick5: ["어느 항목으로 보낼까요?", "Which item should each result go to?"],
Labor: ["노무", "Labor"],
Material: ["재료", "Material"],
Machine: ["장비", "Machine"],
Logic: ["일위대가", "Another job"],
Skip: ["연결 안 함", "Skip"],
Auto: ["이름이 같아 저절로 연결", "Matched by name"],
Choose: ["고르기", "Choose"],
Change: ["변경", "Change"],
NotLinked: ["아직 연결 안 됨", "Not linked yet"],
ExtraHead: ["비율 · 기타 항목", "Rate · other items"],
AddRate: ["+ 비율", "+ Rate"],
AddOther: ["+ 기타", "+ Other"],
ItemName: ["이름", "Name"],
Base: ["바탕", "Of"],
RatePct: ["비율 %", "Rate %"],
Amount: ["금액(원)", "Amount (won)"],
Where: ["넣을 자리", "Goes to"],
SumLabor: ["사람 줄 노무비 합", "Labor sum"],
Labor$: ["노무비", "Labor cost"],
Mat$: ["재료비", "Material cost"],
Exp$: ["경비", "Expense"],
RateLine: ["{b} 의 {r} % — {n}", "{r} % of {b} — {n}"],
OtherLine: ["{n} — {a} 원", "{n} — {a} won"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
Object.entries(v).reduce(
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
TEXT[key][currentLanguageIndex] as string,
);
const numOf = (s: string): number | null =>
s.trim() !== "" && Number.isFinite(Number(s)) ? Number(s) : null;
const sel = (
options: [string, string][],
value: string,
onChange?: (v: string) => void,
): ReturnType<typeof createSelectField> =>
createSelectField({
options: options.map(([value, text]) => ({ value, text })),
value,
compact: true,
onChange,
});
const row = (label: string, ...kids: HTMLElement[]): HTMLElement =>
el("label", {
className: "m01-lbw__line",
children: [el("span", { className: "m01-lbw__muted", text: label }), ...kids],
});
/* ── 4단계 계산 ─────────────────────────────────────────────────────────── */
const describe = (m: Mod): string => {
const src = m.src ? ` · ${t("Src", { s: m.src })}` : "";
if (m.k === "할증") {
const r = m.rate;
const text =
r.t === "num"
? t("DSurchargeNum", { p: r.v })
: r.t === "input"
? t("DSurchargeIn", { n: r.name })
: t("DSurchargeCond", {
i: r.input,
l: r.limit,
o: t(r.op === "<=" ? "OpLe" : "OpLt"),
t: r.table.이름,
c: r.col,
});
return `${t("Surcharge")} — ${text}${src}`;
}
if (m.k === "나누기")
return `${t("Divide")} — ${m.by.t === "num" ? t("DDivNum", { n: m.by.v }) : t("DDivIn", { n: m.by.name })}${src}`;
if (m.k === "최소" || m.k === "최대")
return `${m.k === "최소" ? t("Min") : t("Max")} — ${t(m.k === "최소" ? "DMin" : "DMax", { n: m.v })}${src}`;
return `${t("Round")} — ${t("DRound", { f: m.fn, n: m.places })}${src}`;
};
/** 주 글에서 맞는 문장 틀 추리기 */
const guess = (note: string): string =>
/할증|증가|가산|초과/.test(note)
? "할증"
: /나누|시공량|당\s*작업/.test(note)
? "나누기"
: /이상|이하|최대|최소/.test(note)
? "최대"
: /올림|절상|반올림/.test(note)
? "올림"
: "";
export function stepCalc(ctx: WCtx, host: HTMLElement): void {
const { a } = ctx;
let frame = "";
let src = "";
const draw = (): void => {
const notes = a.table.주;
const noteBox = el("div", { className: "m01-lbw__notes" });
if (!notes.length) noteBox.append(el("p", { className: "m01-lbw__muted", text: t("NoNote") }));
notes.forEach((n, i) => {
const b = el("button", {
className: `m01-lbw__note${src === `주${i}` ? " is-on" : ""}`,
attrs: { type: "button" },
text: n,
});
b.addEventListener("click", () => {
src = `주${i}`;
frame = guess(n) || frame;
draw();
});
noteBox.append(b);
});
const noteSrc = (): string | undefined => {
const i = Number(src.slice(1));
const mark = src && notes[i] ? (notes[i].match(/^[①-⑳]/)?.[0] ?? `${i + 1}`) : "";
return mark ? `${a.table.출처} 주${mark}` : undefined;
};
const frames: [string, TKey][] = [
["할증", "Surcharge"],
["나누기", "Divide"],
["최소", "Min"],
["최대", "Max"],
["올림", "Round"],
];
const tabs = el("div", {
className: "m01-lbw__chips",
children: frames.map(([k, label]) => {
const b = el("button", {
className: `m01-lbw__chip${frame === k ? " is-on" : ""}`,
attrs: { type: "button" },
text: t(label),
});
b.addEventListener("click", () => ((frame = k), draw()));
return b;
}),
});
const added = el("div", { className: "m01-lbw__added" });
a.mods.forEach((m, i) => {
const rm = createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => (a.mods.splice(i, 1), ctx.changed(), draw()),
});
added.append(
el("div", {
className: "m01-lbw__item",
children: [el("span", { text: describe(m) }), rm],
}),
);
});
if (!a.mods.length) added.append(el("p", { className: "m01-lbw__muted", text: t("None") }));
host.replaceChildren(
el("p", { className: "m01-lbw__muted", text: t("NoteHint") }),
noteBox,
el("h5", { text: t("Frames") }),
tabs,
frame ? form(frame, noteSrc()) : el("span"),
el("h5", { text: t("Added") }),
added,
);
};
const form = (k: string, from: string | undefined): HTMLElement => {
const box = el("div", { className: "m01-lbw__form" });
const push = (m: Mod): void => (
a.mods.push(from ? { ...m, src: from } : m),
(frame = ""),
(src = ""),
ctx.changed(),
draw()
);
const addBtn = (go: () => void): HTMLElement => createButton({ label: t("Add"), onClick: go });
if (k === "할증") {
const others = ctx.sectionTables.filter(
(x) =>
x.key !== a.table.key && Object.keys(x.조건).length === 1 && Object.keys(x.값칸).length,
);
const type = sel(
[
["num", t("RNum")],
["input", t("RInput")],
...(others.length ? ([["cond", t("RCond")]] as [string, string][]) : []),
],
"num",
() => paint(),
);
const body = el("div", { className: "m01-lbw__stack" });
const paint = (): void => {
const num = createInputField({ type: "text", placeholder: "예: 30", compact: true });
const nm = createInputField({ type: "text", value: "할증률", compact: true });
const tb = sel(
others.map((x) => [x.key, x.이름]),
others[0]?.key ?? "",
() => paint(),
);
const tbl = others.find((x) => x.key === tb.select.value) ?? others[0];
const col = sel(
Object.keys(tbl?.값칸 ?? {}).map((c) => [c, c]),
"",
);
const lim = createInputField({ type: "text", placeholder: "예: 3", compact: true });
const op = sel(
[
["<=", "≤ (이하)"],
["<", "< (미만)"],
],
"<=",
);
body.replaceChildren(
...(type.select.value === "num"
? [row(t("Value"), num.root)]
: type.select.value === "input"
? [row(t("RName"), nm.root)]
: [
row(t("Table"), tb.root),
row(t("ResultCol"), col.root),
row(t("Limit"), op.root, lim.root),
]),
addBtn(() => {
const ty = type.select.value;
const n = numOf(num.input.value);
const l = numOf(lim.input.value);
if (ty === "num" && n !== null) push({ k: "할증", rate: { t: "num", v: n } });
else if (ty === "input" && nm.input.value.trim())
push({ k: "할증", rate: { t: "input", name: nm.input.value.trim() } });
else if (ty === "cond" && tbl && l !== null)
push({
k: "할증",
rate: {
t: "cond",
input: Object.keys(tbl.조건)[0],
op: op.select.value as "<=" | "<",
limit: l,
table: tbl,
col: col.select.value,
},
});
}),
);
};
paint();
box.append(row(t("RateType"), type.root), body);
} else if (k === "나누기") {
const ty = sel(
[
["num", t("DNum")],
["input", t("DInput")],
],
"num",
() => paint(),
);
const f = createInputField({ type: "text", placeholder: "예: 8", compact: true });
const body = el("div", { className: "m01-lbw__stack" });
const paint = (): void => {
f.input.value = ty.select.value === "input" ? "시공량" : "";
body.replaceChildren(row(t("DivBy"), f.root));
};
paint();
box.append(
row(t("DivBy"), ty.root),
body,
addBtn(() => {
const n = numOf(f.input.value);
if (ty.select.value === "num" && n !== null && n !== 0)
push({ k: "나누기", by: { t: "num", v: n } });
else if (ty.select.value === "input" && f.input.value.trim())
push({ k: "나누기", by: { t: "input", name: f.input.value.trim() } });
}),
);
} else if (k === "최소" || k === "최대") {
const f = createInputField({ type: "text", placeholder: "예: 100", compact: true });
box.append(
row(t(k === "최소" ? "Min" : "Max"), f.root),
addBtn(() => {
const n = numOf(f.input.value);
if (n !== null) push({ k, v: n });
}),
);
} else {
const fn = sel(
[
["올림", "올림"],
["버림", "버림"],
["반올림", "반올림"],
],
"올림",
);
const p = createInputField({ type: "text", value: "2", compact: true });
box.append(
row(t("Round"), fn.root),
row(t("Places"), p.root),
addBtn(() => {
const n = numOf(p.input.value);
if (n !== null && n >= 0)
push({ k: "올림", fn: fn.select.value as "올림", places: Math.floor(n) });
}),
);
}
return box;
};
draw();
}
/* ── 5단계 항목 연결 ───────────────────────────────────────────────────── */
/** 처음 들어올 때 — 결과 칸 이름이 노임 · 자재 이름과 같으면 저절로 연결 */
export async function autoLinks(ctx: WCtx): Promise<void> {
const { a } = ctx;
for (const col of colsOf(a, "결과")) {
if (a.links.some((l) => l.col === col && l.kind !== "없음")) continue;
const unit = a.table.값칸[col] ?? "";
const link: Link = { col, kind: "없음", name: col, spec: "", unit, element: null };
try {
const job = (await fetchPick("job", col)).items.find((i) => i.이름 === col);
if (job) Object.assign(link, { kind: "노무", unit: unit || "인", element: job.ref });
else {
const mat = (await fetchPick("price", col)).items.find((i) => i.이름 === col);
if (mat)
Object.assign(link, {
kind: "재료",
spec: mat.규격,
unit: unit || mat.단위,
element: {
구분: (mat as { 구분?: string }).구분 ?? "",
검색어: `${mat.이름} ${mat.규격}`.trim(),
},
});
}
} catch {
/* 못 찾으면 연결 안 함으로 둠 */
}
a.links = a.links.filter((l) => l.col !== col);
a.links.push(link);
}
ctx.changed();
}
const KINDS: [LinkKind, TKey, "person" | "material" | "machine" | "logic"][] = [
["노무", "Labor", "person"],
["재료", "Material", "material"],
["장비", "Machine", "machine"],
["일위대가", "Logic", "logic"],
];
export function stepLink(ctx: WCtx, host: HTMLElement): void {
const { a } = ctx;
const draw = (): void => {
const rows = colsOf(a, "결과").map((col) => {
const link = a.links.find((l) => l.col === col) ?? {
col,
kind: "없음" as LinkKind,
name: col,
spec: "",
unit: a.table.값칸[col] ?? "",
element: null,
};
if (!a.links.includes(link)) a.links.push(link);
const pick = async (
kind: LinkKind,
pk: "person" | "material" | "machine" | "logic",
): Promise<void> => {
const r = await openEasyPick(pk, { qty: 1 });
if (!r) return;
Object.assign(link, {
kind,
name: r.name,
spec: r.spec,
unit: a.table.값칸[col] || r.unit,
element: r.element,
needInputs: r.needInputs,
});
ctx.changed();
draw();
};
const btns = KINDS.map(([kind, label, pk]) => {
const b = el("button", {
className: `m01-lbw__chip${link.kind === kind ? " is-on" : ""}`,
attrs: { type: "button" },
text: t(label),
});
b.addEventListener(
"click",
() => void pick(kind, pk).catch((e) => showToast(plainError(e), "error")),
);
return b;
});
const none = el("button", {
className: `m01-lbw__chip${link.kind === "없음" ? " is-on" : ""}`,
attrs: { type: "button" },
text: t("Skip"),
});
none.addEventListener(
"click",
() => (Object.assign(link, { kind: "없음", element: null }), ctx.changed(), draw()),
);
const state = link.kind === "없음" ? t("NotLinked") : `${link.name} ${link.spec}`.trim();
return el("div", {
className: "m01-lbw__link",
children: [
el("strong", { text: `${col} (${a.table.값칸[col] || "—"})` }),
el("div", { className: "m01-lbw__chips", children: [...btns, none] }),
el("span", { className: "m01-lbw__muted", text: state }),
],
});
});
host.replaceChildren(
el("p", { className: "m01-lbw__muted", text: t("Pick5") }),
...rows,
extras(ctx, draw),
);
};
draw();
}
/** 비율 · 기타 덧줄 */
function extras(ctx: WCtx, redraw: () => void): HTMLElement {
const { a } = ctx;
const costs: [string, string][] = [
["노무비", t("Labor$")],
["재료비", t("Mat$")],
["경비", t("Exp$")],
];
const bases: [string, string][] = [["사람줄합", t("SumLabor")], ...costs];
const list = a.extras.map((e: Extra, i) => {
const text =
e.k === "비율"
? t("RateLine", {
b: bases.find(([v]) => v === e.base)?.[1] ?? e.base,
r: e.rate,
n: e.name,
})
: t("OtherLine", { n: e.name, a: e.amount });
return el("div", {
className: "m01-lbw__item",
children: [
el("span", { text }),
createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => (a.extras.splice(i, 1), ctx.changed(), redraw()),
}),
],
});
});
const name = createInputField({ type: "text", placeholder: t("ItemName"), compact: true });
const base = sel(bases, "사람줄합");
const val = createInputField({ type: "text", placeholder: t("RatePct"), compact: true });
const where = sel(costs, "재료비");
const add = (k: "비율" | "기타"): void => {
const n = numOf(val.input.value);
const nm = name.input.value.trim();
if (n === null || !nm) return;
const cost = where.select.value as "노무비";
a.extras.push(
k === "비율"
? { k, name: nm, base: base.select.value as "노무비", rate: n, cost }
: { k, name: nm, amount: n, cost },
);
ctx.changed();
redraw();
};
return el("div", {
className: "m01-lbw__extras",
children: [
el("h5", { text: t("ExtraHead") }),
...list,
el("div", {
className: "m01-lbw__form",
children: [
row(t("ItemName"), name.root),
row(t("Base"), base.root),
row(`${t("RatePct")} / ${t("Amount")}`, val.root),
row(t("Where"), where.root),
el("div", {
className: "m01-lbw__chips",
children: [
createButton({ label: t("AddRate"), onClick: () => add("비율") }),
createButton({ label: t("AddOther"), variant: "ghost", onClick: () => add("기타") }),
],
}),
],
}),
],
});
}