feat(M01): 새로 만들기 모달 — 걸음 틀 · 원문 절 · 호표 줄 고르기 · 중간 값 · 설계 입력 · 시험 계산 · 저장 자리
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_Wizard.ts
|
||||
* 「새로 만들기」 모달 — 질문 한 번에 하나 · 옆에 지금까지 만든 호표 미리보기
|
||||
* 걸음 = 이름 → 원문 절 → 중간 값 → 호표 줄 → 설계 입력 → 할증·끝수 → 시험 계산 → 저장
|
||||
* 시험 계산은 `POST /calc` 에 `row`+`file` 을 실어 보냄(파일에 안 씀) · 저장은 `POST /save` `add`
|
||||
* 걸음별 화면 = `_Wizard_Steps.ts` · 값 고르기 칸 = `_Wizard_Atom.ts` · 식 조립 = `_Wizard_Model.ts`
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
ApiError,
|
||||
fetchLogicFiles,
|
||||
runCalc,
|
||||
saveFiles,
|
||||
type CalcAnswer,
|
||||
type LogicFile,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { select, textBox } from "./M01_MasterData_UI_Logic_Wizard_Atom";
|
||||
import { searchLogics } from "./M01_MasterData_UI_Logic_Wizard_Api";
|
||||
import {
|
||||
atomPlain,
|
||||
buildRow,
|
||||
collectInputs,
|
||||
emptyDraft,
|
||||
isMoney,
|
||||
middleText,
|
||||
qtyPlain,
|
||||
type Draft,
|
||||
} from "./M01_MasterData_UI_Logic_Wizard_Model";
|
||||
import { STEPS, type Step } from "./M01_MasterData_UI_Logic_Wizard_Steps";
|
||||
import { tw } from "./M01_MasterData_UI_Logic_Wizard_Text";
|
||||
import { plainReason } from "./M01_MasterData_UI_Test_Text";
|
||||
import "./M01_MasterData_UI_Logic_Wizard_Style.css";
|
||||
|
||||
export interface WizardOptions {
|
||||
/** 저장이 끝나면 새 키와 함께 — 방식 C 로 여는 자리 */
|
||||
onSaved?: (key: string) => void;
|
||||
}
|
||||
|
||||
const fail = (e: unknown): void =>
|
||||
showToast(e instanceof Error ? e.message : tw("Failed"), "error");
|
||||
|
||||
/** 「새로 만들기」 모달을 엶 */
|
||||
export function openLogicWizard(options: WizardOptions = {}): void {
|
||||
const d: Draft = emptyDraft();
|
||||
let files: LogicFile[] = [];
|
||||
let at = 0;
|
||||
let answered = false;
|
||||
const testValues: Record<string, string> = {};
|
||||
|
||||
const steps: Step[] = [
|
||||
...STEPS,
|
||||
{ key: "S_Test", question: "Q_Test", render: () => testView(), ready: () => answered },
|
||||
{ key: "S_Save", question: "Q_Save", render: () => saveView(), ready: () => false },
|
||||
];
|
||||
|
||||
const body = el("div", { className: "m01w__body" });
|
||||
const preview = el("aside", { className: "m01w__preview" });
|
||||
const head = el("p", { className: "m01w__step" });
|
||||
const back = createButton({ label: tw("Back"), variant: "ghost", onClick: () => go(at - 1) });
|
||||
const next = createButton({ label: tw("Next"), onClick: () => go(at + 1) });
|
||||
const close = (): void => backdrop.remove();
|
||||
const dialog = el("div", {
|
||||
className: "m01w",
|
||||
attrs: { role: "dialog", "aria-label": tw("Title") },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01w__top",
|
||||
children: [
|
||||
el("h2", { text: tw("Title") }),
|
||||
createButton({ label: tw("Close"), variant: "ghost", onClick: close }),
|
||||
],
|
||||
}),
|
||||
head,
|
||||
el("div", { className: "m01w__main", children: [body, preview] }),
|
||||
el("div", { className: "m01w__nav", children: [back, next] }),
|
||||
],
|
||||
});
|
||||
const backdrop = el("div", { className: "m01w__backdrop", children: [dialog] });
|
||||
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
|
||||
|
||||
const changed = (): void => {
|
||||
drawPreview();
|
||||
next.disabled = !steps[at].ready(d);
|
||||
};
|
||||
|
||||
const go = (to: number): void => {
|
||||
at = Math.max(0, Math.min(steps.length - 1, to));
|
||||
if (at === STEPS.length) answered = false; // 시험 계산 걸음에 들어올 때마다 새로
|
||||
const step = steps[at];
|
||||
head.textContent = tw("Step", { n: at + 1, total: steps.length, name: tw(step.key) });
|
||||
body.replaceChildren(step.render({ d, changed }));
|
||||
back.disabled = at === 0;
|
||||
next.hidden = at === steps.length - 1;
|
||||
changed();
|
||||
};
|
||||
|
||||
/* ── 시험 계산 ── */
|
||||
const testView = (): HTMLElement => {
|
||||
const out = el("div", { className: "m01w__stack" });
|
||||
const inputs = collectInputs(d);
|
||||
const fields = inputs.map((s) => {
|
||||
testValues[s.name] ??= s.choices ? String(s.choices[0]) : "";
|
||||
const meta = d.meta[s.name];
|
||||
const control = s.choices
|
||||
? select(
|
||||
s.choices.map((c) => ({ value: String(c), text: String(c) })),
|
||||
testValues[s.name],
|
||||
(v) => (testValues[s.name] = v),
|
||||
s.name,
|
||||
)
|
||||
: textBox(
|
||||
testValues[s.name],
|
||||
meta?.min && meta?.max ? `${meta.min} ∼ ${meta.max}` : tw("Qty_Number_Ph"),
|
||||
(v) => (testValues[s.name] = v),
|
||||
meta?.unit ? `${s.name} (${meta.unit})` : s.name,
|
||||
);
|
||||
control.dataset.input = s.name;
|
||||
return control;
|
||||
});
|
||||
const run = createButton({
|
||||
label: tw("Test_Run"),
|
||||
onClick: () => {
|
||||
void calc()
|
||||
.then((answer) => {
|
||||
answered = answer.ok;
|
||||
out.replaceChildren(resultView(answer));
|
||||
changed();
|
||||
})
|
||||
.catch(fail);
|
||||
},
|
||||
});
|
||||
out.append(el("p", { className: "m01w__muted", text: tw("Test_Empty") }));
|
||||
return el("div", {
|
||||
className: "m01w__page",
|
||||
children: [el("h3", { text: tw("Q_Test") }), ...fields, run, out],
|
||||
});
|
||||
};
|
||||
|
||||
const calc = async (): Promise<CalcAnswer> => {
|
||||
if (!files.length) files = await fetchLogicFiles();
|
||||
const file = (files.find((f) => f.book === d.book) ?? files[0]).file;
|
||||
const given: Record<string, unknown> = {};
|
||||
for (const s of collectInputs(d)) {
|
||||
const raw = (testValues[s.name] ?? "").trim();
|
||||
if (raw === "") continue;
|
||||
const option = s.choices?.find((c) => String(c) === raw);
|
||||
given[s.name] = option ?? (Number.isNaN(Number(raw)) ? raw : Number(raw));
|
||||
}
|
||||
return runCalc({ key: "", inputs: given, row: buildRow(d), file });
|
||||
};
|
||||
|
||||
/* ── 저장 ── */
|
||||
const saveView = (): HTMLElement => {
|
||||
const own = files.filter((f) => f.file.includes("자체"));
|
||||
const message = el("p", { className: "m01w__muted", text: tw("Save_Owner") });
|
||||
const errors = el("div", { className: "m01w__reasons", attrs: { hidden: "" } });
|
||||
let file = own[0]?.file ?? "";
|
||||
const save = createButton({
|
||||
label: tw("Save_Do"),
|
||||
disabled: !own.length,
|
||||
onClick: () => void doSave(file, errors),
|
||||
});
|
||||
return el("div", {
|
||||
className: "m01w__page",
|
||||
children: [
|
||||
el("h3", { text: tw("Q_Save") }),
|
||||
message,
|
||||
own.length
|
||||
? select(
|
||||
own.map((f) => ({ value: f.file, text: f.file })),
|
||||
file,
|
||||
(v) => (file = v),
|
||||
tw("Save_File"),
|
||||
)
|
||||
: el("p", { className: "m01w__bad", text: tw("Save_None") }),
|
||||
errors,
|
||||
save,
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const doSave = async (file: string, errors: HTMLElement): Promise<void> => {
|
||||
const target = files.find((f) => f.file === file);
|
||||
if (!target) return;
|
||||
errors.hidden = true;
|
||||
try {
|
||||
const row = buildRow(d);
|
||||
await saveFiles([
|
||||
{ file: target.file, version: target.version, changes: [{ op: "add", row }] },
|
||||
]);
|
||||
showToast(tw("Save_Done"), "success");
|
||||
const found = await searchLogics(row.이름);
|
||||
const mine = found.logics.filter((l) => l.이름 === row.이름).map((l) => l.키);
|
||||
close();
|
||||
if (mine.length) options.onSaved?.(mine[mine.length - 1]);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && typeof e.detail === "object" && e.detail !== null) {
|
||||
const list = (e.detail as { errors?: string[]; stale?: string[] }).errors ?? [];
|
||||
errors.replaceChildren(...list.map((t) => el("div", { text: t })));
|
||||
errors.hidden = false;
|
||||
} else fail(e);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 옆 미리보기 ── */
|
||||
const drawPreview = (): void => {
|
||||
const rows: HTMLElement[] = [
|
||||
el("h4", { text: tw("Preview") }),
|
||||
el("p", {
|
||||
className: "m01w__muted",
|
||||
text: [d.name || "…", d.unit, d.section ? `${d.book} ${d.section}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
}),
|
||||
];
|
||||
for (const m of d.middles) {
|
||||
rows.push(
|
||||
el("div", {
|
||||
className: "m01w__line",
|
||||
text: `${tw("Preview_Middle")} ${m.name} = ${middleText(m)}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (isMoney(d.unit)) {
|
||||
for (const l of d.lines) {
|
||||
rows.push(
|
||||
el("div", {
|
||||
className: "m01w__line",
|
||||
attrs: { "data-line": l.name },
|
||||
children: [
|
||||
el("strong", { text: `${l.kind} · ${l.name}` }),
|
||||
el("span", { className: "m01w__muted", text: `${l.unit} · ${qtyPlain(l.qty)}` }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (d.result.atom)
|
||||
rows.push(el("div", { className: "m01w__line", text: atomPlain(d.result.atom) }));
|
||||
const inputs = collectInputs(d).map((s) => s.name);
|
||||
if (inputs.length) {
|
||||
rows.push(
|
||||
el("p", {
|
||||
className: "m01w__muted",
|
||||
text: `${tw("Preview_Inputs")}: ${inputs.join(" · ")}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const e of d.extras.filter((x) => x.name)) {
|
||||
rows.push(
|
||||
el("div", {
|
||||
className: "m01w__line",
|
||||
text: `${tw("Preview_Extra")} ${e.name} · ${e.base} × ${e.pct}%`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
preview.replaceChildren(...rows);
|
||||
};
|
||||
|
||||
document.body.append(backdrop);
|
||||
void fetchLogicFiles()
|
||||
.then((f) => (files = f))
|
||||
.catch(fail);
|
||||
go(0);
|
||||
}
|
||||
|
||||
function resultView(answer: CalcAnswer): HTMLElement {
|
||||
if (!answer.ok) {
|
||||
return el("div", {
|
||||
className: "m01w__reasons",
|
||||
children: [
|
||||
el("strong", { text: tw("Test_Stopped") }),
|
||||
el("div", { text: plainReason(answer.reason) }),
|
||||
],
|
||||
});
|
||||
}
|
||||
const parts: HTMLElement[] = [];
|
||||
if (answer.lines) {
|
||||
parts.push(
|
||||
el("table", {
|
||||
className: "m01w__grid",
|
||||
children: [
|
||||
el("tbody", {
|
||||
children: answer.lines.map((l) =>
|
||||
el("tr", {
|
||||
children: [
|
||||
el("td", { text: l.이름 }),
|
||||
el("td", { text: `${formatNumber(l.수량)} ${l.단위}` }),
|
||||
el("td", { className: "m01w__money", text: formatNumber(l.금액) }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
const total = answer.sums?.["계"] ?? (answer.result as number | undefined);
|
||||
if (total !== undefined) {
|
||||
parts.push(
|
||||
el("p", {
|
||||
className: "m01w__total",
|
||||
attrs: { "data-total": String(total) },
|
||||
text: `${answer.sums ? tw("Sum_Total") : tw("Sum_Result")} ${formatNumber(total)}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return el("div", { className: "m01w__stack", children: parts });
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_Wizard_Api.ts
|
||||
* 「새로 만들기」 모달이 쓰는 읽기 길 — 계약 `resources/master_data/_화면_계약.md` 2장
|
||||
* 절 목록 API 가 서기 전 = `/tables` 를 절 번호로 찾아 원문번호가 같은 표만 남김
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { ApiError, type ElementBrief } from "./M01_MasterData_UI_Logic_Api";
|
||||
|
||||
export interface TableBrief {
|
||||
file: string;
|
||||
키: string;
|
||||
원문번호: string;
|
||||
구분: string;
|
||||
이름: string;
|
||||
기준?: string;
|
||||
조건?: Record<string, string>;
|
||||
값칸?: Record<string, string>;
|
||||
용도?: { 공종?: string; 대상?: string[]; 로직키?: string[] };
|
||||
}
|
||||
|
||||
/** 표 통째 — `줄` 에서 조건 값 목록을 뽑음 */
|
||||
export interface TableFull extends TableBrief {
|
||||
줄?: Record<string, unknown>[];
|
||||
주?: string[];
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string, params: Record<string, string>): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 책 + 절 번호의 표 — 소요량·계수 둘을 다 봄 · 하위 절(13-4-1-1)도 같이 */
|
||||
export async function fetchSectionTables(book: string, section: string): Promise<TableBrief[]> {
|
||||
const found = await Promise.all(
|
||||
["소요량", "계수"].map((group) =>
|
||||
getJson<{ tables: TableBrief[] }>("/tables", { group, q: section, size: "100" }),
|
||||
),
|
||||
);
|
||||
return found
|
||||
.flatMap((f) => f.tables)
|
||||
.filter(
|
||||
(t) => t.구분 === book && (t.원문번호 === section || t.원문번호.startsWith(`${section}-`)),
|
||||
);
|
||||
}
|
||||
|
||||
const tableCache = new Map<string, Promise<TableFull>>();
|
||||
|
||||
export function fetchTableFull(file: string, key: string): Promise<TableFull> {
|
||||
let got = tableCache.get(key);
|
||||
if (!got) {
|
||||
got = getJson<{ table: TableFull }>("/table", { file, key }).then((d) => d.table);
|
||||
tableCache.set(key, got);
|
||||
}
|
||||
return got;
|
||||
}
|
||||
|
||||
/** 공표 직종 찾기 */
|
||||
export const searchJobs = (q: string): Promise<{ items: ElementBrief[] }> =>
|
||||
getJson("/pick", { kind: "job", q, limit: "100" });
|
||||
|
||||
/** 로직 이름·번호 찾기 — 하위 로직 고르기 */
|
||||
export interface LogicBrief {
|
||||
키: string;
|
||||
원문번호: string;
|
||||
이름: string;
|
||||
결과단위: string;
|
||||
}
|
||||
export const searchLogics = (q: string): Promise<{ logics: LogicBrief[] }> =>
|
||||
getJson("/logics", { q });
|
||||
@@ -0,0 +1,262 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_Wizard_Atom.ts
|
||||
* 값 하나를 고르는 칸 — 직접 값 / 설계 입력 / 표에서 찾기 / 다른 로직 부르기
|
||||
* 수량 · 조건 나누기의 각 갈래가 같이 씀 · 고른 것은 넘겨받은 Atom 에 바로 씀(식은 Model 이 조립)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createInputField, createSelectField, el, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchTableFull,
|
||||
searchLogics,
|
||||
type LogicBrief,
|
||||
type TableBrief,
|
||||
} from "./M01_MasterData_UI_Logic_Wizard_Api";
|
||||
import { condChoices, tableInfo, type Atom } from "./M01_MasterData_UI_Logic_Wizard_Model";
|
||||
import { tw } from "./M01_MasterData_UI_Logic_Wizard_Text";
|
||||
|
||||
type Option = { value: string; text: string };
|
||||
|
||||
export const select = (
|
||||
options: Option[],
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
label?: string,
|
||||
): HTMLElement => createSelectField({ options, value, onChange, compact: true, label }).root;
|
||||
|
||||
export const textBox = (
|
||||
value: string,
|
||||
placeholder: string,
|
||||
onInput: (v: string) => void,
|
||||
label?: string,
|
||||
): HTMLElement => createInputField({ value, placeholder, onInput, label, type: "text" }).root;
|
||||
|
||||
/** 원자를 통째로 바꿔 씀(다른 갈래로 옮길 때) */
|
||||
function reset(atom: Atom, next: Atom): void {
|
||||
for (const k of Object.keys(atom)) delete (atom as unknown as Record<string, unknown>)[k];
|
||||
Object.assign(atom, next);
|
||||
}
|
||||
|
||||
const fresh = (kind: Atom["kind"]): Atom =>
|
||||
kind === "value"
|
||||
? { kind, value: "" }
|
||||
: kind === "input"
|
||||
? { kind, name: "" }
|
||||
: kind === "logic"
|
||||
? { kind, key: "", args: "" }
|
||||
: { kind, table: "", col: "", cond: {} };
|
||||
|
||||
/**
|
||||
* `tables` = 원문 절 단계에서 고른 표 · `onChange` = 고른 것이 바뀔 때마다
|
||||
* `kinds` = 이 자리에서 고를 수 있는 갈래(기본 넷 다)
|
||||
*/
|
||||
export function atomEditor(
|
||||
atom: Atom,
|
||||
tables: TableBrief[],
|
||||
onChange: () => void,
|
||||
kinds: Atom["kind"][] = ["value", "table", "input", "logic"],
|
||||
): HTMLElement {
|
||||
const host = el("div", { className: "m01w__atom" });
|
||||
const body = el("div", { className: "m01w__atom-body" });
|
||||
const names: Record<Atom["kind"], string> = {
|
||||
value: tw("Qty_Value"),
|
||||
table: tw("Qty_Table"),
|
||||
input: tw("Qty_Input"),
|
||||
logic: tw("Qty_Logic"),
|
||||
};
|
||||
|
||||
const draw = (): void => {
|
||||
body.replaceChildren();
|
||||
if (atom.kind === "value") body.append(valueBox(atom, onChange));
|
||||
else if (atom.kind === "input") body.append(inputBox(atom, onChange));
|
||||
else if (atom.kind === "logic") body.append(logicBox(atom, onChange));
|
||||
else body.append(tableBox(atom, tables, onChange));
|
||||
};
|
||||
|
||||
host.append(
|
||||
select(
|
||||
kinds.map((k) => ({ value: k, text: names[k] })),
|
||||
atom.kind,
|
||||
(v) => {
|
||||
reset(atom, fresh(v as Atom["kind"]));
|
||||
draw();
|
||||
onChange();
|
||||
},
|
||||
),
|
||||
body,
|
||||
);
|
||||
draw();
|
||||
return host;
|
||||
}
|
||||
|
||||
function valueBox(atom: Extract<Atom, { kind: "value" }>, onChange: () => void): HTMLElement {
|
||||
return textBox(atom.value, tw("Qty_Number_Ph"), (v) => {
|
||||
atom.value = v;
|
||||
onChange();
|
||||
});
|
||||
}
|
||||
|
||||
function inputBox(atom: Extract<Atom, { kind: "input" }>, onChange: () => void): HTMLElement {
|
||||
return textBox(atom.name, tw("Qty_Name"), (v) => {
|
||||
atom.name = v.trim();
|
||||
onChange();
|
||||
});
|
||||
}
|
||||
|
||||
function logicBox(atom: Extract<Atom, { kind: "logic" }>, onChange: () => void): HTMLElement {
|
||||
const list = el("div", { className: "m01w__found" });
|
||||
const key = createInputField({ value: atom.key, placeholder: tw("Qty_LogicKey"), type: "text" });
|
||||
key.input.addEventListener("input", () => {
|
||||
atom.key = key.input.value.trim();
|
||||
onChange();
|
||||
});
|
||||
const find = createInputField({ placeholder: tw("Line_Logic_Find"), type: "search" });
|
||||
let timer = 0;
|
||||
find.input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
const q = find.input.value.trim();
|
||||
if (!q) return list.replaceChildren();
|
||||
void searchLogics(q)
|
||||
.then((r) => showLogics(r.logics.slice(0, 8)))
|
||||
.catch((e: unknown) => showToast(e instanceof Error ? e.message : tw("Failed"), "error"));
|
||||
}, 250);
|
||||
});
|
||||
const showLogics = (found: LogicBrief[]): void => {
|
||||
list.replaceChildren(
|
||||
...found.map((l) => {
|
||||
const row = el("button", {
|
||||
className: "m01w__row",
|
||||
attrs: { type: "button" },
|
||||
text: `${l.원문번호} ${l.이름} · ${l.결과단위} · ${l.키}`,
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
atom.key = l.키;
|
||||
key.input.value = l.키;
|
||||
list.replaceChildren();
|
||||
onChange();
|
||||
});
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
};
|
||||
const args = createInputField({
|
||||
value: atom.args,
|
||||
placeholder: tw("Qty_LogicArgs"),
|
||||
type: "text",
|
||||
});
|
||||
args.input.addEventListener("input", () => {
|
||||
atom.args = args.input.value;
|
||||
onChange();
|
||||
});
|
||||
return el("div", { className: "m01w__stack", children: [find.root, list, key.root, args.root] });
|
||||
}
|
||||
|
||||
function tableBox(
|
||||
atom: Extract<Atom, { kind: "table" }>,
|
||||
tables: TableBrief[],
|
||||
onChange: () => void,
|
||||
): HTMLElement {
|
||||
const host = el("div", { className: "m01w__stack" });
|
||||
const detail = el("div", { className: "m01w__stack" });
|
||||
const drawDetail = (): void => {
|
||||
const info = tableInfo.get(atom.table);
|
||||
detail.replaceChildren();
|
||||
if (!info) return;
|
||||
detail.append(
|
||||
select(
|
||||
[
|
||||
{ value: "", text: "—" },
|
||||
...Object.keys(info.값칸 ?? {}).map((c) => ({ value: c, text: c })),
|
||||
],
|
||||
atom.col,
|
||||
(v) => {
|
||||
atom.col = v;
|
||||
onChange();
|
||||
},
|
||||
tw("Qty_Col"),
|
||||
),
|
||||
);
|
||||
for (const cond of Object.keys(info.조건 ?? {})) detail.append(condRow(atom, cond, onChange));
|
||||
};
|
||||
const load = (key: string): void => {
|
||||
const brief = tables.find((t) => t.키 === key);
|
||||
if (!brief) return (drawDetail(), onChange());
|
||||
void fetchTableFull(brief.file, key)
|
||||
.then((full) => {
|
||||
tableInfo.set(key, full);
|
||||
for (const c of Object.keys(full.조건 ?? {})) atom.cond[c] ??= { ask: true };
|
||||
drawDetail();
|
||||
onChange();
|
||||
})
|
||||
.catch((e: unknown) => showToast(e instanceof Error ? e.message : tw("Failed"), "error"));
|
||||
};
|
||||
const choose = (key: string): void => {
|
||||
atom.table = key;
|
||||
atom.col = "";
|
||||
atom.cond = {};
|
||||
load(key);
|
||||
};
|
||||
host.append(
|
||||
select(
|
||||
[
|
||||
{ value: "", text: "—" },
|
||||
...tables.map((t) => ({ value: t.키, text: `${t.이름} · ${t.키}` })),
|
||||
],
|
||||
atom.table,
|
||||
choose,
|
||||
tw("Qty_Table_Pick"),
|
||||
),
|
||||
detail,
|
||||
);
|
||||
if (atom.table && !tableInfo.has(atom.table)) load(atom.table);
|
||||
else drawDetail();
|
||||
return host;
|
||||
}
|
||||
|
||||
/** 표의 조건 칸 하나 — 설계자에게 물음 / 붙박이 값 */
|
||||
function condRow(
|
||||
atom: Extract<Atom, { kind: "table" }>,
|
||||
cond: string,
|
||||
onChange: () => void,
|
||||
): HTMLElement {
|
||||
atom.cond[cond] ??= { ask: true };
|
||||
const choices = condChoices(atom.table, cond);
|
||||
const value = el("div", { className: "m01w__inline" });
|
||||
const set = (v: string): void => {
|
||||
atom.cond[cond] = { ask: false, value: v };
|
||||
onChange();
|
||||
};
|
||||
const drawValue = (): void => {
|
||||
value.replaceChildren();
|
||||
const bind = atom.cond[cond];
|
||||
if (bind.ask) return;
|
||||
if (choices) {
|
||||
const now = bind.value || String(choices[0]);
|
||||
atom.cond[cond] = { ask: false, value: now };
|
||||
value.append(
|
||||
select(
|
||||
choices.map((c) => ({ value: String(c), text: String(c) })),
|
||||
now,
|
||||
set,
|
||||
),
|
||||
);
|
||||
} else value.append(textBox(bind.value, tw("Qty_Number_Ph"), set));
|
||||
};
|
||||
const mode = select(
|
||||
[
|
||||
{ value: "ask", text: tw("Qty_Ask") },
|
||||
{ value: "fixed", text: tw("Qty_Fixed") },
|
||||
],
|
||||
atom.cond[cond].ask ? "ask" : "fixed",
|
||||
(v) => {
|
||||
atom.cond[cond] = v === "ask" ? { ask: true } : { ask: false, value: "" };
|
||||
drawValue();
|
||||
onChange();
|
||||
},
|
||||
);
|
||||
drawValue();
|
||||
return el("div", {
|
||||
className: "m01w__inline",
|
||||
children: [el("strong", { text: cond }), mode, value],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_Wizard_Model.ts
|
||||
* 「새로 만들기」 — 만드는 중인 로직 한 벌(선택지) → 식 글자 → 로직 줄. 화면 없음(순수 함수)
|
||||
* 식은 사람이 치지 않음 — 고른 것을 여기서 엔진 문법(`_틀.md` 8장)으로 조립
|
||||
* ========================================================================== */
|
||||
|
||||
import type { HoLine, LogicInput, LogicRow, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
|
||||
import type { TableBrief, TableFull } from "./M01_MasterData_UI_Logic_Wizard_Api";
|
||||
|
||||
export type Bind = { ask: true } | { ask: false; value: string };
|
||||
export type Atom =
|
||||
| { kind: "value"; value: string }
|
||||
| { kind: "input"; name: string }
|
||||
| { kind: "table"; table: string; col: string; cond: Record<string, Bind> }
|
||||
| { kind: "logic"; key: string; args: string };
|
||||
export interface Qty {
|
||||
atom: Atom;
|
||||
/** 곱할 중간 값 이름(× (1 + 값 / 100)) — 없으면 "" */
|
||||
times: string;
|
||||
}
|
||||
export interface Branch {
|
||||
input: string;
|
||||
op: "<=" | "<" | ">=" | ">";
|
||||
than: string;
|
||||
then: Atom;
|
||||
}
|
||||
export interface Middle {
|
||||
name: string;
|
||||
branches: Branch[];
|
||||
otherwise: Atom;
|
||||
}
|
||||
export type LineKind = "인력" | "재료" | "기계" | "로직";
|
||||
export interface Line {
|
||||
kind: LineKind;
|
||||
/** 인력 = LB 키 · 기계 = EQ 키 · 재료 = 조건 묶음 · 로직 = 하위 로직 키 */
|
||||
ref: string | { 구분: string; 상세구분?: string };
|
||||
args: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
qty: Qty;
|
||||
cost: string;
|
||||
}
|
||||
export interface Extra {
|
||||
name: string;
|
||||
base: string;
|
||||
pct: string;
|
||||
cost: string;
|
||||
}
|
||||
export interface InputMeta {
|
||||
desc: string;
|
||||
unit: string;
|
||||
min: string;
|
||||
max: string;
|
||||
}
|
||||
export interface Draft {
|
||||
name: string;
|
||||
unit: string;
|
||||
book: string;
|
||||
section: string;
|
||||
tables: TableBrief[];
|
||||
middles: Middle[];
|
||||
lines: Line[];
|
||||
result: Qty;
|
||||
meta: Record<string, InputMeta>;
|
||||
extras: Extra[];
|
||||
round: string;
|
||||
}
|
||||
|
||||
export const BASES: Record<string, string> = {
|
||||
노무비: "노무비",
|
||||
재료비: "재료비",
|
||||
경비: "경비",
|
||||
"노무비+재료비": "(노무비 + 재료비)",
|
||||
"노무비+재료비+경비": "(노무비 + 재료비 + 경비)",
|
||||
};
|
||||
export const COSTS = ["노무비", "재료비", "경비"];
|
||||
export const COST_OF: Record<LineKind, string> = {
|
||||
인력: "노무비",
|
||||
재료: "재료비",
|
||||
기계: "경비",
|
||||
로직: "",
|
||||
};
|
||||
|
||||
/** 표 통째 — 고르기 목록·조건 종류를 셈할 때 씀(모달이 읽어 두는 곳) */
|
||||
export const tableInfo = new Map<string, TableFull>();
|
||||
|
||||
export const emptyAtom = (): Atom => ({ kind: "value", value: "" });
|
||||
export const emptyQty = (): Qty => ({ atom: emptyAtom(), times: "" });
|
||||
export const emptyDraft = (): Draft => ({
|
||||
name: "",
|
||||
unit: "원/㎡",
|
||||
book: "산림품셈",
|
||||
section: "",
|
||||
tables: [],
|
||||
middles: [],
|
||||
lines: [],
|
||||
result: emptyQty(),
|
||||
meta: {},
|
||||
extras: [],
|
||||
round: "",
|
||||
});
|
||||
|
||||
export const isMoney = (unit: string): boolean => unit.trim().startsWith("원");
|
||||
const isNumber = (v: string): boolean => v.trim() !== "" && !Number.isNaN(Number(v));
|
||||
const name_ = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`);
|
||||
const literal = (v: string): string => (isNumber(v) ? v.trim() : `'${v}'`);
|
||||
|
||||
export function atomText(a: Atom): string {
|
||||
if (a.kind === "value") return a.value.trim();
|
||||
if (a.kind === "input") return a.name;
|
||||
if (a.kind === "logic") return `로직(${a.key}${a.args.trim() ? `, ${a.args.trim()}` : ""})`;
|
||||
const conds = Object.entries(a.cond).map(
|
||||
([n, b]) => `, ${name_(n)}=${b.ask ? n : literal(b.value)}`,
|
||||
);
|
||||
return `찾기(${a.table}${conds.join("")}).${name_(a.col)}`;
|
||||
}
|
||||
|
||||
export const qtyText = (q: Qty): string =>
|
||||
q.times ? `${atomText(q.atom)} * (1 + ${q.times} / 100)` : atomText(q.atom);
|
||||
|
||||
export function middleText(m: Middle): string {
|
||||
let out = atomText(m.otherwise);
|
||||
for (const b of [...m.branches].reverse()) {
|
||||
out = `만약(${b.input} ${b.op} ${b.than}, ${atomText(b.then)}, ${out})`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 이 원자가 채워졌는지 — 다음으로 못 넘기는 까닭에 씀 */
|
||||
export function atomReady(a: Atom): boolean {
|
||||
if (a.kind === "value") return isNumber(a.value);
|
||||
if (a.kind === "input") return a.name.trim() !== "";
|
||||
if (a.kind === "logic") return a.key.trim() !== "";
|
||||
return a.table !== "" && a.col !== "";
|
||||
}
|
||||
export const qtyReady = (q: Qty): boolean => atomReady(q.atom);
|
||||
|
||||
/** 쉬운 말 한 줄 — 미리보기 */
|
||||
export function atomPlain(a: Atom): string {
|
||||
if (a.kind === "value") return a.value || "?";
|
||||
if (a.kind === "input") return `입력 「${a.name}」`;
|
||||
if (a.kind === "logic") return `로직 ${a.key} 의 결과`;
|
||||
const asks = Object.entries(a.cond)
|
||||
.filter(([, b]) => b.ask)
|
||||
.map(([n]) => n);
|
||||
const table = tableInfo.get(a.table)?.이름 ?? a.table;
|
||||
return `표 「${table}」의 「${a.col}」${asks.length ? ` (${asks.join("·")} 에 따라)` : ""}`;
|
||||
}
|
||||
export const qtyPlain = (q: Qty): string =>
|
||||
q.times ? `${atomPlain(q.atom)} × (1 + ${q.times}/100)` : atomPlain(q.atom);
|
||||
|
||||
/** 조건 칸 하나가 받을 수 있는 값 — 표 줄에서 뽑음(없으면 undefined = 수 칸) */
|
||||
export function condChoices(table: string, cond: string): (string | number)[] | undefined {
|
||||
const info = tableInfo.get(table);
|
||||
if (!info || info.조건?.[cond] !== "고르기") return undefined;
|
||||
return [...new Set((info.줄 ?? []).map((r) => r[cond] as string | number))];
|
||||
}
|
||||
|
||||
/** 만드는 중인 것이 모든 식에서 받는 설계 입력 이름들(등장 차례) — 이름 · 모양 */
|
||||
export interface InputShape {
|
||||
name: string;
|
||||
choices?: (string | number)[];
|
||||
}
|
||||
export function collectInputs(d: Draft): InputShape[] {
|
||||
const found = new Map<string, InputShape>();
|
||||
const add = (name: string, choices?: (string | number)[]): void => {
|
||||
const had = found.get(name);
|
||||
if (!had) found.set(name, { name, ...(choices ? { choices } : {}) });
|
||||
else if (choices && had.choices) {
|
||||
const more = choices.filter((c) => !had.choices!.includes(c));
|
||||
had.choices.push(...more);
|
||||
}
|
||||
};
|
||||
const middleNames = new Set(d.middles.map((m) => m.name));
|
||||
const seeAtom = (a: Atom): void => {
|
||||
if (a.kind === "input" && a.name && !middleNames.has(a.name)) add(a.name);
|
||||
if (a.kind === "table") {
|
||||
for (const [n, b] of Object.entries(a.cond)) if (b.ask) add(n, condChoices(a.table, n));
|
||||
}
|
||||
};
|
||||
for (const m of d.middles) {
|
||||
for (const b of m.branches) {
|
||||
if (!middleNames.has(b.input)) add(b.input);
|
||||
seeAtom(b.then);
|
||||
}
|
||||
seeAtom(m.otherwise);
|
||||
}
|
||||
if (isMoney(d.unit)) for (const l of d.lines) seeAtom(l.qty.atom);
|
||||
else seeAtom(d.result.atom);
|
||||
return [...found.values()];
|
||||
}
|
||||
|
||||
const inputOf = (s: InputShape, meta: InputMeta | undefined): LogicInput => {
|
||||
const out: LogicInput = { 이름: s.name };
|
||||
const rest: Record<string, unknown> = {};
|
||||
if (meta?.desc.trim()) rest["설명"] = meta.desc.trim();
|
||||
if (meta?.unit.trim()) out.단위 = meta.unit.trim();
|
||||
if (s.choices) out.고르기 = s.choices;
|
||||
else if (isNumber(meta?.min ?? "") && isNumber(meta?.max ?? "")) {
|
||||
out.범위 = [Number(meta!.min), Number(meta!.max)];
|
||||
}
|
||||
return Object.assign(out, rest);
|
||||
};
|
||||
|
||||
const extraOf = (e: Extra, source: string): NamedFormula => ({
|
||||
이름: e.name.trim(),
|
||||
식: `${BASES[e.base] ?? e.base} * ${e.pct.trim()} / 100`,
|
||||
비목: e.cost,
|
||||
출처: source,
|
||||
});
|
||||
|
||||
/** 만드는 중인 것 → 로직 줄(키는 서버가 저장 때 붙임 — "") */
|
||||
export function buildRow(d: Draft): LogicRow {
|
||||
const source = `${d.book} ${d.section}`.trim();
|
||||
const row: LogicRow = {
|
||||
키: "",
|
||||
원문번호: d.section,
|
||||
구분: d.book,
|
||||
상세구분: "자체",
|
||||
이름: d.name.trim(),
|
||||
결과단위: d.unit.trim(),
|
||||
출처: source,
|
||||
소유: "개인",
|
||||
입력: collectInputs(d).map((s) => inputOf(s, d.meta[s.name])),
|
||||
중간: d.middles.map((m): NamedFormula => ({ 이름: m.name, 식: middleText(m), 출처: source })),
|
||||
};
|
||||
if (isMoney(d.unit)) {
|
||||
row.호표 = d.lines.map((l): HoLine => {
|
||||
const ref =
|
||||
l.kind === "로직"
|
||||
? `로직(${l.ref as string}${l.args.trim() ? `, ${l.args.trim()}` : ""})`
|
||||
: (l.ref as unknown as HoLine["요소"]);
|
||||
return {
|
||||
종류: l.kind,
|
||||
요소: ref,
|
||||
이름: l.name,
|
||||
단위: l.unit,
|
||||
수량: qtyText(l.qty),
|
||||
...(l.cost ? { 비목: l.cost } : {}),
|
||||
};
|
||||
});
|
||||
row.덧줄 = d.extras
|
||||
.filter((e) => e.name.trim() && isNumber(e.pct))
|
||||
.map((e) => extraOf(e, source));
|
||||
row.끝수 = d.round || null;
|
||||
} else row.결과 = { 식: qtyText(d.result) };
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
/* =============================================================================
|
||||
* 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 {
|
||||
fetchSectionTables,
|
||||
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";
|
||||
|
||||
export interface Ctx {
|
||||
d: Draft;
|
||||
/** 값이 바뀜 — 미리보기·다음 단추 다시 셈 */
|
||||
changed: () => void;
|
||||
}
|
||||
export interface Step {
|
||||
key: WizardTextKey;
|
||||
question: WizardTextKey;
|
||||
render: (ctx: Ctx) => HTMLElement;
|
||||
ready: (d: Draft) => boolean;
|
||||
}
|
||||
|
||||
const page = (question: WizardTextKey, ...body: (HTMLElement | string)[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01w__page",
|
||||
children: [el("h3", { text: tw(question) }), ...body],
|
||||
});
|
||||
const note = (text: string): HTMLElement => el("p", { className: "m01w__muted", text });
|
||||
const fail = (e: unknown): void =>
|
||||
showToast(e instanceof Error ? e.message : tw("Failed"), "error");
|
||||
|
||||
/* ── 1. 이름 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const UNITS = ["원/㎡", "원/m", "원/㎥", "원/개소", "원/본", "원/톤"];
|
||||
|
||||
const nameStep: Step = {
|
||||
key: "S_Name",
|
||||
question: "Q_Name",
|
||||
ready: (d) => d.name.trim() !== "" && d.unit.trim() !== "",
|
||||
render: ({ d, changed }) => {
|
||||
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")),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/* ── 2. 원문 절 ──────────────────────────────────────────────────────── */
|
||||
|
||||
const sourceStep: Step = {
|
||||
key: "S_Source",
|
||||
question: "Q_Source",
|
||||
ready: (d) => d.section.trim() !== "",
|
||||
render: ({ d, changed }) => {
|
||||
const list = el("div", { className: "m01w__stack" });
|
||||
const box = createInputField({
|
||||
value: d.section,
|
||||
placeholder: tw("Section_Ph"),
|
||||
type: "text",
|
||||
});
|
||||
const draw = (found: TableBrief[], searched: boolean): void => {
|
||||
list.replaceChildren(
|
||||
...(found.length
|
||||
? found.map((t) => tableRow(t, d, changed))
|
||||
: searched
|
||||
? [note(tw("Section_None")), note(tw("Section_Skip"))]
|
||||
: []),
|
||||
);
|
||||
};
|
||||
const find = (): void => {
|
||||
d.section = box.input.value.trim();
|
||||
d.tables = [];
|
||||
changed();
|
||||
if (!d.section) return draw([], false);
|
||||
void fetchSectionTables(d.book, d.section)
|
||||
.then((found) => {
|
||||
d.tables = [...found]; // 처음에는 이 절의 표를 모두 씀 — 빼려면 체크를 풂
|
||||
draw(found, true);
|
||||
changed();
|
||||
})
|
||||
.catch(fail);
|
||||
};
|
||||
box.input.addEventListener("keydown", (ev) => ev.key === "Enter" && find());
|
||||
box.input.addEventListener("input", () => {
|
||||
d.section = box.input.value.trim();
|
||||
changed();
|
||||
});
|
||||
if (d.section && !d.tables.length) find();
|
||||
else draw(d.tables, d.tables.length > 0);
|
||||
return page(
|
||||
"Q_Source",
|
||||
select(
|
||||
["산림품셈", "건설품셈"].map((b) => ({ value: b, text: b })),
|
||||
d.book,
|
||||
(v) => {
|
||||
d.book = v;
|
||||
find();
|
||||
},
|
||||
tw("Book"),
|
||||
),
|
||||
el("div", {
|
||||
className: "m01w__inline",
|
||||
children: [box.root, createButton({ label: tw("Section_Find"), onClick: find })],
|
||||
}),
|
||||
list,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function tableRow(t: TableBrief, d: Draft, changed: () => void): HTMLElement {
|
||||
const check = el("input", { attrs: { type: "checkbox", "data-table": t.키 } });
|
||||
check.checked = d.tables.some((x) => x.키 === t.키);
|
||||
check.addEventListener("change", () => {
|
||||
d.tables = check.checked ? [...d.tables, t] : d.tables.filter((x) => x.키 !== t.키);
|
||||
changed();
|
||||
});
|
||||
const usage = t.용도?.대상?.join("·") ?? "";
|
||||
return el("label", {
|
||||
className: "m01w__table",
|
||||
children: [
|
||||
check,
|
||||
el("div", {
|
||||
className: "m01w__stack",
|
||||
children: [
|
||||
el("strong", { text: `${t.이름} · ${t.키}` }),
|
||||
el("span", {
|
||||
className: "m01w__muted",
|
||||
text: [
|
||||
t.기준 ? `${tw("Table_Base")} ${t.기준}` : "",
|
||||
`${tw("Table_Cols")} ${Object.keys(t.값칸 ?? {}).join("·")}`,
|
||||
usage ? `${tw("Table_Usage")} ${usage}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 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_Half") },
|
||||
],
|
||||
d.round,
|
||||
(v) => {
|
||||
d.round = v;
|
||||
changed();
|
||||
},
|
||||
),
|
||||
note(tw("Round_Note")),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const STEPS: Step[] = [nameStep, sourceStep, middleStep, linesStep, inputsStep, extraStep];
|
||||
@@ -0,0 +1,164 @@
|
||||
/* M01 「새로 만들기」 모달 — 접두 m01w__ */
|
||||
.m01w__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.m01w {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
width: min(960px, 96vw);
|
||||
max-height: 92vh;
|
||||
padding: var(--spacing-12);
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01w__top,
|
||||
.m01w__nav,
|
||||
.m01w__inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.m01w__top h2 {
|
||||
margin: 0 auto 0 0;
|
||||
font-size: 18px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.m01w__nav {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m01w__main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: var(--spacing-12);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m01w__body,
|
||||
.m01w__preview {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m01w__preview {
|
||||
padding: var(--spacing-8);
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01w__page,
|
||||
.m01w__stack,
|
||||
.m01w__found {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01w__page h3,
|
||||
.m01w__page h4,
|
||||
.m01w__preview h4 {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.m01w__step,
|
||||
.m01w__muted,
|
||||
.m01w__picked {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01w__bad {
|
||||
margin: 0;
|
||||
color: var(--color-danger, #c0392b);
|
||||
}
|
||||
|
||||
.m01w__reasons {
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
}
|
||||
|
||||
.m01w__row {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01w__row:hover {
|
||||
background: var(--color-bg, rgb(0 0 0 / 4%));
|
||||
}
|
||||
|
||||
.m01w__table,
|
||||
.m01w__card,
|
||||
.m01w__branch,
|
||||
.m01w__atom {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
}
|
||||
|
||||
.m01w__card,
|
||||
.m01w__atom,
|
||||
.m01w__branch {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.m01w__atom {
|
||||
padding: var(--spacing-4);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.m01w__line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-4) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01w__grid {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.m01w__grid td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01w__money,
|
||||
.m01w__total {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01w__total {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (width <= 720px) {
|
||||
.m01w__main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_Wizard_Text.ts
|
||||
* 「새로 만들기」 모달 글자 — [한국어, 영어] · 설계자 말(식·기호를 글자로 치지 않음)
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["새 일위대가 로직 만들기", "New unit-cost logic"],
|
||||
Close: ["닫기", "Close"],
|
||||
Back: ["이전", "Back"],
|
||||
Next: ["다음", "Next"],
|
||||
Step: ["{n} / {total} · {name}", "{n} / {total} · {name}"],
|
||||
S_Name: ["이름", "Name"],
|
||||
S_Source: ["원문 절", "Source section"],
|
||||
S_Middle: ["중간 값", "Middle values"],
|
||||
S_Lines: ["호표 줄", "Table lines"],
|
||||
S_Inputs: ["설계 입력", "Design inputs"],
|
||||
S_Extra: ["할증·끝수", "Surcharge · rounding"],
|
||||
S_Test: ["시험 계산", "Trial calculation"],
|
||||
S_Save: ["저장", "Save"],
|
||||
|
||||
Q_Name: ["무엇을 얼마만큼 하는 값인가요?", "What does this value price?"],
|
||||
Name_Ph: ["예: 돌쌓기 메쌓기(인력)", "e.g. Dry stone masonry"],
|
||||
Q_Unit: ["결과는 어떤 단위인가요?", "Result unit?"],
|
||||
Unit_Money: [
|
||||
"「원」 으로 시작하면 돈을 셈하는 로직(호표) — 아니면 값 하나를 셈",
|
||||
"Units starting with 원 give money",
|
||||
],
|
||||
Unit_Other: ["그 밖 (직접)", "Other"],
|
||||
Unit_Own_Ph: ["예: ㎥/시간", "e.g. ㎥/h"],
|
||||
|
||||
Q_Source: ["어느 원문 절을 옮기나요?", "Which source section?"],
|
||||
Book: ["책", "Book"],
|
||||
Section_Ph: ["절 번호 (예: 13-4-1)", "Section (e.g. 13-4-1)"],
|
||||
Section_Find: ["표 찾기", "Find tables"],
|
||||
Section_None: [
|
||||
"이 절에 쓰는 표가 없습니다 — 절 번호를 확인해 주세요",
|
||||
"No tables in this section",
|
||||
],
|
||||
Section_Skip: [
|
||||
"표 없이 직접 만들 수도 있습니다 — 그대로 다음",
|
||||
"You can also build without tables",
|
||||
],
|
||||
Table_Use: ["이 표를 씀", "Use this table"],
|
||||
Table_Base: ["기준", "Base"],
|
||||
Table_Cols: ["값 칸", "Value columns"],
|
||||
Table_Usage: ["용도", "Used for"],
|
||||
|
||||
Q_Middle: ["이름을 붙여 둘 값이 있나요? (예: 높이에 따라 달라지는 증가율)", "Named values?"],
|
||||
Middle_Note: ["없으면 그대로 다음 — 있으면 아래에서 만듭니다", "Skip if none"],
|
||||
Middle_Add: ["중간 값 더하기", "Add a value"],
|
||||
Middle_Name: ["이 값의 이름", "Name"],
|
||||
Middle_If: ["만약", "If"],
|
||||
Middle_Then: ["이면", "then"],
|
||||
Middle_Else: ["그 밖이면", "otherwise"],
|
||||
Middle_AddIf: ["조건 더하기", "Add condition"],
|
||||
Middle_Done: ["이 중간 값 넣기", "Add this value"],
|
||||
Middle_Need: ["이름을 적어 주세요", "Name it"],
|
||||
|
||||
Q_Lines: ["호표에 어떤 줄을 넣을까요?", "Add lines"],
|
||||
Line_Kind: ["무엇을", "Kind"],
|
||||
K_job: ["인력", "Labor"],
|
||||
K_mat: ["재료", "Material"],
|
||||
K_eq: ["기계", "Machine"],
|
||||
K_logic: ["다른 로직의 단가", "Other logic"],
|
||||
Line_FromTable: ["표의 값 칸에서 바로 줄 넣기", "Add from table column"],
|
||||
Line_Find: ["찾기", "Search"],
|
||||
Line_Pick: ["고르기", "Pick"],
|
||||
Line_Sub: ["구분", "Group"],
|
||||
Line_Detail: ["상세구분", "Sub-group"],
|
||||
Line_Unit: ["단위", "Unit"],
|
||||
Line_Qty: ["수량", "Quantity"],
|
||||
Line_Cost: ["비목", "Cost item"],
|
||||
Line_Add: ["줄 넣기", "Add line"],
|
||||
Line_Need: ["무엇을 쓸지 고르고 수량을 정해 주세요", "Pick an item and quantity"],
|
||||
Line_None: ["아직 넣은 줄이 없습니다", "No lines yet"],
|
||||
Line_Del: ["빼기", "Remove"],
|
||||
Line_Logic_Find: ["로직 이름·번호 찾기", "Find logic"],
|
||||
Result_Q: ["결과 값을 어떻게 셈할까요?", "How is the result computed?"],
|
||||
|
||||
Qty_Value: ["직접 값", "Fixed number"],
|
||||
Qty_Table: ["표에서 찾기", "Look up in a table"],
|
||||
Qty_Input: ["설계 입력 값", "Design input"],
|
||||
Qty_Logic: ["다른 로직 부르기", "Call another logic"],
|
||||
Qty_Table_Pick: ["어느 표", "Table"],
|
||||
Qty_Col: ["값 칸", "Column"],
|
||||
Qty_Ask: ["설계자에게 물음", "Ask designer"],
|
||||
Qty_Fixed: ["붙박이 값", "Fixed"],
|
||||
Qty_Times: ["× 할증·증가율 (중간 값)", "× surcharge"],
|
||||
Qty_NoTimes: ["(없음)", "(none)"],
|
||||
Qty_Name: ["입력 이름", "Input name"],
|
||||
Qty_Number_Ph: ["수", "number"],
|
||||
Qty_LogicKey: ["로직 키 (예: GF000220)", "Logic key"],
|
||||
Qty_LogicArgs: ["넘길 입력 이름=값 (쉼표로 나눔)", "Args"],
|
||||
Qty_Table_Manual: ["표 키 (예: QF000421)", "Table key"],
|
||||
|
||||
Q_Inputs: ["설계자에게 물을 값에 쉬운 말 설명을 달아 주세요", "Describe design inputs"],
|
||||
Inputs_None: ["설계자에게 물을 값이 없습니다 — 그대로 다음", "No inputs"],
|
||||
In_Desc: ["설명 (쉬운 말 한 줄)", "Description"],
|
||||
In_Unit: ["단위", "Unit"],
|
||||
In_Range: ["범위 아래 ∼ 위 (수 칸)", "Range"],
|
||||
In_Pick: ["고르기 목록", "Choices"],
|
||||
In_Number: ["수를 넣는 칸", "Number"],
|
||||
|
||||
Q_Extra: ["할증·덧줄과 끝수를 정해 주세요", "Surcharge lines and rounding"],
|
||||
Extra_Add: ["덧줄 더하기 (노무비의 몇 % 같은 줄)", "Add surcharge line"],
|
||||
Extra_Name: ["줄 이름 (예: 공구손료 및 경장비)", "Name"],
|
||||
Extra_Base: ["기준", "Base"],
|
||||
Extra_Pct: ["몇 %", "%"],
|
||||
Extra_Cost: ["넣을 비목", "Cost item"],
|
||||
Round_Title: ["끝수", "Rounding"],
|
||||
Round_None: ["처리 안 함", "None"],
|
||||
Round_Down: ["원 미만 버림", "Round down"],
|
||||
Round_Half: ["원 미만 반올림", "Round half"],
|
||||
Round_Note: ["끝수는 적어만 둠 — 계산에는 아직 안 붙음", "Rounding is recorded only"],
|
||||
|
||||
Q_Test: ["견본 값을 넣어 계산해 보세요", "Try sample values"],
|
||||
Test_Run: ["시험 계산", "Calculate"],
|
||||
Test_Empty: ["값을 넣고 「시험 계산」을 누르세요", "Enter values and calculate"],
|
||||
Test_Stopped: ["계산이 멈춤", "Stopped"],
|
||||
Col_Name: ["이름", "Name"],
|
||||
Col_Qty: ["수량", "Qty"],
|
||||
Col_Amount: ["금액", "Amount"],
|
||||
Sum_Total: ["합계", "Total"],
|
||||
Sum_Result: ["결과 값", "Result"],
|
||||
|
||||
Q_Save: ["이대로 저장할까요?", "Save as is?"],
|
||||
Save_Owner: ["개인 로직으로 저장됩니다 — 키는 저장할 때 서버가 붙임", "Saved as personal logic"],
|
||||
Save_File: ["저장할 파일", "File"],
|
||||
Save_None: [
|
||||
"자체 로직 파일이 아직 없어 저장할 수 없습니다 — 서버 준비 뒤 저장됨",
|
||||
"No own-logic file yet",
|
||||
],
|
||||
Save_Do: ["저장", "Save"],
|
||||
Save_Done: ["저장했습니다", "Saved"],
|
||||
Save_Failed: ["저장하지 못함", "Save failed"],
|
||||
Failed: ["불러오지 못함", "Load failed"],
|
||||
|
||||
Preview: ["지금까지 만든 호표", "Lines so far"],
|
||||
Preview_Name: ["이름", "Name"],
|
||||
Preview_Source: ["출처", "Source"],
|
||||
Preview_Inputs: ["설계 입력", "Inputs"],
|
||||
Preview_Middle: ["중간 값", "Middle"],
|
||||
Preview_Extra: ["덧줄", "Extra"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type WizardTextKey = keyof typeof TEXT;
|
||||
|
||||
export function tw(key: WizardTextKey, fill: Record<string, string | number> = {}): string {
|
||||
const entry = TEXT[key];
|
||||
const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0];
|
||||
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
|
||||
}
|
||||
@@ -5,8 +5,9 @@
|
||||
* 접속 계약 — 방식 파일마다 `render(host, logicKey)` 하나
|
||||
* ========================================================================== */
|
||||
|
||||
import { createSelectField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { fetchLogics } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { openLogicWizard } from "./M01_MasterData_UI_Logic_Wizard";
|
||||
import { render as renderA } from "./M01_MasterData_UI_Test_A";
|
||||
import { render as renderB } from "./M01_MasterData_UI_Test_B";
|
||||
import { render as renderC } from "./M01_MasterData_UI_Test_C";
|
||||
@@ -79,7 +80,12 @@ export function mountM01Test(host: HTMLElement): void {
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-test__bar",
|
||||
children: [el("h2", { text: tt("Title") }), select.root, tabs],
|
||||
children: [
|
||||
el("h2", { text: tt("Title") }),
|
||||
select.root,
|
||||
tabs,
|
||||
createButton({ label: tt("New_Logic"), onClick: () => openLogicWizard() }),
|
||||
],
|
||||
}),
|
||||
el("p", { className: "m01-test__muted", text: tt("Read_Only") }),
|
||||
body,
|
||||
|
||||
@@ -12,6 +12,7 @@ const TEXT = {
|
||||
Tab_B: ["B 호표 + 설명", "B Table + notes"],
|
||||
Tab_C: ["C 흐름 그림", "C Flow"],
|
||||
Soon: ["준비 중", "Coming soon"],
|
||||
New_Logic: ["새로 만들기", "Create new"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
Failed: ["불러오지 못함", "Load failed"],
|
||||
Read_Only: ["시험 계산만 — 저장하지 않음", "Trial only — nothing is saved"],
|
||||
|
||||
Reference in New Issue
Block a user