diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts new file mode 100644 index 00000000..98477389 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts @@ -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 = {}; + + 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 => { + if (!files.length) files = await fetchLogicFiles(); + const file = (files.find((f) => f.book === d.book) ?? files[0]).file; + const given: Record = {}; + 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 => { + 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 }); +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Api.ts new file mode 100644 index 00000000..70d0dd1d --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Api.ts @@ -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; + 값칸?: Record; + 용도?: { 공종?: string; 대상?: string[]; 로직키?: string[] }; +} + +/** 표 통째 — `줄` 에서 조건 값 목록을 뽑음 */ +export interface TableFull extends TableBrief { + 줄?: Record[]; + 주?: string[]; +} + +async function getJson(path: string, params: Record): Promise { + 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 { + 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>(); + +export function fetchTableFull(file: string, key: string): Promise { + 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 }); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Atom.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Atom.ts new file mode 100644 index 00000000..73fa996f --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Atom.ts @@ -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)[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 = { + 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, onChange: () => void): HTMLElement { + return textBox(atom.value, tw("Qty_Number_Ph"), (v) => { + atom.value = v; + onChange(); + }); +} + +function inputBox(atom: Extract, onChange: () => void): HTMLElement { + return textBox(atom.name, tw("Qty_Name"), (v) => { + atom.name = v.trim(); + onChange(); + }); +} + +function logicBox(atom: Extract, 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, + 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, + 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], + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Model.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Model.ts new file mode 100644 index 00000000..feb7180c --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Model.ts @@ -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 } + | { 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; + extras: Extra[]; + round: string; +} + +export const BASES: Record = { + 노무비: "노무비", + 재료비: "재료비", + 경비: "경비", + "노무비+재료비": "(노무비 + 재료비)", + "노무비+재료비+경비": "(노무비 + 재료비 + 경비)", +}; +export const COSTS = ["노무비", "재료비", "경비"]; +export const COST_OF: Record = { + 인력: "노무비", + 재료: "재료비", + 기계: "경비", + 로직: "", +}; + +/** 표 통째 — 고르기 목록·조건 종류를 셈할 때 씀(모달이 읽어 두는 곳) */ +export const tableInfo = new Map(); + +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(); + 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 = {}; + 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; +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Steps.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Steps.ts new file mode 100644 index 00000000..1cb0b38e --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Steps.ts @@ -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, + 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]; diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Style.css new file mode 100644 index 00000000..7c4d4d09 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Style.css @@ -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; + } +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Text.ts new file mode 100644 index 00000000..1c48be8f --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Text.ts @@ -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; + +export type WizardTextKey = keyof typeof TEXT; + +export function tw(key: WizardTextKey, fill: Record = {}): 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)); +} diff --git a/M01_MasterData/M01_MasterData_UI_Test.ts b/M01_MasterData/M01_MasterData_UI_Test.ts index 570d1644..735683bd 100644 --- a/M01_MasterData/M01_MasterData_UI_Test.ts +++ b/M01_MasterData/M01_MasterData_UI_Test.ts @@ -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, diff --git a/M01_MasterData/M01_MasterData_UI_Test_Text.ts b/M01_MasterData/M01_MasterData_UI_Test_Text.ts index fc551b2c..b2252f5d 100644 --- a/M01_MasterData/M01_MasterData_UI_Test_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Test_Text.ts @@ -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"],