Files
Aislo/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Steps.ts
T

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];