Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
549 lines
18 KiB
TypeScript
549 lines
18 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_Logic_Wizard_Steps.ts
|
|
* 「새로 만들기」 걸음별 화면 — 이름 · 원문 절 · 중간 값 · 호표 줄 · 설계 입력 · 할증·끝수
|
|
* 한 걸음 = `{name, render(ctx), ready(draft)}` · 시험 계산·저장은 모달 본체(`Wizard.ts`)가 얹음
|
|
* ========================================================================== */
|
|
|
|
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
|
import { searchElements, searchPrice } from "./M01_MasterData_UI_Logic_Api";
|
|
import { searchJobs, type TableBrief } from "./M01_MasterData_UI_Logic_Wizard_Api";
|
|
import { atomEditor, select, textBox } from "./M01_MasterData_UI_Logic_Wizard_Atom";
|
|
import {
|
|
BASES,
|
|
COSTS,
|
|
COST_OF,
|
|
atomReady,
|
|
collectInputs,
|
|
emptyAtom,
|
|
emptyQty,
|
|
isMoney,
|
|
qtyPlain,
|
|
qtyReady,
|
|
type Branch,
|
|
type Draft,
|
|
type Line,
|
|
type LineKind,
|
|
type Middle,
|
|
} from "./M01_MasterData_UI_Logic_Wizard_Model";
|
|
import { tw, type WizardTextKey } from "./M01_MasterData_UI_Logic_Wizard_Text";
|
|
|
|
import { copyBox } from "./M01_MasterData_UI_Logic_Wizard_Copy";
|
|
import { sourceStep } from "./M01_MasterData_UI_Logic_Wizard_Source";
|
|
import { fail, note, page, type Ctx, type Step } from "./M01_MasterData_UI_Logic_Wizard_Ui";
|
|
|
|
export type { Ctx, Step };
|
|
|
|
/* ── 1. 이름 ─────────────────────────────────────────────────────────── */
|
|
|
|
const UNITS = ["원/㎡", "원/m", "원/㎥", "원/개소", "원/본", "원/톤"];
|
|
|
|
const nameStep: Step = {
|
|
key: "S_Name",
|
|
question: "Q_Name",
|
|
ready: (d) => d.name.trim() !== "" && d.unit.trim() !== "",
|
|
render: ({ d, changed, onCopied }) => {
|
|
const other = !UNITS.includes(d.unit);
|
|
const own = el("div");
|
|
const drawOwn = (on: boolean): void =>
|
|
own.replaceChildren(
|
|
...(on
|
|
? [
|
|
textBox(d.unit, tw("Unit_Own_Ph"), (v) => {
|
|
d.unit = v;
|
|
changed();
|
|
}),
|
|
]
|
|
: []),
|
|
);
|
|
drawOwn(other);
|
|
return page(
|
|
"Q_Name",
|
|
textBox(d.name, tw("Name_Ph"), (v) => {
|
|
d.name = v;
|
|
changed();
|
|
}),
|
|
el("h4", { text: tw("Q_Unit") }),
|
|
select(
|
|
[...UNITS, tw("Unit_Other")].map((u) => ({ value: u, text: u })),
|
|
other ? tw("Unit_Other") : d.unit,
|
|
(v) => {
|
|
const isOther = v === tw("Unit_Other");
|
|
d.unit = isOther ? "" : v;
|
|
drawOwn(isOther);
|
|
changed();
|
|
},
|
|
),
|
|
own,
|
|
note(tw("Unit_Money")),
|
|
copyBox(onCopied),
|
|
);
|
|
},
|
|
};
|
|
|
|
/* ── 3. 중간 값(조건 나누기) ─────────────────────────────────────────── */
|
|
|
|
const newMiddle = (): Middle => ({ name: "", branches: [], otherwise: emptyAtom() });
|
|
|
|
const middleStep: Step = {
|
|
key: "S_Middle",
|
|
question: "Q_Middle",
|
|
ready: () => true,
|
|
render: ({ d, changed }) => {
|
|
const done = el("div", { className: "m01w__stack" });
|
|
const form = el("div", { className: "m01w__stack" });
|
|
let cur = newMiddle();
|
|
const drawDone = (): void =>
|
|
done.replaceChildren(
|
|
...d.middles.map((m, i) =>
|
|
el("div", {
|
|
className: "m01w__inline",
|
|
children: [
|
|
el("strong", { text: m.name }),
|
|
el("span", { className: "m01w__muted", text: `${m.branches.length + 1}갈래` }),
|
|
createButton({
|
|
label: tw("Line_Del"),
|
|
variant: "ghost",
|
|
onClick: () => {
|
|
d.middles.splice(i, 1);
|
|
drawDone();
|
|
changed();
|
|
},
|
|
}),
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
const drawForm = (): void => {
|
|
const rows = cur.branches.map((b) => branchRow(b, d.tables, changed));
|
|
form.replaceChildren(
|
|
textBox(
|
|
cur.name,
|
|
tw("Middle_Name"),
|
|
(v) => {
|
|
cur.name = v.trim();
|
|
},
|
|
tw("Middle_Name"),
|
|
),
|
|
...rows,
|
|
createButton({
|
|
label: tw("Middle_AddIf"),
|
|
variant: "ghost",
|
|
onClick: () => {
|
|
cur.branches.push({ input: "", op: "<=", than: "", then: emptyAtom() });
|
|
drawForm();
|
|
},
|
|
}),
|
|
el("strong", { text: tw("Middle_Else") }),
|
|
atomEditor(cur.otherwise, d.tables, changed, ["value", "table", "input"]),
|
|
createButton({
|
|
label: tw("Middle_Done"),
|
|
onClick: () => {
|
|
const ok =
|
|
cur.name &&
|
|
atomReady(cur.otherwise) &&
|
|
cur.branches.every((b) => b.input && b.than.trim() && atomReady(b.then));
|
|
if (!ok) return showToast(tw("Middle_Need"), "warning");
|
|
d.middles.push(cur);
|
|
cur = newMiddle();
|
|
drawDone();
|
|
drawForm();
|
|
changed();
|
|
},
|
|
}),
|
|
);
|
|
};
|
|
drawDone();
|
|
drawForm();
|
|
return page("Q_Middle", note(tw("Middle_Note")), done, form);
|
|
},
|
|
};
|
|
|
|
function branchRow(b: Branch, tables: TableBrief[], changed: () => void): HTMLElement {
|
|
return el("div", {
|
|
className: "m01w__branch",
|
|
children: [
|
|
el("span", { text: tw("Middle_If") }),
|
|
textBox(b.input, tw("Qty_Name"), (v) => (b.input = v.trim())),
|
|
select(
|
|
["<=", "<", ">=", ">"].map((o) => ({ value: o, text: o })),
|
|
b.op,
|
|
(v) => (b.op = v as Branch["op"]),
|
|
),
|
|
textBox(b.than, tw("Qty_Number_Ph"), (v) => (b.than = v)),
|
|
el("span", { text: tw("Middle_Then") }),
|
|
atomEditor(b.then, tables, changed, ["value", "table", "input"]),
|
|
],
|
|
});
|
|
}
|
|
|
|
/* ── 4. 호표 줄 ──────────────────────────────────────────────────────── */
|
|
|
|
interface Found {
|
|
ref: string;
|
|
이름?: string;
|
|
규격?: string;
|
|
단위?: unknown;
|
|
구분?: string;
|
|
상세구분?: string;
|
|
}
|
|
|
|
/** 찾기 → 구분 → 상세구분 → 후보 — 후보를 누르면 `onPick` */
|
|
function pickBox(
|
|
search: (q: string) => Promise<Found[]>,
|
|
onPick: (f: Found) => void,
|
|
start: string,
|
|
): HTMLElement {
|
|
const box = createInputField({ value: start, placeholder: tw("Line_Find"), type: "search" });
|
|
const list = el("div", { className: "m01w__found" });
|
|
const shown = el("p", { className: "m01w__picked" });
|
|
const filters = el("div", { className: "m01w__inline" });
|
|
let all: Found[] = [];
|
|
let sub = "";
|
|
let detail = "";
|
|
const distinct = (rows: Found[], k: "구분" | "상세구분"): string[] => [
|
|
...new Set(rows.map((r) => r[k]).filter((v): v is string => !!v)),
|
|
];
|
|
const draw = (): void => {
|
|
const inSub = all.filter((f) => !sub || f.구분 === sub);
|
|
const inDetail = inSub.filter((f) => !detail || f.상세구분 === detail);
|
|
const all_ = { value: "", text: "(전체)" };
|
|
filters.replaceChildren(
|
|
select(
|
|
[all_, ...distinct(all, "구분").map((v) => ({ value: v, text: v }))],
|
|
sub,
|
|
(v) => ((sub = v), (detail = ""), draw()),
|
|
tw("Line_Sub"),
|
|
),
|
|
select(
|
|
[all_, ...distinct(inSub, "상세구분").map((v) => ({ value: v, text: v }))],
|
|
detail,
|
|
(v) => ((detail = v), draw()),
|
|
tw("Line_Detail"),
|
|
),
|
|
);
|
|
list.replaceChildren(
|
|
...inDetail.slice(0, 30).map((f) => {
|
|
const row = el("button", {
|
|
className: "m01w__row",
|
|
attrs: { type: "button", "data-ref": f.ref },
|
|
text: `${f.이름 ?? ""} ${f.규격 ?? ""}`.trim() + ` · ${f.구분 ?? ""} ${f.상세구분 ?? ""}`,
|
|
});
|
|
row.addEventListener("click", () => pick(f));
|
|
return row;
|
|
}),
|
|
);
|
|
};
|
|
const pick = (f: Found): void => {
|
|
shown.textContent = `✔ ${f.이름 ?? ""} ${f.규격 ?? ""}`.trim();
|
|
onPick(f);
|
|
};
|
|
const load = (auto: boolean): void => {
|
|
const q = box.input.value.trim();
|
|
void search(q)
|
|
.then((rows) => {
|
|
all = rows;
|
|
sub = detail = "";
|
|
draw();
|
|
const exact = rows.filter((r) => r.이름 === q);
|
|
if (auto && exact.length === 1) pick(exact[0]);
|
|
})
|
|
.catch(fail);
|
|
};
|
|
let timer = 0;
|
|
box.input.addEventListener("input", () => {
|
|
window.clearTimeout(timer);
|
|
timer = window.setTimeout(() => load(false), 250);
|
|
});
|
|
load(true);
|
|
return el("div", { className: "m01w__stack", children: [box.root, filters, list, shown] });
|
|
}
|
|
|
|
const KINDS: { kind: LineKind; label: WizardTextKey }[] = [
|
|
{ kind: "인력", label: "K_job" },
|
|
{ kind: "재료", label: "K_mat" },
|
|
{ kind: "기계", label: "K_eq" },
|
|
{ kind: "로직", label: "K_logic" },
|
|
];
|
|
|
|
const blankLine = (kind: LineKind = "인력"): Line => ({
|
|
kind,
|
|
ref: "",
|
|
args: "",
|
|
name: "",
|
|
unit: kind === "인력" ? "인" : kind === "기계" ? "시간" : "",
|
|
qty: emptyQty(),
|
|
cost: COST_OF[kind],
|
|
});
|
|
|
|
const timesPicker = (d: Draft, q: Line["qty"], changed: () => void): HTMLElement =>
|
|
select(
|
|
[
|
|
{ value: "", text: tw("Qty_NoTimes") },
|
|
...d.middles.map((m) => ({ value: m.name, text: m.name })),
|
|
],
|
|
q.times,
|
|
(v) => {
|
|
q.times = v;
|
|
changed();
|
|
},
|
|
tw("Qty_Times"),
|
|
);
|
|
|
|
const linesStep: Step = {
|
|
key: "S_Lines",
|
|
question: "Q_Lines",
|
|
ready: (d) => (isMoney(d.unit) ? d.lines.length > 0 : qtyReady(d.result)),
|
|
render: ({ d, changed }) => {
|
|
if (!isMoney(d.unit)) {
|
|
return page(
|
|
"Result_Q",
|
|
atomEditor(d.result.atom, d.tables, changed),
|
|
timesPicker(d, d.result, changed),
|
|
);
|
|
}
|
|
const done = el("div", { className: "m01w__stack" });
|
|
const form = el("div", { className: "m01w__stack" });
|
|
const chips = el("div", { className: "m01w__inline" });
|
|
let cur = blankLine();
|
|
const drawDone = (): void =>
|
|
done.replaceChildren(
|
|
...(d.lines.length
|
|
? d.lines.map((l, i) =>
|
|
el("div", {
|
|
className: "m01w__inline m01w__done",
|
|
children: [
|
|
el("strong", { text: `${l.kind} · ${l.name}` }),
|
|
el("span", { className: "m01w__muted", text: `${l.unit} · ${qtyPlain(l.qty)}` }),
|
|
createButton({
|
|
label: tw("Line_Del"),
|
|
variant: "ghost",
|
|
onClick: () => {
|
|
d.lines.splice(i, 1);
|
|
drawDone();
|
|
changed();
|
|
},
|
|
}),
|
|
],
|
|
}),
|
|
)
|
|
: [note(tw("Line_None"))]),
|
|
);
|
|
const drawForm = (start = ""): void => {
|
|
let unitBox: HTMLInputElement | null = null;
|
|
const picker = (): HTMLElement => {
|
|
const set = (f: Found): void => {
|
|
cur.name = `${f.이름 ?? ""}${cur.kind === "기계" && f.규격 ? ` ${f.규격}` : ""}`.trim();
|
|
if (cur.kind === "재료") {
|
|
cur.ref = { 구분: f.구분 ?? "", ...(f.상세구분 ? { 상세구분: f.상세구분 } : {}) };
|
|
if (typeof f.단위 === "string" && f.단위) cur.unit = f.단위;
|
|
if (unitBox) unitBox.value = cur.unit;
|
|
} else cur.ref = f.ref;
|
|
};
|
|
if (cur.kind === "로직") {
|
|
const atom = { kind: "logic" as const, key: "", args: "" };
|
|
const bridge = (): void => {
|
|
cur.ref = atom.key;
|
|
cur.args = atom.args;
|
|
cur.name ||= atom.key;
|
|
};
|
|
return atomEditor(atom, d.tables, bridge, ["logic"]);
|
|
}
|
|
const search =
|
|
cur.kind === "인력"
|
|
? (q: string) => searchJobs(q).then((r) => r.items as Found[])
|
|
: cur.kind === "재료"
|
|
? (q: string) => searchPrice(q).then((r) => r.items as Found[])
|
|
: (q: string) => searchElements("기계", q).then((r) => r.items as Found[]);
|
|
return pickBox(search, set, start);
|
|
};
|
|
const unit = createInputField({
|
|
value: cur.unit,
|
|
placeholder: tw("Line_Unit"),
|
|
type: "text",
|
|
});
|
|
unit.input.addEventListener("input", () => (cur.unit = unit.input.value.trim()));
|
|
unitBox = unit.input;
|
|
form.replaceChildren(
|
|
select(
|
|
KINDS.map((k) => ({ value: k.kind, text: tw(k.label) })),
|
|
cur.kind,
|
|
(v) => {
|
|
cur = blankLine(v as LineKind);
|
|
drawForm();
|
|
},
|
|
tw("Line_Kind"),
|
|
),
|
|
picker(),
|
|
unit.root,
|
|
el("strong", { text: tw("Line_Qty") }),
|
|
atomEditor(cur.qty.atom, d.tables, changed),
|
|
timesPicker(d, cur.qty, changed),
|
|
select(
|
|
["", ...COSTS].map((c) => ({ value: c, text: c || "(자동)" })),
|
|
cur.cost,
|
|
(v) => (cur.cost = v),
|
|
tw("Line_Cost"),
|
|
),
|
|
createButton({
|
|
label: tw("Line_Add"),
|
|
onClick: () => {
|
|
if (!cur.ref || !qtyReady(cur.qty)) return showToast(tw("Line_Need"), "warning");
|
|
d.lines.push(cur);
|
|
cur = blankLine(cur.kind);
|
|
drawDone();
|
|
drawForm();
|
|
changed();
|
|
},
|
|
}),
|
|
);
|
|
};
|
|
// 표의 값 칸에서 바로 줄 넣기 — 값 칸 이름으로 찾아 두고 수량은 그 표 칸으로
|
|
const drawChips = (): void =>
|
|
chips.replaceChildren(
|
|
...d.tables.flatMap((t) =>
|
|
Object.keys(t.값칸 ?? {}).map((col) =>
|
|
createButton({
|
|
label: `${col} (${t.키})`,
|
|
variant: "pill",
|
|
onClick: () => {
|
|
const target = t.용도?.대상 ?? [];
|
|
const kind: LineKind = target.includes("기계")
|
|
? "기계"
|
|
: target.includes("재료")
|
|
? "재료"
|
|
: "인력";
|
|
cur = blankLine(kind);
|
|
cur.qty.atom = { kind: "table", table: t.키, col, cond: {} };
|
|
drawForm(col);
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
drawDone();
|
|
drawForm();
|
|
drawChips();
|
|
return page(
|
|
"Q_Lines",
|
|
done,
|
|
d.tables.length ? el("strong", { text: tw("Line_FromTable") }) : "",
|
|
chips,
|
|
form,
|
|
);
|
|
},
|
|
};
|
|
|
|
/* ── 5. 설계 입력 ────────────────────────────────────────────────────── */
|
|
|
|
const inputsStep: Step = {
|
|
key: "S_Inputs",
|
|
question: "Q_Inputs",
|
|
ready: () => true,
|
|
render: ({ d, changed }) => {
|
|
const shapes = collectInputs(d);
|
|
if (!shapes.length) return page("Q_Inputs", note(tw("Inputs_None")));
|
|
const cards = shapes.map((s) => {
|
|
const meta = (d.meta[s.name] ??= { desc: "", unit: "", min: "", max: "" });
|
|
const range = s.choices
|
|
? [note(`${tw("In_Pick")} · ${s.choices.join(" · ")}`)]
|
|
: [
|
|
note(tw("In_Number")),
|
|
el("div", {
|
|
className: "m01w__inline",
|
|
children: [
|
|
textBox(meta.min, tw("In_Range"), (v) => ((meta.min = v), changed())),
|
|
textBox(meta.max, tw("In_Range"), (v) => ((meta.max = v), changed())),
|
|
],
|
|
}),
|
|
];
|
|
return el("div", {
|
|
className: "m01w__card",
|
|
attrs: { "data-input": s.name },
|
|
children: [
|
|
el("strong", { text: s.name }),
|
|
textBox(meta.desc, tw("In_Desc"), (v) => ((meta.desc = v), changed())),
|
|
textBox(meta.unit, tw("In_Unit"), (v) => ((meta.unit = v), changed())),
|
|
...range,
|
|
],
|
|
});
|
|
});
|
|
return page("Q_Inputs", ...cards);
|
|
},
|
|
};
|
|
|
|
/* ── 6. 할증·덧줄 · 끝수 ─────────────────────────────────────────────── */
|
|
|
|
const extraStep: Step = {
|
|
key: "S_Extra",
|
|
question: "Q_Extra",
|
|
ready: () => true,
|
|
render: ({ d, changed }) => {
|
|
const money = isMoney(d.unit);
|
|
if (!money) return page("Q_Extra", note("돈이 아닌 로직 — 덧줄·끝수 없음"));
|
|
const list = el("div", { className: "m01w__stack" });
|
|
const draw = (): void =>
|
|
list.replaceChildren(
|
|
...d.extras.map((e, i) =>
|
|
el("div", {
|
|
className: "m01w__inline m01w__card",
|
|
children: [
|
|
textBox(e.name, tw("Extra_Name"), (v) => ((e.name = v), changed())),
|
|
select(
|
|
Object.keys(BASES).map((b) => ({ value: b, text: b })),
|
|
e.base,
|
|
(v) => ((e.base = v), changed()),
|
|
tw("Extra_Base"),
|
|
),
|
|
textBox(e.pct, tw("Extra_Pct"), (v) => ((e.pct = v), changed())),
|
|
select(
|
|
COSTS.map((c) => ({ value: c, text: c })),
|
|
e.cost,
|
|
(v) => ((e.cost = v), changed()),
|
|
tw("Extra_Cost"),
|
|
),
|
|
createButton({
|
|
label: tw("Line_Del"),
|
|
variant: "ghost",
|
|
onClick: () => {
|
|
d.extras.splice(i, 1);
|
|
draw();
|
|
changed();
|
|
},
|
|
}),
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
draw();
|
|
return page(
|
|
"Q_Extra",
|
|
list,
|
|
createButton({
|
|
label: tw("Extra_Add"),
|
|
variant: "ghost",
|
|
onClick: () => {
|
|
d.extras.push({ name: "", base: "노무비", pct: "", cost: "경비" });
|
|
draw();
|
|
changed();
|
|
},
|
|
}),
|
|
el("h4", { text: tw("Round_Title") }),
|
|
select(
|
|
[
|
|
{ value: "", text: tw("Round_None") },
|
|
{ value: "버림", text: tw("Round_Down") },
|
|
{ value: "올림", text: tw("Round_Up") },
|
|
{ value: "반올림", text: tw("Round_Half") },
|
|
],
|
|
d.round,
|
|
(v) => {
|
|
d.round = v;
|
|
changed();
|
|
},
|
|
),
|
|
);
|
|
},
|
|
};
|
|
|
|
export const STEPS: Step[] = [nameStep, sourceStep, middleStep, linesStep, inputsStep, extraStep];
|