Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
547 lines
19 KiB
TypeScript
547 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 } 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}” {o} {l} add 0, else “{t}” {c} %",
|
|
],
|
|
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: r.op === "<=" ? "≤" : "<",
|
|
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)) 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.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) => alert(plainError(e))));
|
|
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("기타") }),
|
|
],
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
}
|