diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts index 98477389..d3b6f611 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts @@ -7,17 +7,10 @@ * ========================================================================== */ 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 { ApiError, runCalc, type CalcAnswer } 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 { createLogic } from "./M01_MasterData_UI_Logic_Wizard_Api"; import { atomPlain, buildRow, @@ -44,7 +37,6 @@ const fail = (e: unknown): void => /** 「새로 만들기」 모달을 엶 */ export function openLogicWizard(options: WizardOptions = {}): void { const d: Draft = emptyDraft(); - let files: LogicFile[] = []; let at = 0; let answered = false; const testValues: Record = {}; @@ -85,12 +77,17 @@ export function openLogicWizard(options: WizardOptions = {}): void { next.disabled = !steps[at].ready(d); }; + const onCopied = (key: string): void => { + close(); + options.onSaved?.(key); + }; + 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 })); + body.replaceChildren(step.render({ d, changed, onCopied })); back.disabled = at === 0; next.hidden = at === steps.length - 1; changed(); @@ -139,8 +136,6 @@ export function openLogicWizard(options: WizardOptions = {}): void { }; 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(); @@ -148,56 +143,33 @@ export function openLogicWizard(options: WizardOptions = {}): void { 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 }); + return runCalc({ key: "", inputs: given, row: buildRow(d) }); }; /* ── 저장 ── */ 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") }), + el("p", { className: "m01w__muted", text: tw("Save_Owner") }), errors, - save, + createButton({ label: tw("Save_Do"), onClick: () => void doSave(errors) }), ], }); }; - const doSave = async (file: string, errors: HTMLElement): Promise => { - const target = files.find((f) => f.file === file); - if (!target) return; + const doSave = async (errors: HTMLElement): Promise => { errors.hidden = true; try { - const row = buildRow(d); - await saveFiles([ - { file: target.file, version: target.version, changes: [{ op: "add", row }] }, - ]); + const made = await createLogic(buildRow(d)); 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]); + options.onSaved?.(made.key); } catch (e) { if (e instanceof ApiError && typeof e.detail === "object" && e.detail !== null) { - const list = (e.detail as { errors?: string[]; stale?: string[] }).errors ?? []; + const list = (e.detail as { errors?: string[] }).errors ?? []; errors.replaceChildren(...list.map((t) => el("div", { text: t }))); errors.hidden = false; } else fail(e); @@ -259,9 +231,6 @@ export function openLogicWizard(options: WizardOptions = {}): void { }; document.body.append(backdrop); - void fetchLogicFiles() - .then((f) => (files = f)) - .catch(fail); go(0); } diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Api.ts index 70d0dd1d..ef7ef7d7 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Api.ts @@ -5,7 +5,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import { ApiError, type ElementBrief } from "./M01_MasterData_UI_Logic_Api"; +import { ApiError, type ElementBrief, type LogicRow } from "./M01_MasterData_UI_Logic_Api"; export interface TableBrief { file: string; @@ -19,10 +19,26 @@ export interface TableBrief { 용도?: { 공종?: string; 대상?: string[]; 로직키?: string[] }; } -/** 표 통째 — `줄` 에서 조건 값 목록을 뽑음 */ -export interface TableFull extends TableBrief { - 줄?: Record[]; - 주?: string[]; +/** 표 조건 칸의 값만(`/table/options`) — 줄을 통째로 받지 않음 */ +export interface TableInfo { + 키: string; + 이름: string; + 조건?: Record; + 값칸?: Record; + /** 고르기 조건 = 줄에 나온 값 차례 · 범위 조건 = [[아래, 위]…] */ + 값?: Record; +} + +async function postJson(path: string, body: unknown): Promise { + const response = await fetch(`${API_BASE_URL}/m01${path}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); + return data; } async function getJson(path: string, params: Record): Promise { @@ -34,31 +50,58 @@ async function getJson(path: string, params: Record): Promise return data; } -/** 책 + 절 번호의 표 — 소요량·계수 둘을 다 봄 · 하위 절(13-4-1-1)도 같이 */ +/** 원문 절 한 줄 — `/sections` */ +export interface SectionBrief { + book: string; + division: string; + chapter: string; + chapterNo: string; + section: string; + title: string; + /** 그 절(아래 절 포함)의 마스터 표·로직 수 — 0 이면 아직 안 올린 절 */ + tables: number; + logics: number; +} + +export const fetchSections = (book: string, division = ""): Promise => + getJson<{ sections: SectionBrief[] }>("/sections", { + book, + ...(division ? { division } : {}), + limit: "2000", + }).then((d) => d.sections); + +/** 그 절(아래 절 포함)의 표 — 소요량·계수 둘을 다 봄 */ 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" }), + getJson<{ tables: TableBrief[] }>("/tables", { group, section, size: "100" }), ), ); - return found - .flatMap((f) => f.tables) - .filter( - (t) => t.구분 === book && (t.원문번호 === section || t.원문번호.startsWith(`${section}-`)), - ); + return found.flatMap((f) => f.tables).filter((t) => t.구분 === book); } -const tableCache = new Map>(); +const tableCache = new Map>(); -export function fetchTableFull(file: string, key: string): Promise { +export function fetchTableInfo(file: string, key: string): Promise { let got = tableCache.get(key); if (!got) { - got = getJson<{ table: TableFull }>("/table", { file, key }).then((d) => d.table); + got = getJson("/table/options", { file, key }); tableCache.set(key, got); } return got; } +/** 자체 로직 만들기 — 키(GX)는 서버가 붙임 */ +export interface Made { + file: string; + version: string; + key: string; + logic: LogicRow; +} +export const createLogic = (logic: LogicRow): Promise => postJson("/logic/new", { logic }); +export const copyLogic = (key: string, name: string): Promise => + postJson("/logic/copy", { key, 이름: name }); + /** 공표 직종 찾기 */ export const searchJobs = (q: string): Promise<{ items: ElementBrief[] }> => getJson("/pick", { kind: "job", q, limit: "100" }); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Atom.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Atom.ts index 73fa996f..4da8904d 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Atom.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Atom.ts @@ -6,7 +6,7 @@ import { createInputField, createSelectField, el, showToast } from "@ui/ui_template_elements"; import { - fetchTableFull, + fetchTableInfo, searchLogics, type LogicBrief, type TableBrief, @@ -181,7 +181,7 @@ function tableBox( const load = (key: string): void => { const brief = tables.find((t) => t.키 === key); if (!brief) return (drawDetail(), onChange()); - void fetchTableFull(brief.file, key) + void fetchTableInfo(brief.file, key) .then((full) => { tableInfo.set(key, full); for (const c of Object.keys(full.조건 ?? {})) atom.cond[c] ??= { ask: true }; diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Copy.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Copy.ts new file mode 100644 index 00000000..0d963f52 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Copy.ts @@ -0,0 +1,62 @@ +/* ============================================================================= + * M01_MasterData_UI_Logic_Wizard_Copy.ts + * 본떠 만들기 — 이미 있는 로직(정본·자체)을 찾아 새 이름으로 복제(`POST /logic/copy`) · 키 GX 는 서버가 붙임 + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { copyLogic, searchLogics, type LogicBrief } from "./M01_MasterData_UI_Logic_Wizard_Api"; +import { tw } from "./M01_MasterData_UI_Logic_Wizard_Text"; +import { fail } from "./M01_MasterData_UI_Logic_Wizard_Ui"; + +export function copyBox(done: (key: string) => void): HTMLElement { + const find = createInputField({ placeholder: tw("Copy_Find"), type: "search" }); + const list = el("div", { className: "m01w__found" }); + const name = createInputField({ placeholder: tw("Copy_Name"), type: "text" }); + let from: LogicBrief | null = null; + const go = createButton({ + label: tw("Copy_Do"), + disabled: true, + onClick: () => { + if (!from) return; + const target = from; + void copyLogic(target.키, name.input.value.trim() || `${target.이름} (수정)`) + .then((made) => { + showToast(tw("Copy_Done"), "success"); + done(made.key); + }) + .catch(fail); + }, + }); + 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) => + list.replaceChildren( + ...r.logics.slice(0, 8).map((l) => { + const row = el("button", { + className: "m01w__row", + attrs: { type: "button", "data-copy": l.키 }, + text: `${l.원문번호} ${l.이름} · ${l.결과단위} · ${l.키}`, + }); + row.addEventListener("click", () => { + from = l; + name.input.value = `${l.이름} (수정)`; + go.disabled = false; + list.replaceChildren(row); + }); + return row; + }), + ), + ) + .catch(fail); + }, 250); + }); + return el("div", { + className: "m01w__card", + children: [el("h4", { text: tw("Copy_Title") }), find.root, list, name.root, go], + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Model.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Model.ts index c49b7c1e..238c6692 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Model.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Model.ts @@ -5,7 +5,7 @@ * ========================================================================== */ import type { HoLine, LogicInput, LogicRow, NamedFormula } from "./M01_MasterData_UI_Logic_Api"; -import type { TableBrief, TableFull } from "./M01_MasterData_UI_Logic_Wizard_Api"; +import type { TableBrief, TableInfo } from "./M01_MasterData_UI_Logic_Wizard_Api"; export type Bind = { ask: true } | { ask: false; value: string }; export type Atom = @@ -82,7 +82,7 @@ export const COST_OF: Record = { }; /** 표 통째 — 고르기 목록·조건 종류를 셈할 때 씀(모달이 읽어 두는 곳) */ -export const tableInfo = new Map(); +export const tableInfo = new Map(); export const emptyAtom = (): Atom => ({ kind: "value", value: "" }); export const emptyQty = (): Qty => ({ atom: emptyAtom(), times: "" }); @@ -153,8 +153,7 @@ export const qtyPlain = (q: Qty): string => export function condChoices(table: string, cond: string): (string | number)[] | undefined { const info = tableInfo.get(table); if (!info || info.조건?.[cond] !== "고르기") return undefined; - const seen = (info.줄 ?? []).map((r) => r[cond] as string | number | undefined | null); - return [...new Set(seen.filter((v): v is string | number => v !== undefined && v !== null))]; + return info.값?.[cond] as (string | number)[] | undefined; } /** 만드는 중인 것이 모든 식에서 받는 설계 입력 이름들(등장 차례) — 이름 · 모양 */ @@ -216,12 +215,12 @@ export function buildRow(d: Draft): LogicRow { 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 })), }; @@ -243,7 +242,7 @@ export function buildRow(d: Draft): LogicRow { row.덧줄 = d.extras .filter((e) => e.name.trim() && isNumber(e.pct)) .map((e) => extraOf(e, source)); - row.끝수 = d.round || null; + Object.assign(row, { 끝수: d.round ? { 대상: "계", 자리: 0, 방법: d.round } : null }); } else row.결과 = { 식: qtyText(d.result) }; return row; } diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Source.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Source.ts new file mode 100644 index 00000000..06a3473d --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Source.ts @@ -0,0 +1,178 @@ +/* ============================================================================= + * M01_MasterData_UI_Logic_Wizard_Source.ts + * 만들기 둘째 걸음 — 원문 절 고르기(책 → 부문 → 장 → 절) · 그 절(아래 절 포함)의 표를 용도와 함께 제시 + * 절 목록 = `GET /sections` · 표 = `GET /tables?section=` — 번호를 손으로 넣지 않음 + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { + fetchSections, + fetchSectionTables, + type SectionBrief, + type TableBrief, +} from "./M01_MasterData_UI_Logic_Wizard_Api"; +import { select, textBox } from "./M01_MasterData_UI_Logic_Wizard_Atom"; +import type { Draft } from "./M01_MasterData_UI_Logic_Wizard_Model"; +import { tw } from "./M01_MasterData_UI_Logic_Wizard_Text"; +import { fail, note, page, type Step } from "./M01_MasterData_UI_Logic_Wizard_Ui"; + +const sections = new Map>(); +const load = (book: string): Promise => { + let got = sections.get(book); + if (!got) sections.set(book, (got = fetchSections(book))); + return got; +}; + +const distinct = (rows: SectionBrief[], key: "division" | "chapter"): string[] => [ + ...new Set(rows.map((r) => r[key]).filter(Boolean)), +]; + +export const sourceStep: Step = { + key: "S_Source", + question: "Q_Source", + ready: (d) => d.section.trim() !== "", + render: ({ d, changed }) => { + const filters = el("div", { className: "m01w__inline" }); + const list = el("div", { className: "m01w__found" }); + const picked = el("p", { className: "m01w__picked" }); + const tables = el("div", { className: "m01w__stack" }); + let all: SectionBrief[] = []; + let division = ""; + let chapter = ""; + let q = ""; + + const drawTables = (): void => + tables.replaceChildren( + ...(d.section && !d.tables.length + ? [note(tw("Section_None")), note(tw("Section_Skip"))] + : d.tables.map((t) => tableRow(t, d, changed))), + ); + + const choose = (s: SectionBrief): void => { + d.section = s.section; + d.tables = []; + picked.textContent = `${tw("Section_Picked")} · ${s.section} ${s.title}`; + changed(); + drawList(); + void fetchSectionTables(d.book, s.section) + .then((found) => { + d.tables = [...found]; // 처음에는 이 절의 표를 모두 씀 — 빼려면 체크를 풂 + drawTables(); + changed(); + }) + .catch(fail); + }; + + const options = (values: string[]): { value: string; text: string }[] => + values.map((v) => ({ value: v, text: v })); + + const draw = (): void => { + const inDivision = all.filter((s) => !division || s.division === division); + const chapters = distinct(inDivision, "chapter"); + if (!chapters.includes(chapter)) chapter = chapters[0] ?? ""; + const divisions = distinct(all, "division"); + filters.replaceChildren( + select( + ["산림품셈", "건설품셈"].map((b) => ({ value: b, text: b })), + d.book, + (v) => { + d.book = v; + d.section = ""; + d.tables = []; + division = chapter = ""; + picked.textContent = ""; + drawTables(); + changed(); + start(); + }, + tw("Book"), + ), + ...(divisions.length + ? [ + select( + options(divisions), + division || divisions[0], + (v) => ((division = v), (chapter = ""), draw()), + tw("Division"), + ), + ] + : []), + select(options(chapters), chapter, (v) => ((chapter = v), draw()), tw("Chapter")), + textBox(q, tw("Section_Search"), (v) => ((q = v.trim()), drawList())), + ); + drawList(); + }; + + const drawList = (): void => { + const inChapter = all.filter( + (s) => + (!division || s.division === division) && + s.chapter === chapter && + (!q || s.section.startsWith(q) || s.title.includes(q)), + ); + list.replaceChildren( + ...inChapter.slice(0, 80).map((s) => { + const row = el("button", { + className: `m01w__row${s.section === d.section ? " is-active" : ""}`, + attrs: { type: "button", "data-section": s.section }, + text: `${s.section} ${s.title} · ${tw("Section_Counts", { t: s.tables, l: s.logics })}`, + }); + row.addEventListener("click", () => choose(s)); + return row; + }), + ); + }; + + const start = (): void => { + void load(d.book) + .then((rows) => { + all = rows; + const divisions = distinct(rows, "division"); + division = divisions[0] ?? ""; + const own = rows.find((s) => s.section === d.section); + if (own) { + division = own.division; + chapter = own.chapter; + picked.textContent = `${tw("Section_Picked")} · ${own.section} ${own.title}`; + } + draw(); + }) + .catch(fail); + }; + start(); + drawTables(); + return page("Q_Source", filters, list, picked, tables); + }, +}; + +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.이름} · ${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(" · "), + }), + ], + }), + ], + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Steps.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Steps.ts index 1cb0b38e..cb162725 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Steps.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Steps.ts @@ -6,11 +6,7 @@ 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 { searchJobs, type TableBrief } from "./M01_MasterData_UI_Logic_Wizard_Api"; import { atomEditor, select, textBox } from "./M01_MasterData_UI_Logic_Wizard_Atom"; import { BASES, @@ -31,26 +27,11 @@ import { } 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; -} +import { copyBox } from "./M01_MasterData_UI_Logic_Wizard_Copy"; +import { sourceStep } from "./M01_MasterData_UI_Logic_Wizard_Source"; +import { fail, note, page, type Ctx, type Step } from "./M01_MasterData_UI_Logic_Wizard_Ui"; -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"); +export type { Ctx, Step }; /* ── 1. 이름 ─────────────────────────────────────────────────────────── */ @@ -60,7 +41,7 @@ const nameStep: Step = { key: "S_Name", question: "Q_Name", ready: (d) => d.name.trim() !== "" && d.unit.trim() !== "", - render: ({ d, changed }) => { + render: ({ d, changed, onCopied }) => { const other = !UNITS.includes(d.unit); const own = el("div"); const drawOwn = (on: boolean): void => @@ -94,104 +75,11 @@ const nameStep: Step = { ), own, note(tw("Unit_Money")), + copyBox(onCopied), ); }, }; -/* ── 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() }); @@ -643,8 +531,9 @@ const extraStep: Step = { select( [ { value: "", text: tw("Round_None") }, - { value: "원 미만 버림", text: tw("Round_Down") }, - { value: "원 미만 반올림", text: tw("Round_Half") }, + { value: "버림", text: tw("Round_Down") }, + { value: "올림", text: tw("Round_Up") }, + { value: "반올림", text: tw("Round_Half") }, ], d.round, (v) => { @@ -652,7 +541,6 @@ const extraStep: Step = { changed(); }, ), - note(tw("Round_Note")), ); }, }; diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Style.css index 7c4d4d09..d245fd85 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Style.css @@ -162,3 +162,8 @@ grid-template-columns: 1fr; } } + +.m01w__row.is-active { + border-color: var(--color-accent, #2b6cb0); + font-weight: 600; +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Text.ts index 1c48be8f..b59596f4 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Text.ts @@ -28,12 +28,20 @@ const TEXT = { "Units starting with 원 give money", ], Unit_Other: ["그 밖 (직접)", "Other"], + Copy_Title: ["또는 이미 있는 로직을 본떠 만들기", "Or copy an existing logic"], + Copy_Find: ["본뜰 로직 이름·번호 찾기", "Find a logic to copy"], + Copy_Name: ["새 이름", "New name"], + Copy_Do: ["본떠서 저장", "Copy and save"], + Copy_Done: ["본떠서 저장했습니다", "Copied"], 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"], + Division: ["부문", "Division"], + Chapter: ["장", "Chapter"], + Section_Search: ["절 번호·제목 찾기", "Search sections"], + Section_Counts: ["표 {t} · 로직 {l}", "{t} tables · {l} logics"], + Section_Picked: ["고른 절", "Chosen section"], Section_None: [ "이 절에 쓰는 표가 없습니다 — 절 번호를 확인해 주세요", "No tables in this section", @@ -112,8 +120,8 @@ const TEXT = { Round_Title: ["끝수", "Rounding"], Round_None: ["처리 안 함", "None"], Round_Down: ["원 미만 버림", "Round down"], + Round_Up: ["원 미만 올림", "Round up"], Round_Half: ["원 미만 반올림", "Round half"], - Round_Note: ["끝수는 적어만 둠 — 계산에는 아직 안 붙음", "Rounding is recorded only"], Q_Test: ["견본 값을 넣어 계산해 보세요", "Try sample values"], Test_Run: ["시험 계산", "Calculate"], @@ -126,11 +134,9 @@ const TEXT = { 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_Owner: [ + "자체 로직으로 저장됩니다 — 키(GX…)는 저장할 때 서버가 붙임", + "Saved as your own logic; the server issues the key", ], Save_Do: ["저장", "Save"], Save_Done: ["저장했습니다", "Saved"], diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Ui.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Ui.ts new file mode 100644 index 00000000..ed3f904d --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Ui.ts @@ -0,0 +1,31 @@ +/* ============================================================================= + * M01_MasterData_UI_Logic_Wizard_Ui.ts + * 「새로 만들기」 걸음 화면이 같이 쓰는 작은 부품 — 걸음 모양 · 쪽 · 안내 글 · 오류 알림 + * ========================================================================== */ + +import { el, showToast } from "@ui/ui_template_elements"; +import type { Draft } 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; + /** 본떠 만들기가 끝남 — 모달이 닫고 새 키를 넘김 */ + onCopied: (key: string) => void; +} +export interface Step { + key: WizardTextKey; + question: WizardTextKey; + render: (ctx: Ctx) => HTMLElement; + ready: (d: Draft) => boolean; +} + +export const page = (question: WizardTextKey, ...body: (HTMLElement | string)[]): HTMLElement => + el("div", { + className: "m01w__page", + children: [el("h3", { text: tw(question) }), ...body], + }); +export const note = (text: string): HTMLElement => el("p", { className: "m01w__muted", text }); +export const fail = (e: unknown): void => + showToast(e instanceof Error ? e.message : tw("Failed"), "error"); diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index 41cc657e..e2c32f6b 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -38,7 +38,7 @@ - `elements` = 요소 찾기 창 — 그룹 전체에서 `q` 찾기 · `ref` = 식에 넣는 키 · 표형 그룹은 `값칸` 이 옴. - `materials` = 재료 고르기 단계 — 로직 재료 줄 `요소` 가 조건 `{구분, 상세구분, 규격}` 이면 이 API 로 후보(방식 A·B·C 가 부품 `M01_MasterData_UI_Test_Pick.ts` 하나를 같이 씀 · 구분 → 상세구분 → 후보 순으로 좁힘). `값` 이 비어 있어도 후보로 둠. 옛 재료 줄(품셈재료 키)은 품셈재료 이름(없으면 이름 첫 낱말)으로 `pick?kind=price` 후보. 후보 글 = 이름 규격 · 값 있는 열 전부(물가자료 · 유통물가 …). 멈춤 까닭 `입력 「X」 없음` · `맞는 줄 0개` 는 방식 A·B·C 가 쉬운 말로 바꿔 보임(`plainReason`) · 빈 수 칸은 「값을 넣어 주세요」 안내 글. - 소요량·계수 표의 `용도`({공종, 대상, 로직키}) = 방식 B 원문 표 미리보기 머리에 안내 · 칸이 없으면 안 보임 · `/tables` 는 `usage` 로 `용도.대상` 거름(기계 `sub`·`detail` 은 인력과 같이 구분 → 상세구분). -- 「새로 만들기」 모달(`M01_MasterData_UI_Logic_Wizard*.ts`) — 읽기는 기존 길만 씀: 원문 절 = `/tables`(`group` 소요량·계수 · `q` 절 번호)를 책·원문번호로 거름 · 표 조건 값 목록 = `/table` 줄에서 화면이 뽑음 · 인력 = `/pick?kind=job` · 재료 = `/pick?kind=price` 로 찾아 구분·상세구분 조건 묶음으로 넣음 · 기계 = `/elements` · 다른 로직 = `/logics?q`. 시험 계산 = `/calc` `row`+`file`(키 "") · 저장 = `/save` `add` — 파일 이름에 「자체」 가 든 로직 파일이 있어야 저장 단추가 열림. +- 「새로 만들기」 모달(`M01_MasterData_UI_Logic_Wizard*.ts`) — 원문 절 = `/sections` · 그 절의 표 = `/tables?section=`(소요량·계수) · 표 조건 값 = `/table/options` · 인력 = `/pick?kind=job` · 재료 = `/pick?kind=price`(구분·상세구분 조건 묶음으로 넣음) · 기계 = `/elements` · 다른 로직 = `/logics?q`. 시험 계산 = `/calc` 키 "" + `row`(파일 안 보냄) · 저장 = `/logic/new`(소유 `현장` · 키 GX 는 서버) · 본떠 만들기 = `/logic/copy`. 끝수는 묶음 `{대상: 계, 자리: 0, 방법}` 으로 보냄. - `section` = 절 번호 거름 — 그 절과 그 아래 절만(`13-4` 는 `13-4` · `13-4-1` 만 · `13-40` 은 안 걸림). 「만들기」가 절을 고르면 그 절의 표만 보임. - `/sections` = 만들기 첫 걸음 — 원문 본문의 절 제목 줄에서 모음(`book` = 산림품셈 · 건설품셈 · `division` = 건설품셈 부문(공통 · 토목 · 건축 · 기계설비 · 유지관리) · `chapter` = 장 번호 두 자리 「13」 · `q` = 절 번호·제목 찾기). `chapter` = 「부문 NN장 장 제목」 · `chapterNo` = 「NN」 · `tables` · `logics` = 그 절(아래 절 포함)의 마스터 표·로직 수 — 0 이면 아직 안 올린 절. - `/table/options` = 조건 값 고르기 — `고르기` 조건은 줄에 나온 값 차례대로(중복 없음) · `범위` 조건은 `[[아래, 위]…]` · `count` = 줄 수. 표 몸을 통째로 받지 않으려는 자리에만 씀(통째는 `/table`).