diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts new file mode 100644 index 00000000..bf8cde20 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -0,0 +1,430 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab.ts + * 관리자 화면 — 로직 칸. 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산 + * 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「일위대가 로직」 컨테이너(`listHost`) 안 + * + * 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`. + * 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임. + * ========================================================================== */ + +import { t as L } from "@ui/ui_template_locale"; +import { + createButton, + el, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { + ApiError, + fetchLogic, + fetchLogicFiles, + fetchLogics, + fetchLogicSubs, + saveFiles, + type CalcLine, + type ElementBrief, + type LogicFile, + type LogicRow, + type LogicSummary, + type SaveFile, +} from "./M01_MasterData_UI_Logic_Api"; +import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; +import { buildEditor } from "./M01_MasterData_UI_Logic_Edit"; +import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List"; +import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; +import type { SideHandle } from "./M01_MasterData_UI_Side"; +import { tx } from "./M01_MasterData_UI_Logic_Text"; +import "./M01_MasterData_UI_Logic_Style.css"; + +/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ +interface Draft { + /** 구분(원문 + 부문) — 목록 id 를 세움 */ + sub: string; + file: string; + version: string; + origKey: string | null; + row: LogicRow | null; +} + +interface Opened extends Draft { + id: string; + row: LogicRow | null; + original: string | null; + prices: Record; + reasons: string[]; + lines: CalcLine[] | null; + values: Record; +} + +const cacheKey = "m01_logic_drafts_lab"; +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; + +function loadDrafts(): Record { + try { + return JSON.parse(sessionStorage.getItem(cacheKey) ?? "{}") as Record; + } catch { + return {}; + } +} + +export interface LogicHandle { + /** 밖(요소 화면)에서 이 키를 바로 엶 — 소요량·계수 표의 「쓰는 로직」 눌렀을 때. */ + openKey: (key: string) => Promise; +} + +export async function mountM01LogicLab( + host: HTMLElement, + side: SideHandle, + openKey?: string, +): Promise { + let drafts = loadDrafts(); + let files: LogicFile[] = []; + let opened: Opened | null = null; + let calcInputs = ""; + const editor = el("div", { className: "m01-logic__editor" }); + const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } }); + const calc = el("aside", { className: "m01-logic__calc" }); + const filterHost = el("div"); + const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") }); + let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음 + const pending = el("span", { className: "m01-logic__muted" }); + const list = buildList((item) => void open(item.id, item.sub, item.key)); + const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) }); + + const persist = (): void => { + try { + sessionStorage.setItem(cacheKey, JSON.stringify(drafts)); + } catch { + /* 캐시가 막혀도 화면 안의 고친 것은 남음 */ + } + const marks = new Map(); + const extra: ListItem[] = []; + for (const [id, d] of Object.entries(drafts)) { + marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited"); + if (d.origKey === null && d.row) { + extra.push({ + id, + sub: d.sub, + detail: "", + key: d.row.키, + number: d.row.원문번호, + name: d.row.이름, + unit: d.row.결과단위, + source: d.row.출처, + blocked: false, + reasons: [], + }); + } + } + list.setMarks(marks, extra); + const n = Object.keys(drafts).length; + pending.textContent = n ? tx("Bar_Pending", { n }) : ""; + saveButton.disabled = discardButton.disabled = n === 0; + }; + + const touch = (): void => { + if (!opened?.row) return; + const now = JSON.stringify(opened.row); + if (opened.origKey !== null && now === opened.original) delete drafts[opened.id]; + else drafts[opened.id] = pick(opened, clone(opened.row)); + persist(); + const inputs = JSON.stringify(opened.row.입력 ?? []); + if (inputs !== calcInputs) drawCalc(); + }; + + const pick = (o: Opened, row: LogicRow | null): Draft => ({ + sub: o.sub, + file: o.file, + version: o.version, + origKey: o.origKey, + row, + }); + + const drawCalc = (): void => { + if (!opened?.row) { + calc.replaceChildren(); + return; + } + const current = opened; + calcInputs = JSON.stringify(current.row?.입력 ?? []); + buildCalc(calc, { + savedKey: current.origKey, + file: current.file, + row: current.row as LogicRow, + dirty: () => current.id in drafts, + values: current.values, + onLines: (lines) => { + current.lines = lines; + drawEditor(); + }, + }); + }; + + const drawEditor = (): void => { + // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산 + const listing = !opened; + list.root.hidden = !listing || !loaded; + waiting.hidden = loaded; + editor.hidden = back.hidden = listing; + calc.hidden = listing; + if (!opened) return; + if (!opened.row) { + editor.replaceChildren( + el("p", { className: "m01-logic__empty", text: `● ${tx("List_Deleted")}` }), + ); + return; + } + const current = opened; + buildEditor(editor, { + row: current.row as LogicRow, + file: current.file, + isNew: current.origKey === null, + files: files.map((f) => f.file), + reasons: current.reasons, + prices: current.prices, + lines: current.lines, + onChange: touch, + onFile: (file) => { + const f = files.find((x) => x.file === file); + if (!f) return; + Object.assign(current, { file: f.file, sub: subOf(f), version: f.version }); + touch(); + }, + onPick: openPicker, + }); + }; + + /** 로직 파일 → 구분(원문 + 부문) — 「공통 03장 토공사」 의 앞말이 부문 */ + const subOf = (f: LogicFile): string => + f.book === "건설품셈" ? `${f.book} ${f.chapter.split(" ")[0]}` : String(f.book); + + const show = (next: Opened | null): void => { + opened = next; + errors.hidden = true; + drawEditor(); + drawCalc(); + }; + + const open = async (id: string, sub: string, key: string): Promise => { + const draft = drafts[id]; + if (draft?.origKey === null) { + show({ ...draft, id, original: null, prices: {}, reasons: [], lines: null, values: {} }); + return; + } + try { + const one = await fetchLogic(key); + show({ + id, + sub, + file: draft?.file ?? one.file, + version: draft?.version ?? one.version, + origKey: key, + row: draft ? (draft.row === null ? null : clone(draft.row)) : clone(one.logic), + original: JSON.stringify(one.logic), + prices: one.prices, + reasons: one.reasons, + lines: null, + values: {}, + }); + } catch (error) { + showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); + } + }; + + let allLogics: LogicSummary[] = []; + + const reload = async (): Promise => { + const [logics, logicFiles, subs] = await Promise.all([ + fetchLogics(), + fetchLogicFiles(), + fetchLogicSubs(), + ]); + files = logicFiles; + allLogics = logics; + list.setItems(logics); + // 왼쪽 「전체」 + 구분 → 상세구분 거름 — 고르면 목록으로 돌아와 그 범위만 + filterHost.replaceChildren( + side.filter({ + id: "로직시험|", + store: "m01.filter.로직시험", + subs, + total: list.total(), + subLabel: L("M01_LaborSub"), + detailLabel: L("M01_LaborDetail"), + restore: true, + onPick: (sub, detail) => { + list.setPick(sub, detail); + if (opened) show(null); + }, + }), + ); + persist(); + }; + + /** 옛 컨테이너의 「새로 만들기」 — 빈 줄 하나를 초안으로 세움(저장 때 서버가 키를 줌) */ + const onDraftNew = (): void => { + const f = files.find((x) => x.file === opened?.file) ?? files[0]; + if (!f) return; + const id = `new:${Date.now()}`; + // 원문번호는 절 번호만(부문은 구분 칸) · 키는 저장 때 서버가 줌 + const row: LogicRow = { + 키: "", + 원문번호: `${tx("New_Key")} ${Object.keys(drafts).length + 1}`, + 이름: "", + 결과단위: "원/", + 출처: "", + 소유: "공용", + 입력: [], + 중간: [], + 호표: [], + 덧줄: [], + 끝수: null, + }; + const next: Opened = { + id, + sub: subOf(f), + file: f.file, + version: f.version, + origKey: null, + row, + original: null, + prices: {}, + reasons: [], + lines: null, + values: {}, + }; + drafts[id] = pick(next, clone(row)); + persist(); + show(next); + }; + + const onDelete = async (): Promise => { + if (!opened?.row) return; + if ( + !(await showConfirmDialog( + tx("Confirm_Delete", { v: opened.row.원문번호 || opened.row.키 }), + tx("Bar_Delete"), + )) + ) + return; + if (opened.origKey === null) { + delete drafts[opened.id]; + persist(); + show(null); + return; + } + drafts[opened.id] = pick(opened, null); + opened.row = null; + persist(); + show(opened); + }; + + const onDiscard = async (): Promise => { + if (!(await showConfirmDialog(tx("Confirm_Discard"), tx("Bar_Discard")))) return; + drafts = {}; + persist(); + const was = opened; + if (was && was.origKey !== null) + await open(logicId(was.sub, was.origKey), was.sub, was.origKey); + else show(null); + }; + + const onSave = async (): Promise => { + const byFile = new Map(); + for (const d of Object.values(drafts)) { + const part = byFile.get(d.file) ?? { file: d.file, version: d.version, changes: [] }; + if (d.origKey === null) { + if (d.row) part.changes.push({ op: "add", row: d.row }); + } else if (d.row === null) part.changes.push({ op: "delete", key: d.origKey }); + else part.changes.push({ op: "edit", key: d.origKey, row: d.row }); + byFile.set(d.file, part); + } + if (!byFile.size) { + showToast(tx("Save_Nothing"), "info"); + return; + } + showLoadingOverlay(); + try { + await saveFiles([...byFile.values()]); + const was = opened; + drafts = {}; + await reload(); + showToast(tx("Save_Done"), "success"); + if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키); + else show(null); + } catch (error) { + failed(error); + } finally { + hideLoadingOverlay(); + } + }; + + const failed = (error: unknown): void => { + const detail = + error instanceof ApiError ? (error.detail as { stale?: string[]; errors?: string[] }) : null; + if (error instanceof ApiError && error.status === 409) { + showToast(tx("Save_Stale", { v: (detail?.stale ?? []).join(", ") }), "error"); + } else if (error instanceof ApiError && error.status === 422) { + errors.replaceChildren( + el("strong", { text: tx("Save_Errors") }), + ...(detail?.errors ?? []).map((x) => el("div", { text: x })), + ); + errors.hidden = false; + } else { + showToast(error instanceof Error ? error.message : tx("Save_Failed"), "error"); + } + }; + + const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() }); + const discardButton = createButton({ + label: tx("Bar_Discard"), + variant: "ghost", + onClick: () => void onDiscard(), + }); + const bar = el("div", { + className: "m01-logic__bar", + children: [ + back, + el("h2", { text: tx("Title") }), + pending, + createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onDraftNew }), + createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), + discardButton, + saveButton, + ], + }); + host.replaceChildren( + el("div", { + className: "m01-logic", + children: [ + el("main", { + className: "m01-logic__main", + children: [bar, errors, waiting, list.root, editor], + }), + calc, + ], + }), + ); + side.labHost.replaceChildren(filterHost); + show(null); + showLoadingOverlay(); + const openKeyOn = async (key: string): Promise => { + const found = allLogics.find((x) => x.키 === key); + if (found) await open(logicId(found.구분, found.키), found.구분, found.키); + else showToast(tx("Load_Failed"), "error"); + }; + + try { + await reload(); + loaded = true; + drawEditor(); + if (openKey) await openKeyOn(openKey); + } catch (error) { + showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); + } finally { + hideLoadingOverlay(); + } + return { openKey: openKeyOn }; +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_CView.ts b/M01_MasterData/M01_MasterData_UI_Logic_CView.ts deleted file mode 100644 index a7faf6ff..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_CView.ts +++ /dev/null @@ -1,288 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_Logic_CView.ts - * 로직 테스트 — 방식 C(흐름 그림). 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계 - * - * 읽기 + 시험 계산만(저장 없음 · 정본 로직은 건드리지 않음) — 로직 화면이 쓰는 같은 길 - * (`fetchLogic` · `runCalc`)을 그대로 씀. 상자를 누르면 값과 출처 · 로직이 로직을 부르는 - * 줄은 상자 안에서 펼침 · 설계 값을 바꾸면 흐름의 값이 바로 바뀜. - * 뼈대(어느 상자가 어디에) = `M01_MasterData_UI_Logic_CView_Model.ts`. - * ========================================================================== */ - -import { createSelectField, el, showToast } from "@ui/ui_template_elements"; -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - fetchLogic, - runCalc, - type CalcAnswer, - type LogicOne, - type LogicRow, -} from "./M01_MasterData_UI_Logic_Api"; -import { - buildFlow, - type FlowBox, - type FlowColumn, - type FlowKind, -} from "./M01_MasterData_UI_Logic_CView_Model"; -import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick"; -import { guideLines, plainReason, tx } from "./M01_MasterData_UI_Logic_Text"; -import "./M01_MasterData_UI_Logic_CView_Style.css"; - -const TEXT = { - Loading: ["불러오는 중", "Loading"], - Failed: ["불러오지 못함", "Load failed"], - Stopped: ["멈춤", "Stopped"], - NoInputs: ["받을 값 없음", "No inputs"], - Other: ["그 밖", "Other"], - Sub: ["이 로직이 부르는 로직", "Logic called here"], - SubLines: ["호표", "Unit-cost lines"], - Col_입력: ["설계 값", "Design values"], - Col_중간: ["표 찾기", "Table lookup"], - Col_수량: ["수량", "Qty"], - Col_단가: ["× 단가", "× Unit price"], - Col_덧줄: ["할증·덧줄", "Extra lines"], - Col_비목: ["비목 합계", "Cost items"], - Col_계: ["계", "Total"], -} as const satisfies Record; - -const tc = (key: keyof typeof TEXT): string => - TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]; - -const title = (kind: FlowKind): string => tc(`Col_${kind}` as keyof typeof TEXT); - -/** 흐름 그림 하나를 `host` 에 그림 — 로직 키 하나(어느 로직이 와도 돎) */ -export function render(host: HTMLElement, logicKey: string): void { - host.replaceChildren(el("p", { className: "m01v__muted", text: tc("Loading") })); - void fetchLogic(logicKey) - .then((one) => mount(host, one)) - .catch((error: unknown) => { - host.replaceChildren( - el("p", { - className: "m01v__bad", - text: error instanceof Error ? error.message : tc("Failed"), - }), - ); - }); -} - -function mount(host: HTMLElement, one: LogicOne): void { - const row = one.logic; - const values: Record = {}; - // 고르기 칸은 첫 값으로 시작 — 설계자가 바로 흐름을 보게(수 칸은 비워 둠) - for (const spec of row.입력 ?? []) { - if (spec.고르기?.length) values[spec.이름] = String(spec.고르기[0]); - } - const swaps: Swaps = new Map(); - const picker = pickPanel(one, swaps, () => run()); - const open = new Set(); - const subs = new Map(); - let answer: CalcAnswer | null = null; - let timer: number | undefined; - - const rest = el("div", { className: "m01v__rest" }); - const stopped = el("p", { className: "m01v__bad", attrs: { hidden: "" } }); - - const boxView = (box: FlowBox): HTMLElement => { - const node = el("details", { - className: `m01v__box${box.bad ? " m01v__box--bad" : ""}`, - children: [ - el("summary", { - children: [ - el("span", { className: "m01v__label", text: box.label }), - el("span", { className: "m01v__value", text: box.value }), - ...(box.note ? [el("span", { className: "m01v__note", text: box.note })] : []), - ], - }), - el("dl", { - className: "m01v__detail", - children: box.detail.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: v })]), - }), - ], - }); - if (open.has(box.id)) node.open = true; - node.addEventListener("toggle", () => (node.open ? open.add(box.id) : open.delete(box.id))); - if (box.logic) node.append(subView(box.logic)); - return node; - }; - - /** 로직이 부르는 로직 — 펼칠 때 한 번 읽어 그 호표를 상자 안에 보임 */ - const subView = (key: string): HTMLElement => { - const body = el("div", { className: "m01v__sub-body" }); - const node = el("details", { - className: "m01v__sub", - children: [el("summary", { text: `${tc("Sub")} · ${key}` }), body], - }); - const fill = (sub: LogicRow): void => { - body.replaceChildren( - el("p", { className: "m01v__muted", text: `${sub.이름} (${sub.결과단위}) · ${sub.출처}` }), - el("p", { className: "m01v__muted", text: tc("SubLines") }), - el("ul", { - children: (sub.호표 ?? []).map((item) => - el("li", { text: `${item.이름 ?? item.요소} · ${item.수량}` }), - ), - }), - ); - }; - node.addEventListener("toggle", () => { - if (!node.open || body.childElementCount) return; - const had = subs.get(key); - if (had) { - fill(had); - return; - } - body.replaceChildren(el("p", { className: "m01v__muted", text: tc("Loading") })); - void fetchLogic(key) - .then((deep) => { - subs.set(key, deep.logic); - fill(deep.logic); - }) - .catch(() => body.replaceChildren(el("p", { className: "m01v__bad", text: tc("Failed") }))); - }); - return node; - }; - - const columnView = (column: FlowColumn): HTMLElement => { - const head = el("h4", { className: "m01v__col-head", text: title(column.kind) }); - // 상자가 많으면 비목으로 묶어 접음 — 줄이 많은 로직도 한 화면에(비목 없는 줄은 「그 밖」) - const costOf = (box: FlowBox): string => box.cost ?? tc("Other"); - const costs = [...new Set(column.boxes.map(costOf))]; - const body = - column.boxes.length > 6 && costs.length > 1 - ? costs.map((cost) => - el("details", { - className: "m01v__group", - attrs: { open: "" }, - children: [ - el("summary", { text: cost }), - ...column.boxes.filter((b) => costOf(b) === cost).map(boxView), - ], - }), - ) - : column.boxes.map(boxView); - return el("section", { className: "m01v__col", children: [head, ...body] }); - }; - - const redraw = (): void => { - const columns = buildFlow(row, answer, one.prices, values); - const nodes: HTMLElement[] = []; - for (const column of columns.slice(1)) { - nodes.push(el("span", { className: "m01v__arrow", text: "›" }), columnView(column)); - } - rest.replaceChildren(...nodes); - const reason = answer && !answer.ok ? answer.reason : ""; - stopped.textContent = reason ? `${tc("Stopped")} — ${plainReason(reason)}` : ""; - stopped.hidden = !reason; - }; - - const run = (): void => { - const inputs: Record = {}; - for (const spec of row.입력 ?? []) { - const raw = (values[spec.이름] ?? "").trim(); - if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤 - const option = spec.고르기?.find((o) => String(o) === raw); - inputs[spec.이름] = - option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw); - } - const swapped = swappedRow(row, swaps); - void runCalc({ key: row.키, inputs, ...(swapped ? { row: swapped, file: one.file } : {}) }) - .then((got) => { - answer = got; - redraw(); - }) - .catch((error: unknown) => { - showToast(error instanceof Error ? error.message : tc("Failed"), "error"); - }); - }; - - const later = (): void => { - window.clearTimeout(timer); - timer = window.setTimeout(run, 250); - }; - - host.replaceChildren( - el("div", { - className: "m01v", - children: [ - el("div", { - className: "m01v__head", - children: [ - el("h3", { text: `${row.원문번호} ${row.이름}` }), - el("span", { className: "m01v__muted", text: `${row.결과단위} · ${row.출처}` }), - ], - }), - ...(one.reasons.length - ? [el("p", { className: "m01v__bad", text: one.reasons.join(" · ") })] - : []), - el("ul", { - className: "m01v__muted", - children: guideLines().map((t) => el("li", { text: t })), - }), - stopped, - ...(hasPickable(row) - ? [ - el("details", { - attrs: { open: "" }, - children: [el("summary", { text: tx("Mat_Title") }), picker], - }), - ] - : []), - el("div", { - className: "m01v__flow", - children: [inputColumn(row, values, later), rest], - }), - ], - }), - ); - redraw(); - run(); -} - -/** 설계 값 칸 — 한 번만 세움(다시 그려도 적던 값·글쇠 자리가 안 날아감) */ -function inputColumn( - row: LogicRow, - values: Record, - onChange: () => void, -): HTMLElement { - const fields = (row.입력 ?? []).map((spec) => { - let control: HTMLElement; - if (spec.고르기?.length) { - control = createSelectField({ - options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), - value: values[spec.이름] ?? "", - compact: true, - onChange: (v) => { - values[spec.이름] = v; - onChange(); - }, - }).root; - } else { - const box = el("input", { - className: "m01v__input", - attrs: { type: "text", inputmode: "decimal" }, - }); - box.placeholder = spec.범위 ? `${spec.범위[0]} ∼ ${spec.범위[1]}` : tx("Enter_Value"); - box.value = values[spec.이름] ?? ""; - box.addEventListener("input", () => { - values[spec.이름] = box.value; - onChange(); - }); - control = box; - } - return el("label", { - className: "m01v__field", - children: [ - el("span", { - className: "m01v__label", - text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, - }), - control, - ], - }); - }); - return el("section", { - className: "m01v__col m01v__col--input", - children: [ - el("h4", { className: "m01v__col-head", text: title("입력") }), - ...(fields.length ? fields : [el("p", { className: "m01v__muted", text: tc("NoInputs") })]), - ], - }); -} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_CView_Model.ts b/M01_MasterData/M01_MasterData_UI_Logic_CView_Model.ts deleted file mode 100644 index 7e5f321c..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_CView_Model.ts +++ /dev/null @@ -1,238 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_Logic_CView_Model.ts - * 방식 C(흐름 그림)의 뼈대 — 로직 한 줄 + 시험 계산 답 → 왼쪽에서 오른쪽 칸의 상자. - * 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계 - * - * 순수 — DOM·서버를 안 씀(시험이 이 파일만 떼어 Node 로 돌림). - * 값은 모두 시험 계산 답(`POST /calc`)에서 옴 — 여기서 새로 셈하지 않음. - * ========================================================================== */ - -import type { - CalcAnswer, - CalcLine, - ElementBrief, - HoLine, - LogicRow, - NamedFormula, -} from "./M01_MasterData_UI_Logic_Api"; - -export type FlowKind = "입력" | "중간" | "수량" | "단가" | "덧줄" | "비목" | "계"; - -/** 흐름 상자 하나 — 누르면 `detail`(값과 출처) */ -export interface FlowBox { - /** 다시 그려도 같은 id — 펼친 상자를 그대로 둠 */ - id: string; - kind: FlowKind; - label: string; - value: string; - note?: string; - detail: [string, string][]; - /** 로직이 로직을 부르는 줄 — 그 로직 키(펼치기) */ - logic?: string; - /** 비목 — 상자가 많은 로직에서 묶기·접기 */ - cost?: string; - /** 값이 없어 막힌 상자 */ - bad?: boolean; -} - -export interface FlowColumn { - kind: FlowKind; - boxes: FlowBox[]; -} - -export const COSTS = ["노무비", "재료비", "경비"]; - -export function fmt(value: unknown): string { - if (typeof value === "number") return value.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); - if (value === null || value === undefined) return ""; - return typeof value === "object" ? JSON.stringify(value) : String(value); -} - -/** 로직 줄의 결과가 돈인지 — 돈이면 호표·비목·계, 아니면 결과 식 하나 */ -export const isMoney = (row: LogicRow): boolean => - !("결과" in row) && (row.결과단위 ?? "").startsWith("원"); - -/** `로직(GC000268, 기계='…')` 에서 부르는 로직 키 */ -export function logicRef(element: string): string | undefined { - return /로직\(\s*([A-Z]{1,2}\w+)/.exec(element)?.[1]; -} - -const keep = (pairs: [string, unknown][]): [string, string][] => - pairs.filter(([, v]) => v !== undefined && v !== null && v !== "").map(([k, v]) => [k, fmt(v)]); - -/** 파일에 있으나 화면 틀에 없는 칸(입력·호표의 출처·비고) */ -const extra = (item: object, name: string): string | undefined => - (item as Record)[name]; - -export function buildFlow( - row: LogicRow, - answer: CalcAnswer | null, - prices: Record, - values: Record, -): FlowColumn[] { - const ok = answer?.ok ? answer : null; - const lines = ok?.lines ?? []; - const middle = ok?.middle ?? {}; - const columns: FlowColumn[] = [inputColumn(row, values)]; - const mid = middleColumn(row, middle); - if (mid.boxes.length) columns.push(mid); - if (!isMoney(row)) { - columns.push(resultColumn(row, ok?.result)); - return columns; - } - const ho = row.호표 ?? []; - columns.push(qtyColumn(ho, lines), priceColumn(ho, lines, prices)); - const plus = extraColumn(row.덧줄 ?? [], lines, ho.length); - if (plus.boxes.length) columns.push(plus); - const sums = ok?.sums ?? null; - columns.push(costColumn(sums, lines), totalColumn(row, sums)); - return columns; -} - -function inputColumn(row: LogicRow, values: Record): FlowColumn { - const boxes = (row.입력 ?? []).map((spec, i) => ({ - id: `입력:${i}`, - kind: "입력" as const, - label: spec.이름, - value: values[spec.이름] ?? "", - note: spec.단위, - detail: keep([ - ["단위", spec.단위], - ["고르기", (spec.고르기 ?? []).join(" · ")], - ["범위", spec.범위 ? `${spec.범위[0]} ∼ ${spec.범위[1]}` : ""], - ["출처", extra(spec, "출처")], - ]), - })); - return { kind: "입력", boxes }; -} - -function middleColumn(row: LogicRow, middle: Record): FlowColumn { - const boxes = (row.중간 ?? []).map((step, i) => ({ - id: `중간:${i}`, - kind: "중간" as const, - label: step.이름, - value: fmt(middle[step.이름]), - note: step.식.startsWith("찾기(") ? "표 찾기" : "식", - detail: keep([ - ["식", step.식], - ["출처", step.출처], - ]), - })); - return { kind: "중간", boxes }; -} - -function qtyColumn(ho: HoLine[], lines: CalcLine[]): FlowColumn { - const boxes = ho.map((item, i) => ({ - id: `수량:${i}`, - kind: "수량" as const, - label: item.이름 || item.요소, - value: lines[i] ? fmt(lines[i].수량) : "", - note: item.단위, - cost: item.비목, - detail: keep([ - ["식", item.수량], - ["종류", item.종류], - ["단위", item.단위], - ["비고", extra(item, "비고")], - ]), - })); - return { kind: "수량", boxes }; -} - -function priceColumn( - ho: HoLine[], - lines: CalcLine[], - prices: Record, -): FlowColumn { - const boxes = ho.map((item, i) => { - const line = lines[i]; - const brief = prices[item.요소]; - const price = line ? line.단가 : brief?.값; - return { - id: `단가:${i}`, - kind: "단가" as const, - label: item.이름 || item.요소, - value: line ? fmt(line.금액) : "", - note: price === undefined || price === null ? "단가 —" : `× ${fmt(price)}`, - cost: item.비목, - logic: logicRef(item.요소), - bad: !line && brief === null, - detail: keep([ - ["요소", item.요소], - ["단가", price], - ["금액", line?.금액], - ["비목", item.비목 ?? Object.keys(line?.비목 ?? {}).join(" · ")], - ["출처", line?.출처 ?? [brief?.이름, brief?.규격].filter(Boolean).join(" ")], - ]), - }; - }); - return { kind: "단가", boxes }; -} - -function extraColumn(list: NamedFormula[], lines: CalcLine[], from: number): FlowColumn { - const boxes = list.map((item, i) => ({ - id: `덧줄:${i}`, - kind: "덧줄" as const, - label: item.이름, - value: fmt(lines[from + i]?.금액), - note: item.비목, - cost: item.비목, - detail: keep([ - ["식", item.식], - ["비목", item.비목], - ["출처", item.출처], - ]), - })); - return { kind: "덧줄", boxes }; -} - -function costColumn(sums: Record | null, lines: CalcLine[]): FlowColumn { - const boxes = COSTS.map((cost) => ({ - id: `비목:${cost}`, - kind: "비목" as const, - label: cost, - value: fmt(sums?.[cost]), - cost, - detail: lines - .filter((line) => line.비목?.[cost] !== undefined) - .map((line) => [line.이름, fmt(line.비목[cost])] as [string, string]), - })); - return { kind: "비목", boxes }; -} - -function totalColumn(row: LogicRow, sums: Record | null): FlowColumn { - return { - kind: "계", - boxes: [ - { - id: "계", - kind: "계", - label: row.결과단위 || "계", - value: fmt(sums?.계), - detail: keep([ - ...COSTS.map((c) => [c, sums?.[c]] as [string, unknown]), - ["끝수", row.끝수], - ["출처", row.출처], - ]), - }, - ], - }; -} - -function resultColumn(row: LogicRow, result: unknown): FlowColumn { - return { - kind: "계", - boxes: [ - { - id: "계", - kind: "계", - label: row.결과단위 || "결과", - value: fmt(result), - detail: keep([ - ["식", row.결과?.식], - ["출처", row.출처], - ]), - }, - ], - }; -} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_CView_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_CView_Style.css deleted file mode 100644 index e0a468ce..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_CView_Style.css +++ /dev/null @@ -1,182 +0,0 @@ -/* M01 로직 테스트 — 방식 C(흐름 그림). 왼쪽에서 오른쪽으로 칸 · 칸마다 상자 */ -.m01v [hidden] { - display: none !important; -} - -.m01v { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-12); - box-sizing: border-box; - color: var(--color-text-body); - font-size: var(--text-body-sm); -} - -.m01v__head { - display: flex; - align-items: baseline; - gap: var(--spacing-8); - flex-wrap: wrap; -} - -.m01v__head h3 { - margin: 0; - font-size: 16px; - color: var(--color-text); -} - -.m01v__flow { - display: flex; - align-items: flex-start; - gap: var(--spacing-4); - overflow-x: auto; - padding-bottom: var(--spacing-8); -} - -/* 값 칸은 다시 그려도 설계 값 칸은 그대로 — 그래서 한 겹 더 있음 */ -.m01v__rest { - display: contents; -} - -.m01v__col { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - flex: 0 0 auto; - width: 170px; - min-width: 0; -} - -.m01v__col--input { - width: 200px; -} - -.m01v__col-head { - margin: 0; - padding-bottom: 2px; - border-bottom: 1px solid var(--color-border); - color: var(--color-text-muted); - font-size: var(--text-caption); - font-weight: 600; -} - -.m01v__arrow { - align-self: center; - padding-top: 28px; - color: var(--color-text-muted); - font-size: 18px; -} - -.m01v__box, -.m01v__group { - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface); -} - -.m01v__group { - border-style: dashed; - background: none; -} - -.m01v__group > summary { - padding: 2px 6px; - color: var(--color-text-muted); - font-size: var(--text-caption); - cursor: pointer; -} - -.m01v__group > .m01v__box { - margin: 2px; -} - -.m01v__box > summary { - display: flex; - flex-direction: column; - gap: 1px; - padding: 4px 6px; - cursor: pointer; -} - -.m01v__box--bad { - border-color: var(--color-danger); -} - -.m01v__label { - color: var(--color-text); - overflow-wrap: anywhere; -} - -.m01v__value { - font-variant-numeric: tabular-nums; - font-weight: 600; -} - -.m01v__note, -.m01v__muted { - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -.m01v__bad { - margin: 0; - color: var(--color-danger); - font-size: var(--text-caption); -} - -.m01v__detail { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 1px var(--spacing-4); - margin: 0; - padding: 4px 6px; - border-top: 1px solid var(--color-border); - font-size: var(--text-caption); -} - -.m01v__detail dt { - color: var(--color-text-muted); -} - -.m01v__detail dd { - margin: 0; - overflow-wrap: anywhere; -} - -.m01v__sub { - padding: 0 6px 4px; - font-size: var(--text-caption); -} - -.m01v__sub > summary { - color: var(--color-text-muted); - cursor: pointer; -} - -.m01v__sub-body ul { - margin: 2px 0; - padding-left: 14px; -} - -.m01v__field { - display: flex; - flex-direction: column; - gap: 2px; - padding: 4px 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface); -} - -.m01v__input { - box-sizing: border-box; - width: 100%; - min-width: 0; - padding: 3px 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface); - color: var(--color-text); - font-size: var(--text-body-sm); -} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Flow.ts b/M01_MasterData/M01_MasterData_UI_Logic_Flow.ts deleted file mode 100644 index 8f31326e..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_Flow.ts +++ /dev/null @@ -1,536 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_Logic_Flow.ts - * 일위대가 로직 기본 보기 — 흐름 그림. 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계 - * - * 상자를 누르면 값·출처 · 원문 표 미리보기 · 쉬운 말 풀이(`…_Logic_Note.ts`) · 부르는 로직 펼치기. - * [고치기] 를 켜면 상자 안에서 바로 고치고 줄을 더하거나 지움 — 자체 로직만(정본은 「본떠 만들기」 뒤). - * 값은 모두 시험 계산(`POST /calc`)에서 옴 — 여기서 새로 셈하지 않음. - * 뼈대(어느 상자가 어디에) = `M01_MasterData_UI_Logic_Flow_Model.ts`. - * ========================================================================== */ - -import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; -import { - fetchLogic, - runCalc, - type CalcAnswer, - type HoLine, - type LogicOne, - type LogicRow, - type NamedFormula, -} from "./M01_MasterData_UI_Logic_Api"; -import { - buildFlow, - isMoney, - type FlowBox, - type FlowColumn, - type FlowKind, -} from "./M01_MasterData_UI_Logic_Flow_Model"; -import { buildNote } from "./M01_MasterData_UI_Logic_Note"; -import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick"; -import { guideLines, plainReason, tx, type TextKey } from "./M01_MasterData_UI_Logic_Text"; -import "./M01_MasterData_UI_Logic_Flow_Style.css"; - -const title = (kind: FlowKind): string => tx(`Col_${kind}` as TextKey); - -/** - * 자체 로직인지 — 자체 파일(`로직_자체.json`)이거나 키가 `GX…`(계약 5장). - * 아직 저장 안 한 새 줄(키 "")도 고칠 수 있음 · 정본은 「본떠 만들기」 뒤에야 고침. - */ -export const isOwnLogic = (file: string, row: LogicRow): boolean => - file.startsWith("로직_자체") || (row.키 ?? "").startsWith("GX") || (row.키 ?? "") === ""; - -export interface FlowContext { - one: LogicOne; - /** 지금 보는 줄 — 고친 것이 있으면 그 줄(`one.logic` 과 다른 객체일 수 있음) */ - row: LogicRow; - /** 상자에서 고칠 수 있는지 — 자체 로직만 */ - editable: boolean; - /** 고친 뒤 — 화면이 캐시에 담음 */ - onChange?: () => void; - /** 「본떠 만들기」 — 정본 로직일 때만 */ - onCopy?: () => void; -} - -/** 흐름 그림 하나를 `host` 에 그림 — 로직 키 하나(어느 로직이 와도 돎 · 읽기 전용) */ -export function render(host: HTMLElement, logicKey: string): void { - host.replaceChildren(el("p", { className: "m01c__muted", text: tx("Load_Failed") })); - void fetchLogic(logicKey) - .then((one) => mountFlow(host, { one, row: one.logic, editable: false })) - .catch((error: unknown) => { - host.replaceChildren( - el("p", { - className: "m01c__bad", - text: error instanceof Error ? error.message : tx("Load_Failed"), - }), - ); - }); -} - -export function mountFlow(host: HTMLElement, ctx: FlowContext): void { - const one = ctx.one; - const row = ctx.row; - const values: Record = {}; - // 고르기 칸은 첫 값으로 시작 — 설계자가 바로 흐름을 보게(수 칸은 비워 둠) - for (const spec of row.입력 ?? []) { - if (spec.고르기?.length) values[spec.이름] = String(spec.고르기[0]); - } - const swaps: Swaps = new Map(); - const picker = pickPanel({ ...one, logic: row }, swaps, () => run()); - const open = new Set(); - const subs = new Map(); - let answer: CalcAnswer | null = null; - let editing = false; - let timer: number | undefined; - - const rest = el("div", { className: "m01c__rest" }); - const stopped = el("p", { className: "m01c__bad", attrs: { hidden: "" } }); - - /** 고친 줄 — 화면에 알리고 계산을 다시(값 칸은 글쇠 자리를 지켜 늦춰 그림) */ - const touched = (): void => { - ctx.onChange?.(); - later(); - }; - - /* ── 상자 ─────────────────────────────────────────────────────────── */ - - const boxView = (box: FlowBox): HTMLElement => { - const node = el("details", { - className: `m01c__box${box.bad ? " m01c__box--bad" : ""}`, - children: [ - el("summary", { - children: [ - el("span", { className: "m01c__label", text: box.label }), - el("span", { className: "m01c__value", text: box.value }), - ...(box.note ? [el("span", { className: "m01c__note", text: box.note })] : []), - ], - }), - el("dl", { - className: "m01c__detail", - children: box.detail.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: v })]), - }), - ], - }); - if (open.has(box.id)) node.open = true; - node.addEventListener("toggle", () => (node.open ? open.add(box.id) : open.delete(box.id))); - if (editing) node.append(...editView(box.id)); - const expr = formulaOf(box); - if (expr) node.append(noteView(expr)); - if (box.logic) node.append(subView(box.logic)); - return node; - }; - - /** 이 상자가 풀어 볼 식 — 호표 줄의 수량 · 중간 값·덧줄의 식 */ - const formulaOf = (box: FlowBox): string => { - const [kind, at] = box.id.split(":"); - const i = Number(at); - if (kind === "수량") return (row.호표 ?? [])[i]?.수량 ?? ""; - if (kind === "중간") return (row.중간 ?? [])[i]?.식 ?? ""; - if (kind === "덧줄") return (row.덧줄 ?? [])[i]?.식 ?? ""; - return ""; - }; - - /** 쉬운 말 풀이 + 원문 표 미리보기 — 펼칠 때 한 번만 채움(표는 서버에서 읽음) */ - const noteView = (expr: string): HTMLElement => { - const body = el("div", { className: "m01c__note-body" }); - const node = el("details", { - className: "m01c__sub", - children: [el("summary", { text: tx("Note_Plain") }), body], - }); - node.addEventListener("toggle", () => { - if (!node.open || body.childElementCount) return; - buildNote(body, { - expr, - middles: row.중간 ?? [], - ctx: { values, middle: answer?.ok ? (answer.middle ?? {}) : {} }, - }); - }); - return node; - }; - - /** 로직이 부르는 로직 — 펼칠 때 한 번 읽어 그 호표를 상자 안에 보임 */ - const subView = (key: string): HTMLElement => { - const body = el("div", { className: "m01c__sub-body" }); - const node = el("details", { - className: "m01c__sub", - children: [el("summary", { text: `${tx("Flow_Sub")} · ${key}` }), body], - }); - const fill = (sub: LogicRow): void => { - body.replaceChildren( - el("p", { className: "m01c__muted", text: `${sub.이름} (${sub.결과단위}) · ${sub.출처}` }), - el("p", { className: "m01c__muted", text: tx("Flow_SubLines") }), - el("ul", { - children: (sub.호표 ?? []).map((item) => - el("li", { text: `${item.이름 ?? item.요소} · ${item.수량}` }), - ), - }), - ); - }; - node.addEventListener("toggle", () => { - if (!node.open || body.childElementCount) return; - const had = subs.get(key); - if (had) { - fill(had); - return; - } - body.replaceChildren(el("p", { className: "m01c__muted", text: tx("Note_Searching") })); - void fetchLogic(key) - .then((deep) => { - subs.set(key, deep.logic); - fill(deep.logic); - }) - .catch(() => - body.replaceChildren(el("p", { className: "m01c__bad", text: tx("Load_Failed") })), - ); - }); - return node; - }; - - /* ── 상자에서 고치기 ──────────────────────────────────────────────── */ - - /** 글 칸 하나 — 고치면 그 자리에서 줄을 바꾸고 계산만 다시(다시 그려도 글쇠 자리가 남게 `data-at`) */ - const editBox = (at: string, label: string, value: string, set: (v: string) => void) => { - const input = el("input", { - className: "m01c__input", - attrs: { type: "text", "data-at": at }, - }); - input.value = value; - input.addEventListener("input", () => { - set(input.value); - touched(); - }); - return el("label", { - className: "m01c__edit", - children: [el("span", { className: "m01c__muted", text: label }), input], - }); - }; - - const dropButton = (onClick: () => void): HTMLElement => - createButton({ - label: tx("DeleteRow"), - variant: "ghost", - onClick: () => { - onClick(); - ctx.onChange?.(); - draw(); - }, - }); - - const editView = (id: string): HTMLElement[] => { - const [kind, at] = id.split(":"); - const i = Number(at); - if (kind === "입력") { - const spec = (row.입력 ?? [])[i]; - if (!spec) return []; - return [ - el("div", { - className: "m01c__edits", - children: [ - editBox(id + ":1", tx("Head_Name"), spec.이름, (v) => (spec.이름 = v)), - editBox(id + ":2", tx("Inputs_Unit"), spec.단위 ?? "", (v) => { - if (v.trim()) spec.단위 = v; - else delete spec.단위; - }), - editBox(id + ":3", tx("Inputs_Choices"), (spec.고르기 ?? []).join(", "), (v) => { - const items = v - .split(",") - .map((x) => x.trim()) - .filter(Boolean); - // 모두 수면 수로 — 글 "35" 로 적으면 표의 35 와 안 맞음 - if (items.length) - spec.고르기 = items.every((x) => !Number.isNaN(Number(x))) - ? items.map(Number) - : items; - else delete spec.고르기; - }), - dropButton(() => (row.입력 ?? []).splice(i, 1)), - ], - }), - ]; - } - if (kind === "수량") { - const line = (row.호표 ?? [])[i]; - if (!line) return []; - return [ - el("div", { - className: "m01c__edits", - children: [ - editBox(id + ":1", tx("Ho_Element"), line.요소, (v) => (line.요소 = v)), - editBox(id + ":2", tx("Ho_Name"), line.이름 ?? "", (v) => (line.이름 = v)), - editBox(id + ":3", tx("Ho_Unit"), line.단위 ?? "", (v) => (line.단위 = v)), - editBox(id + ":4", tx("Edit_Qty"), line.수량, (v) => (line.수량 = v)), - dropButton(() => (row.호표 ?? []).splice(i, 1)), - ], - }), - ]; - } - const list: NamedFormula[] | undefined = - kind === "중간" ? row.중간 : kind === "덧줄" ? row.덧줄 : undefined; - if (!list?.[i]) return []; - const item = list[i]; - return [ - el("div", { - className: "m01c__edits", - children: [ - editBox(id + ":1", tx("Head_Name"), item.이름, (v) => (item.이름 = v)), - editBox(id + ":2", tx("Formula"), item.식, (v) => (item.식 = v)), - dropButton(() => list.splice(i, 1)), - ], - }), - ]; - }; - - /** 칸 아래 「줄 더하기」 — 고치기를 켰을 때만 */ - const addButton = (kind: FlowKind): HTMLElement | null => { - if (!editing) return null; - const add = (label: string, push: () => void): HTMLElement => - createButton({ - label, - variant: "ghost", - onClick: () => { - push(); - ctx.onChange?.(); - draw(); - }, - }); - if (kind === "입력") - return add(tx("Edit_AddInput"), () => void (row.입력 ??= []).push({ 이름: "" })); - if (kind === "중간") - return add(tx("Edit_AddMiddle"), () => void (row.중간 ??= []).push({ 이름: "", 식: "" })); - if (kind === "수량") - return add( - tx("Edit_AddHo"), - () => - void (row.호표 ??= []).push({ - 종류: "인력", - 요소: "", - 이름: "", - 단위: "인", - 수량: "", - 비목: "노무비", - } as HoLine), - ); - if (kind === "덧줄") - return add( - tx("Edit_AddExtra"), - () => void (row.덧줄 ??= []).push({ 이름: "", 식: "", 비목: "재료비", 출처: "" }), - ); - return null; - }; - - /* ── 칸·다시 그리기 ───────────────────────────────────────────────── */ - - const columnView = (column: FlowColumn): HTMLElement => { - const head = el("h4", { className: "m01c__col-head", text: title(column.kind) }); - // 상자가 많으면 비목으로 묶어 접음 — 줄이 많은 로직도 한 화면에(비목 없는 줄은 「그 밖」) - const costOf = (box: FlowBox): string => box.cost ?? tx("Flow_Other"); - const costs = [...new Set(column.boxes.map(costOf))]; - const body = - column.boxes.length > 6 && costs.length > 1 && !editing - ? costs.map((cost) => - el("details", { - className: "m01c__group", - attrs: { open: "" }, - children: [ - el("summary", { text: cost }), - ...column.boxes.filter((b) => costOf(b) === cost).map(boxView), - ], - }), - ) - : column.boxes.map(boxView); - const add = addButton(column.kind); - return el("section", { - className: "m01c__col", - children: [head, ...body, ...(add ? [add] : [])], - }); - }; - - /** 다시 그려도 적던 칸으로 돌아감 — `data-at` 이 같은 칸에 글쇠와 자리를 되돌림 */ - const keepFocus = (paint: () => void): void => { - const was = document.activeElement as HTMLInputElement | null; - const at = was?.dataset?.at; - const start = at ? was?.selectionStart : null; - paint(); - if (!at) return; - const next = host.querySelector(`[data-at="${CSS.escape(at)}"]`); - if (!next) return; - next.focus(); - if (start !== null && start !== undefined) next.setSelectionRange(start, start); - }; - - /** 고치기를 켜면 비어 있어 빠진 칸(표 찾기·덧줄)도 세움 — 거기에 줄을 더할 수 있게 */ - const withEmpty = (columns: FlowColumn[]): FlowColumn[] => { - if (!editing || !isMoney(row)) return columns; - const out = [...columns]; - const put = (kind: FlowKind, before: FlowKind): void => { - if (out.some((c) => c.kind === kind)) return; - const at = out.findIndex((c) => c.kind === before); - out.splice(at < 0 ? out.length : at, 0, { kind, boxes: [] }); - }; - put("중간", "수량"); - put("덧줄", "비목"); - return out; - }; - - const redraw = (): void => { - keepFocus(() => { - const columns = withEmpty(buildFlow(row, answer, one.prices, values)); - const nodes: HTMLElement[] = []; - for (const column of columns.slice(1)) { - nodes.push(el("span", { className: "m01c__arrow", text: "›" }), columnView(column)); - } - rest.replaceChildren(...nodes); - }); - const reason = answer && !answer.ok ? answer.reason : ""; - stopped.textContent = reason ? `${tx("Calc_Stopped")} — ${plainReason(reason)}` : ""; - stopped.hidden = !reason; - }; - - const run = (): void => { - const inputs: Record = {}; - for (const spec of row.입력 ?? []) { - const raw = (values[spec.이름] ?? "").trim(); - if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤 - const option = spec.고르기?.find((o) => String(o) === raw); - inputs[spec.이름] = - option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw); - } - // 고친 줄·바꿔 본 재료는 그 줄을 같이 보냄 — 파일에 안 씀 - const swapped = swappedRow(row, swaps); - const draft = swapped ?? (ctx.editable ? row : undefined); - void runCalc({ key: row.키, inputs, ...(draft ? { row: draft, file: one.file } : {}) }) - .then((got) => { - answer = got; - redraw(); - }) - .catch((error: unknown) => { - showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); - }); - }; - - const later = (): void => { - window.clearTimeout(timer); - timer = window.setTimeout(run, 250); - }; - - /* ── 틀 ───────────────────────────────────────────────────────────── */ - - const bar = el("div", { className: "m01c__edit-bar" }); - const fillBar = (): void => { - if (ctx.editable) { - bar.replaceChildren( - createButton({ - label: editing ? tx("Edit_Off") : tx("Edit_On"), - variant: editing ? "filled" : "ghost", - onClick: () => { - editing = !editing; - draw(); - }, - }), - ); - return; - } - bar.replaceChildren( - el("span", { className: "m01c__muted", text: tx("Edit_OwnOnly") }), - createButton({ - label: tx("Edit_Copy"), - variant: "ghost", - onClick: () => (ctx.onCopy ? ctx.onCopy() : showToast(tx("Edit_CopySoon"), "info")), - }), - ); - }; - - const draw = (): void => { - fillBar(); - host.replaceChildren( - el("div", { - className: "m01c", - children: [ - el("div", { - className: "m01c__head", - children: [ - el("h3", { text: `${row.원문번호} ${row.이름}` }), - el("span", { className: "m01c__muted", text: `${row.결과단위} · ${row.출처}` }), - bar, - ], - }), - ...(one.reasons.length - ? [el("p", { className: "m01c__bad", text: one.reasons.join(" · ") })] - : []), - el("ul", { - className: "m01c__muted", - children: guideLines().map((t) => el("li", { text: t })), - }), - stopped, - ...(hasPickable(row) - ? [ - el("details", { - attrs: { open: "" }, - children: [el("summary", { text: tx("Mat_Title") }), picker], - }), - ] - : []), - el("div", { className: "m01c__flow", children: [inputColumn(), rest] }), - ], - }), - ); - redraw(); - }; - - /** - * 설계 값 칸 — `draw()` 에서만 다시 세움(계산 때마다 다시 그리는 오른쪽과 달리 - * 적던 값과 글쇠 자리가 안 날아감). 고치기를 켜면 칸 이름·단위·고르기도 여기서 고침. - */ - const inputColumn = (): HTMLElement => { - const fields = (row.입력 ?? []).map((spec, i) => { - let control: HTMLElement; - if (spec.고르기?.length) { - control = createSelectField({ - options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), - value: values[spec.이름] ?? "", - compact: true, - onChange: (v) => { - values[spec.이름] = v; - later(); - }, - }).root; - } else { - const box = el("input", { - className: "m01c__input", - attrs: { type: "text", inputmode: "decimal" }, - }); - box.placeholder = spec.범위 ? `${spec.범위[0]} ∼ ${spec.범위[1]}` : tx("Enter_Value"); - box.value = values[spec.이름] ?? ""; - box.addEventListener("input", () => { - values[spec.이름] = box.value; - later(); - }); - control = box; - } - return el("label", { - className: "m01c__field", - children: [ - el("span", { - className: "m01c__label", - text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, - }), - control, - ...(editing ? editView(`입력:${i}`) : []), - ], - }); - }); - const add = addButton("입력"); - return el("section", { - className: "m01c__col m01c__col--input", - children: [ - el("h4", { className: "m01c__col-head", text: title("입력") }), - ...(fields.length - ? fields - : [el("p", { className: "m01c__muted", text: tx("Flow_NoInputs") })]), - ...(add ? [add] : []), - ], - }); - }; - - draw(); - run(); -} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Flow_Model.ts b/M01_MasterData/M01_MasterData_UI_Logic_Flow_Model.ts deleted file mode 100644 index b2d6e088..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_Flow_Model.ts +++ /dev/null @@ -1,238 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_Logic_Flow_Model.ts - * 방식 C(흐름 그림)의 뼈대 — 로직 한 줄 + 시험 계산 답 → 왼쪽에서 오른쪽 칸의 상자. - * 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계 - * - * 순수 — DOM·서버를 안 씀(시험이 이 파일만 떼어 Node 로 돌림). - * 값은 모두 시험 계산 답(`POST /calc`)에서 옴 — 여기서 새로 셈하지 않음. - * ========================================================================== */ - -import type { - CalcAnswer, - CalcLine, - ElementBrief, - HoLine, - LogicRow, - NamedFormula, -} from "./M01_MasterData_UI_Logic_Api"; - -export type FlowKind = "입력" | "중간" | "수량" | "단가" | "덧줄" | "비목" | "계"; - -/** 흐름 상자 하나 — 누르면 `detail`(값과 출처) */ -export interface FlowBox { - /** 다시 그려도 같은 id — 펼친 상자를 그대로 둠 */ - id: string; - kind: FlowKind; - label: string; - value: string; - note?: string; - detail: [string, string][]; - /** 로직이 로직을 부르는 줄 — 그 로직 키(펼치기) */ - logic?: string; - /** 비목 — 상자가 많은 로직에서 묶기·접기 */ - cost?: string; - /** 값이 없어 막힌 상자 */ - bad?: boolean; -} - -export interface FlowColumn { - kind: FlowKind; - boxes: FlowBox[]; -} - -export const COSTS = ["노무비", "재료비", "경비"]; - -export function fmt(value: unknown): string { - if (typeof value === "number") return value.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); - if (value === null || value === undefined) return ""; - return typeof value === "object" ? JSON.stringify(value) : String(value); -} - -/** 로직 줄의 결과가 돈인지 — 돈이면 호표·비목·계, 아니면 결과 식 하나 */ -export const isMoney = (row: LogicRow): boolean => - !("결과" in row) && (row.결과단위 ?? "").startsWith("원"); - -/** `로직(GC000268, 기계='…')` 에서 부르는 로직 키 */ -export function logicRef(element: string): string | undefined { - return /로직\(\s*([A-Z]{1,2}\w+)/.exec(element)?.[1]; -} - -const keep = (pairs: [string, unknown][]): [string, string][] => - pairs.filter(([, v]) => v !== undefined && v !== null && v !== "").map(([k, v]) => [k, fmt(v)]); - -/** 파일에 있으나 화면 틀에 없는 칸(입력·호표의 출처·비고) */ -const extra = (item: object, name: string): string | undefined => - (item as Record)[name]; - -export function buildFlow( - row: LogicRow, - answer: CalcAnswer | null, - prices: Record, - values: Record, -): FlowColumn[] { - const ok = answer?.ok ? answer : null; - const lines = ok?.lines ?? []; - const middle = ok?.middle ?? {}; - const columns: FlowColumn[] = [inputColumn(row, values)]; - const mid = middleColumn(row, middle); - if (mid.boxes.length) columns.push(mid); - if (!isMoney(row)) { - columns.push(resultColumn(row, ok?.result)); - return columns; - } - const ho = row.호표 ?? []; - columns.push(qtyColumn(ho, lines), priceColumn(ho, lines, prices)); - const plus = extraColumn(row.덧줄 ?? [], lines, ho.length); - if (plus.boxes.length) columns.push(plus); - const sums = ok?.sums ?? null; - columns.push(costColumn(sums, lines), totalColumn(row, sums)); - return columns; -} - -function inputColumn(row: LogicRow, values: Record): FlowColumn { - const boxes = (row.입력 ?? []).map((spec, i) => ({ - id: `입력:${i}`, - kind: "입력" as const, - label: spec.이름, - value: values[spec.이름] ?? "", - note: spec.단위, - detail: keep([ - ["단위", spec.단위], - ["고르기", (spec.고르기 ?? []).join(" · ")], - ["범위", spec.범위 ? `${spec.범위[0]} ∼ ${spec.범위[1]}` : ""], - ["출처", extra(spec, "출처")], - ]), - })); - return { kind: "입력", boxes }; -} - -function middleColumn(row: LogicRow, middle: Record): FlowColumn { - const boxes = (row.중간 ?? []).map((step, i) => ({ - id: `중간:${i}`, - kind: "중간" as const, - label: step.이름, - value: fmt(middle[step.이름]), - note: step.식.startsWith("찾기(") ? "표 찾기" : "식", - detail: keep([ - ["식", step.식], - ["출처", step.출처], - ]), - })); - return { kind: "중간", boxes }; -} - -function qtyColumn(ho: HoLine[], lines: CalcLine[]): FlowColumn { - const boxes = ho.map((item, i) => ({ - id: `수량:${i}`, - kind: "수량" as const, - label: item.이름 || item.요소, - value: lines[i] ? fmt(lines[i].수량) : "", - note: item.단위, - cost: item.비목, - detail: keep([ - ["식", item.수량], - ["종류", item.종류], - ["단위", item.단위], - ["비고", extra(item, "비고")], - ]), - })); - return { kind: "수량", boxes }; -} - -function priceColumn( - ho: HoLine[], - lines: CalcLine[], - prices: Record, -): FlowColumn { - const boxes = ho.map((item, i) => { - const line = lines[i]; - const brief = prices[item.요소]; - const price = line ? line.단가 : brief?.값; - return { - id: `단가:${i}`, - kind: "단가" as const, - label: item.이름 || item.요소, - value: line ? fmt(line.금액) : "", - note: price === undefined || price === null ? "단가 —" : `× ${fmt(price)}`, - cost: item.비목, - logic: logicRef(item.요소), - bad: !line && brief === null, - detail: keep([ - ["요소", item.요소], - ["단가", price], - ["금액", line?.금액], - ["비목", item.비목 ?? Object.keys(line?.비목 ?? {}).join(" · ")], - ["출처", line?.출처 ?? [brief?.이름, brief?.규격].filter(Boolean).join(" ")], - ]), - }; - }); - return { kind: "단가", boxes }; -} - -function extraColumn(list: NamedFormula[], lines: CalcLine[], from: number): FlowColumn { - const boxes = list.map((item, i) => ({ - id: `덧줄:${i}`, - kind: "덧줄" as const, - label: item.이름, - value: fmt(lines[from + i]?.금액), - note: item.비목, - cost: item.비목, - detail: keep([ - ["식", item.식], - ["비목", item.비목], - ["출처", item.출처], - ]), - })); - return { kind: "덧줄", boxes }; -} - -function costColumn(sums: Record | null, lines: CalcLine[]): FlowColumn { - const boxes = COSTS.map((cost) => ({ - id: `비목:${cost}`, - kind: "비목" as const, - label: cost, - value: fmt(sums?.[cost]), - cost, - detail: lines - .filter((line) => line.비목?.[cost] !== undefined) - .map((line) => [line.이름, fmt(line.비목[cost])] as [string, string]), - })); - return { kind: "비목", boxes }; -} - -function totalColumn(row: LogicRow, sums: Record | null): FlowColumn { - return { - kind: "계", - boxes: [ - { - id: "계", - kind: "계", - label: row.결과단위 || "계", - value: fmt(sums?.계), - detail: keep([ - ...COSTS.map((c) => [c, sums?.[c]] as [string, unknown]), - ["끝수", row.끝수], - ["출처", row.출처], - ]), - }, - ], - }; -} - -function resultColumn(row: LogicRow, result: unknown): FlowColumn { - return { - kind: "계", - boxes: [ - { - id: "계", - kind: "계", - label: row.결과단위 || "결과", - value: fmt(result), - detail: keep([ - ["식", row.결과?.식], - ["출처", row.출처], - ]), - }, - ], - }; -} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Flow_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_Flow_Style.css deleted file mode 100644 index 4a43d7fa..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_Flow_Style.css +++ /dev/null @@ -1,267 +0,0 @@ -/* M01 일위대가 로직 — 흐름 그림(기본 보기). 왼쪽에서 오른쪽으로 칸 · 칸마다 상자 */ -.m01c [hidden] { - display: none !important; -} - -.m01c { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-12); - box-sizing: border-box; - color: var(--color-text-body); - font-size: var(--text-body-sm); -} - -.m01c__head { - display: flex; - align-items: baseline; - gap: var(--spacing-8); - flex-wrap: wrap; -} - -.m01c__head h3 { - margin: 0; - font-size: 16px; - color: var(--color-text); -} - -.m01c__flow { - display: flex; - align-items: flex-start; - gap: var(--spacing-4); - overflow-x: auto; - padding-bottom: var(--spacing-8); -} - -/* 값 칸은 다시 그려도 설계 값 칸은 그대로 — 그래서 한 겹 더 있음 */ -.m01c__rest { - display: contents; -} - -.m01c__col { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - flex: 0 0 auto; - width: 170px; - min-width: 0; -} - -.m01c__col--input { - width: 200px; -} - -.m01c__col-head { - margin: 0; - padding-bottom: 2px; - border-bottom: 1px solid var(--color-border); - color: var(--color-text-muted); - font-size: var(--text-caption); - font-weight: 600; -} - -.m01c__arrow { - align-self: center; - padding-top: 28px; - color: var(--color-text-muted); - font-size: 18px; -} - -.m01c__box, -.m01c__group { - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface); -} - -.m01c__group { - border-style: dashed; - background: none; -} - -.m01c__group > summary { - padding: 2px 6px; - color: var(--color-text-muted); - font-size: var(--text-caption); - cursor: pointer; -} - -.m01c__group > .m01c__box { - margin: 2px; -} - -.m01c__box > summary { - display: flex; - flex-direction: column; - gap: 1px; - padding: 4px 6px; - cursor: pointer; -} - -.m01c__box--bad { - border-color: var(--color-danger); -} - -.m01c__label { - color: var(--color-text); - overflow-wrap: anywhere; -} - -.m01c__value { - font-variant-numeric: tabular-nums; - font-weight: 600; -} - -.m01c__note, -.m01c__muted { - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -.m01c__bad { - margin: 0; - color: var(--color-danger); - font-size: var(--text-caption); -} - -.m01c__detail { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 1px var(--spacing-4); - margin: 0; - padding: 4px 6px; - border-top: 1px solid var(--color-border); - font-size: var(--text-caption); -} - -.m01c__detail dt { - color: var(--color-text-muted); -} - -.m01c__detail dd { - margin: 0; - overflow-wrap: anywhere; -} - -.m01c__sub { - padding: 0 6px 4px; - font-size: var(--text-caption); -} - -.m01c__sub > summary { - color: var(--color-text-muted); - cursor: pointer; -} - -.m01c__sub-body ul { - margin: 2px 0; - padding-left: 14px; -} - -.m01c__field { - display: flex; - flex-direction: column; - gap: 2px; - padding: 4px 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface); -} - -.m01c__input { - box-sizing: border-box; - width: 100%; - min-width: 0; - padding: 3px 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface); - color: var(--color-text); - font-size: var(--text-body-sm); -} - -/* 상자 속 설명 카드 — 쉬운 말 풀이 · 원문 표 미리보기(`M01_MasterData_UI_Logic_Note.ts`) */ -.m01c__note-body { - padding: 2px 0; -} - -.m01c__plain { - margin: 2px 0; - overflow-wrap: anywhere; -} - -.m01c__tables { - overflow-x: auto; -} - -.m01c__table { - width: 100%; - border-collapse: collapse; - font-variant-numeric: tabular-nums; -} - -.m01c__table th, -.m01c__table td { - padding: 1px 4px; - border-bottom: 1px solid var(--color-border); - text-align: left; - white-space: nowrap; -} - -.m01c__table th { - color: var(--color-text-muted); - font-weight: 500; -} - -.m01c__hit { - background: var(--color-surface); - font-weight: 600; -} - -.m01c__raw pre { - margin: 2px 0; - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -/* 상자에서 고치기 — [고치기] 를 켰을 때만 */ -.m01c__edits { - display: flex; - flex-direction: column; - gap: 2px; - padding: 4px 6px; - border-top: 1px dashed var(--color-border); -} - -.m01c__edit { - display: flex; - flex-direction: column; - gap: 1px; -} - -.m01c__edit-bar { - display: flex; - align-items: center; - gap: var(--spacing-8); - margin-left: auto; -} - -/* 재료·기계 바꿔 고르기 칸 — `M01_MasterData_UI_Logic_Pick.ts` */ -.m01-test__picks { - display: flex; - flex-direction: column; - gap: var(--spacing-12); -} - -.m01-test__pick { - display: flex; - flex-direction: column; - gap: var(--spacing-4); -} - -.m01-test__filters { - display: flex; - gap: var(--spacing-8); - flex-wrap: wrap; -} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts index 6768a0c7..36fd199d 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts @@ -18,7 +18,6 @@ import { } from "@ui/ui_template_elements"; import { ApiError, - copyLogic, fetchLogic, fetchLogicFiles, fetchLogics, @@ -33,10 +32,7 @@ import { } from "./M01_MasterData_UI_Logic_Api"; import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; import { buildEditor } from "./M01_MasterData_UI_Logic_Edit"; -import { render as renderCView } from "./M01_MasterData_UI_Logic_CView"; -import { isOwnLogic, mountFlow } from "./M01_MasterData_UI_Logic_Flow"; import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List"; -import { openLogicWizard } from "./M01_MasterData_UI_Logic_Wizard"; import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import type { SideHandle } from "./M01_MasterData_UI_Side"; import { tx } from "./M01_MasterData_UI_Logic_Text"; @@ -62,15 +58,12 @@ interface Opened extends Draft { values: Record; } -/** 세 컨테이너(옛 호표 화면 · 쉽게 보기·만들기 · 흐름 보기)가 서로의 저장 안 한 것을 덮지 않게 캐시 칸을 가름 */ -export type LogicMode = "classic" | "easy" | "cview"; -const SUFFIX: Record = { classic: "", easy: "_easy", cview: "_cview" }; -const cacheKey = (mode: LogicMode): string => `m01_logic_drafts${SUFFIX[mode]}`; +const cacheKey = "m01_logic_drafts"; const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; -function loadDrafts(mode: LogicMode): Record { +function loadDrafts(): Record { try { - return JSON.parse(sessionStorage.getItem(cacheKey(mode)) ?? "{}") as Record; + return JSON.parse(sessionStorage.getItem(cacheKey) ?? "{}") as Record; } catch { return {}; } @@ -85,20 +78,11 @@ export async function mountM01Logic( host: HTMLElement, side: SideHandle, openKey?: string, - mode: LogicMode = "classic", ): Promise { - let drafts = loadDrafts(mode); + let drafts = loadDrafts(); let files: LogicFile[] = []; let opened: Opened | null = null; let calcInputs = ""; - /** 옛 컨테이너 = 호표 표 + 시험 계산 칸 · 쉽게 보기·만들기 컨테이너 = 흐름 그림 */ - const easy = mode === "easy"; - const view: "flow" | "advanced" | "cview" = easy - ? "flow" - : mode === "cview" - ? "cview" - : "advanced"; - const editor = el("div", { className: "m01-logic__editor" }); const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } }); const calc = el("aside", { className: "m01-logic__calc" }); @@ -111,7 +95,7 @@ export async function mountM01Logic( const persist = (): void => { try { - sessionStorage.setItem(cacheKey(mode), JSON.stringify(drafts)); + sessionStorage.setItem(cacheKey, JSON.stringify(drafts)); } catch { /* 캐시가 막혀도 화면 안의 고친 것은 남음 */ } @@ -146,7 +130,6 @@ export async function mountM01Logic( if (opened.origKey !== null && now === opened.original) delete drafts[opened.id]; else drafts[opened.id] = pick(opened, clone(opened.row)); persist(); - if (view !== "advanced") return; // 흐름 그림은 제 안에서 다시 셈 — 다시 그리면 펼친 상자가 접힘 const inputs = JSON.stringify(opened.row.입력 ?? []); if (inputs !== calcInputs) drawCalc(); }; @@ -160,7 +143,7 @@ export async function mountM01Logic( }); const drawCalc = (): void => { - if (!opened?.row || view !== "advanced") { + if (!opened?.row) { calc.replaceChildren(); return; } @@ -180,12 +163,12 @@ export async function mountM01Logic( }; const drawEditor = (): void => { - // 고른 로직이 없으면 목록 · 있으면 흐름 그림(기본) 또는 고급 = 호표 표 + 시험 계산 + // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산 const listing = !opened; list.root.hidden = !listing || !loaded; waiting.hidden = loaded; editor.hidden = back.hidden = listing; - calc.hidden = listing || view !== "advanced"; + calc.hidden = listing; if (!opened) return; if (!opened.row) { editor.replaceChildren( @@ -194,28 +177,6 @@ export async function mountM01Logic( return; } const current = opened; - const shown = current.row as LogicRow; - if (view === "cview") { - renderCView(editor, current.origKey ?? current.id); - return; - } - if (view === "flow") { - mountFlow(editor, { - one: { - file: current.file, - version: current.version, - logic: shown, - blocked: false, - reasons: current.reasons, - prices: current.prices, - }, - row: shown, - editable: isOwnLogic(current.file, shown), - onChange: touch, - onCopy: () => void onCopy(current), - }); - return; - } buildEditor(editor, { row: current.row as LogicRow, file: current.file, @@ -286,12 +247,8 @@ export async function mountM01Logic( // 왼쪽 「전체」 + 구분 → 상세구분 거름 — 고르면 목록으로 돌아와 그 범위만 filterHost.replaceChildren( side.filter({ - id: easy ? "쉬움|" : mode === "cview" ? "흐름|" : "로직|", - store: easy - ? "m01.filter.로직쉬움" - : mode === "cview" - ? "m01.filter.로직흐름" - : "m01.filter.로직", + id: "로직|", + store: "m01.filter.로직", subs, total: list.total(), subLabel: L("M01_LaborSub"), @@ -306,15 +263,6 @@ export async function mountM01Logic( persist(); }; - /** 「새로 만들기」 모달 — 저장·본떠 저장이 끝나면 그 새 자체 로직을 흐름 그림으로 바로 엶 */ - const openWizard = (): void => - openLogicWizard({ - onSaved: (key) => - void reload() - .then(() => open(logicId("자체", key), "자체", key)) - .catch(failed), - }); - /** 옛 컨테이너의 「새로 만들기」 — 빈 줄 하나를 초안으로 세움(저장 때 서버가 키를 줌) */ const onDraftNew = (): void => { const f = files.find((x) => x.file === opened?.file) ?? files[0]; @@ -351,23 +299,6 @@ export async function mountM01Logic( persist(); show(next); }; - const onNew = easy ? openWizard : onDraftNew; - - /** 「본떠 만들기」 — 정본을 자체 파일로 복제하고(계약 5장) 그 새 로직을 바로 엶 */ - const onCopy = async (from: Opened): Promise => { - if (!from.origKey) return; - showLoadingOverlay(); - try { - const made = await copyLogic(from.origKey); - await reload(); - showToast(tx("Edit_Copied", { v: made.key }), "success"); - await open(logicId("자체", made.key), "자체", made.key); - } catch (error) { - failed(error); - } finally { - hideLoadingOverlay(); - } - }; const onDelete = async (): Promise => { if (!opened?.row) return; @@ -457,20 +388,11 @@ export async function mountM01Logic( children: [ back, el("h2", { text: tx("Title") }), - // 흐름 보기 = 읽기 + 시험 계산만 — 만들기·지우기·저장 단추 없음 - ...(mode === "cview" - ? [] - : [ - pending, - createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onNew }), - createButton({ - label: tx("Bar_Delete"), - variant: "danger", - onClick: () => void onDelete(), - }), - discardButton, - saveButton, - ]), + pending, + createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onDraftNew }), + createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), + discardButton, + saveButton, ], }); host.replaceChildren( @@ -485,9 +407,7 @@ export async function mountM01Logic( ], }), ); - ({ classic: side.logicHost, easy: side.easyHost, cview: side.cviewHost })[mode].replaceChildren( - filterHost, - ); + side.logicHost.replaceChildren(filterHost); show(null); showLoadingOverlay(); const openKeyOn = async (key: string): Promise => { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts index 36c4ca15..3c6cba36 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts @@ -85,8 +85,7 @@ const TEXT = { Save_Failed: ["저장 못 함", "Save failed"], Save_Nothing: ["고친 것 없음", "Nothing to save"], Load_Failed: ["불러오지 못함", "Load failed"], - CView_Title: ["로직 흐름 보기", "Logic — flow view"], - Easy_Title: ["로직 쉽게 보기·만들기", "Logic — easy view & create"], + Lab_Title: ["로직 개선 시험", "Logic — improvement lab"], Loading: ["불러오는 중", "Loading"], Confirm_Delete: [ "로직 「{v}」 을 지울까요? [저장] 때 파일에서 빠짐", @@ -99,8 +98,6 @@ const TEXT = { ], New_Key: ["새 로직", "New logic"], /* ── 흐름 그림(기본 보기) ── */ - View_Flow: ["흐름 그림", "Flow"], - View_Advanced: ["고급 — 호표 표", "Advanced — table"], Flow_NoInputs: ["받을 값 없음", "No inputs"], Flow_Other: ["그 밖", "Other"], Flow_Sub: ["이 로직이 부르는 로직", "Logic called here"], @@ -112,10 +109,6 @@ const TEXT = { Col_덧줄: ["할증·덧줄", "Extra lines"], Col_비목: ["비목 합계", "Cost items"], Col_계: ["계", "Total"], - Guide_Flow: [ - "왼쪽 「설계 값」을 넣으면 오른쪽으로 수량 → 단가 → 합계 순으로 흐름이 이어짐|상자를 누르면 값·출처·원문 표·쉬운 말 풀이가 펼쳐짐|빨간 글이 뜨면 그 까닭 그대로 값을 채우거나 바꿈", - "Enter design values on the left; quantity, price and total follow to the right|Click a box for values, source, the source table and a plain-words reading|Red text says what is missing", - ], /* ── 상자 속 설명 카드 ── */ Note_Plain: ["쉬운 말 풀이", "In plain words"], Note_Table: ["원문 표 미리보기", "Source table"], @@ -176,7 +169,6 @@ export function plainReason(reason: string): string { } /** 흐름 그림 머리 「이렇게 씀」 — 줄 목록 */ -export const guideLines = (): string[] => tx("Guide_Flow").split("|"); /** 공용 `t()` 와 같은 뜻 — `{n}`·`{v}` 는 채움 */ export function tx(key: TextKey, fill: Record = {}): string { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts deleted file mode 100644 index 2fadf47e..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts +++ /dev/null @@ -1,279 +0,0 @@ -/* ============================================================================= - * 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, 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 { createLogic } 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_Logic_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 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 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, onCopied })); - 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 => { - 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) }); - }; - - /* ── 저장 ── */ - const saveView = (): HTMLElement => { - const errors = el("div", { className: "m01w__reasons", attrs: { hidden: "" } }); - return el("div", { - className: "m01w__page", - children: [ - el("h3", { text: tw("Q_Save") }), - el("p", { className: "m01w__muted", text: tw("Save_Owner") }), - errors, - createButton({ label: tw("Save_Do"), onClick: () => void doSave(errors) }), - ], - }); - }; - - const doSave = async (errors: HTMLElement): Promise => { - errors.hidden = true; - try { - const made = await createLogic(buildRow(d)); - showToast(tw("Save_Done"), "success"); - close(); - options.onSaved?.(made.key); - } catch (e) { - if (e instanceof ApiError && typeof e.detail === "object" && e.detail !== null) { - const list = (e.detail as { errors?: 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); - 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 deleted file mode 100644 index ef7ef7d7..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Api.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* ============================================================================= - * 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, type LogicRow } from "./M01_MasterData_UI_Logic_Api"; - -export interface TableBrief { - file: string; - 키: string; - 원문번호: string; - 구분: string; - 이름: string; - 기준?: string; - 조건?: Record; - 값칸?: Record; - 용도?: { 공종?: string; 대상?: string[]; 로직키?: 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 { - 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; -} - -/** 원문 절 한 줄 — `/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, section, size: "100" }), - ), - ); - return found.flatMap((f) => f.tables).filter((t) => t.구분 === book); -} - -const tableCache = new Map>(); - -export function fetchTableInfo(file: string, key: string): Promise { - let got = tableCache.get(key); - if (!got) { - 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" }); - -/** 로직 이름·번호 찾기 — 하위 로직 고르기 */ -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 deleted file mode 100644 index 4da8904d..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Atom.ts +++ /dev/null @@ -1,262 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_Logic_Wizard_Atom.ts - * 값 하나를 고르는 칸 — 직접 값 / 설계 입력 / 표에서 찾기 / 다른 로직 부르기 - * 수량 · 조건 나누기의 각 갈래가 같이 씀 · 고른 것은 넘겨받은 Atom 에 바로 씀(식은 Model 이 조립) - * ========================================================================== */ - -import { createInputField, createSelectField, el, showToast } from "@ui/ui_template_elements"; -import { - fetchTableInfo, - 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 fetchTableInfo(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_Copy.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Copy.ts deleted file mode 100644 index 0d963f52..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Copy.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* ============================================================================= - * 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 deleted file mode 100644 index 238c6692..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Model.ts +++ /dev/null @@ -1,248 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_Logic_Wizard_Model.ts - * 「새로 만들기」 — 만드는 중인 로직 한 벌(선택지) → 식 글자 → 로직 줄. 화면 없음(순수 함수) - * 식은 사람이 치지 않음 — 고른 것을 여기서 엔진 문법(`_틀.md` 8장)으로 조립 - * ========================================================================== */ - -import type { HoLine, LogicInput, LogicRow, NamedFormula } from "./M01_MasterData_UI_Logic_Api"; -import type { TableBrief, TableInfo } 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 info.값?.[cond] as (string | number)[] | undefined; -} - -/** 만드는 중인 것이 모든 식에서 받는 설계 입력 이름들(등장 차례) — 이름 · 모양 */ -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.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)); - 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 deleted file mode 100644 index 06a3473d..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Source.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* ============================================================================= - * 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 deleted file mode 100644 index cb162725..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Steps.ts +++ /dev/null @@ -1,548 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_Logic_Wizard_Steps.ts - * 「새로 만들기」 걸음별 화면 — 이름 · 원문 절 · 중간 값 · 호표 줄 · 설계 입력 · 할증·끝수 - * 한 걸음 = `{name, render(ctx), ready(draft)}` · 시험 계산·저장은 모달 본체(`Wizard.ts`)가 얹음 - * ========================================================================== */ - -import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; -import { searchElements, searchPrice } from "./M01_MasterData_UI_Logic_Api"; -import { searchJobs, type TableBrief } from "./M01_MasterData_UI_Logic_Wizard_Api"; -import { atomEditor, select, textBox } from "./M01_MasterData_UI_Logic_Wizard_Atom"; -import { - BASES, - COSTS, - COST_OF, - atomReady, - collectInputs, - emptyAtom, - emptyQty, - isMoney, - qtyPlain, - qtyReady, - type Branch, - type Draft, - type Line, - type LineKind, - type Middle, -} from "./M01_MasterData_UI_Logic_Wizard_Model"; -import { tw, type WizardTextKey } from "./M01_MasterData_UI_Logic_Wizard_Text"; - -import { copyBox } from "./M01_MasterData_UI_Logic_Wizard_Copy"; -import { sourceStep } from "./M01_MasterData_UI_Logic_Wizard_Source"; -import { fail, note, page, type Ctx, type Step } from "./M01_MasterData_UI_Logic_Wizard_Ui"; - -export type { Ctx, Step }; - -/* ── 1. 이름 ─────────────────────────────────────────────────────────── */ - -const UNITS = ["원/㎡", "원/m", "원/㎥", "원/개소", "원/본", "원/톤"]; - -const nameStep: Step = { - key: "S_Name", - question: "Q_Name", - ready: (d) => d.name.trim() !== "" && d.unit.trim() !== "", - render: ({ d, changed, onCopied }) => { - const other = !UNITS.includes(d.unit); - const own = el("div"); - const drawOwn = (on: boolean): void => - own.replaceChildren( - ...(on - ? [ - textBox(d.unit, tw("Unit_Own_Ph"), (v) => { - d.unit = v; - changed(); - }), - ] - : []), - ); - drawOwn(other); - return page( - "Q_Name", - textBox(d.name, tw("Name_Ph"), (v) => { - d.name = v; - changed(); - }), - el("h4", { text: tw("Q_Unit") }), - select( - [...UNITS, tw("Unit_Other")].map((u) => ({ value: u, text: u })), - other ? tw("Unit_Other") : d.unit, - (v) => { - const isOther = v === tw("Unit_Other"); - d.unit = isOther ? "" : v; - drawOwn(isOther); - changed(); - }, - ), - own, - note(tw("Unit_Money")), - copyBox(onCopied), - ); - }, -}; - -/* ── 3. 중간 값(조건 나누기) ─────────────────────────────────────────── */ - -const newMiddle = (): Middle => ({ name: "", branches: [], otherwise: emptyAtom() }); - -const middleStep: Step = { - key: "S_Middle", - question: "Q_Middle", - ready: () => true, - render: ({ d, changed }) => { - const done = el("div", { className: "m01w__stack" }); - const form = el("div", { className: "m01w__stack" }); - let cur = newMiddle(); - const drawDone = (): void => - done.replaceChildren( - ...d.middles.map((m, i) => - el("div", { - className: "m01w__inline", - children: [ - el("strong", { text: m.name }), - el("span", { className: "m01w__muted", text: `${m.branches.length + 1}갈래` }), - createButton({ - label: tw("Line_Del"), - variant: "ghost", - onClick: () => { - d.middles.splice(i, 1); - drawDone(); - changed(); - }, - }), - ], - }), - ), - ); - const drawForm = (): void => { - const rows = cur.branches.map((b) => branchRow(b, d.tables, changed)); - form.replaceChildren( - textBox( - cur.name, - tw("Middle_Name"), - (v) => { - cur.name = v.trim(); - }, - tw("Middle_Name"), - ), - ...rows, - createButton({ - label: tw("Middle_AddIf"), - variant: "ghost", - onClick: () => { - cur.branches.push({ input: "", op: "<=", than: "", then: emptyAtom() }); - drawForm(); - }, - }), - el("strong", { text: tw("Middle_Else") }), - atomEditor(cur.otherwise, d.tables, changed, ["value", "table", "input"]), - createButton({ - label: tw("Middle_Done"), - onClick: () => { - const ok = - cur.name && - atomReady(cur.otherwise) && - cur.branches.every((b) => b.input && b.than.trim() && atomReady(b.then)); - if (!ok) return showToast(tw("Middle_Need"), "warning"); - d.middles.push(cur); - cur = newMiddle(); - drawDone(); - drawForm(); - changed(); - }, - }), - ); - }; - drawDone(); - drawForm(); - return page("Q_Middle", note(tw("Middle_Note")), done, form); - }, -}; - -function branchRow(b: Branch, tables: TableBrief[], changed: () => void): HTMLElement { - return el("div", { - className: "m01w__branch", - children: [ - el("span", { text: tw("Middle_If") }), - textBox(b.input, tw("Qty_Name"), (v) => (b.input = v.trim())), - select( - ["<=", "<", ">=", ">"].map((o) => ({ value: o, text: o })), - b.op, - (v) => (b.op = v as Branch["op"]), - ), - textBox(b.than, tw("Qty_Number_Ph"), (v) => (b.than = v)), - el("span", { text: tw("Middle_Then") }), - atomEditor(b.then, tables, changed, ["value", "table", "input"]), - ], - }); -} - -/* ── 4. 호표 줄 ──────────────────────────────────────────────────────── */ - -interface Found { - ref: string; - 이름?: string; - 규격?: string; - 단위?: unknown; - 구분?: string; - 상세구분?: string; -} - -/** 찾기 → 구분 → 상세구분 → 후보 — 후보를 누르면 `onPick` */ -function pickBox( - search: (q: string) => Promise, - onPick: (f: Found) => void, - start: string, -): HTMLElement { - const box = createInputField({ value: start, placeholder: tw("Line_Find"), type: "search" }); - const list = el("div", { className: "m01w__found" }); - const shown = el("p", { className: "m01w__picked" }); - const filters = el("div", { className: "m01w__inline" }); - let all: Found[] = []; - let sub = ""; - let detail = ""; - const distinct = (rows: Found[], k: "구분" | "상세구분"): string[] => [ - ...new Set(rows.map((r) => r[k]).filter((v): v is string => !!v)), - ]; - const draw = (): void => { - const inSub = all.filter((f) => !sub || f.구분 === sub); - const inDetail = inSub.filter((f) => !detail || f.상세구분 === detail); - const all_ = { value: "", text: "(전체)" }; - filters.replaceChildren( - select( - [all_, ...distinct(all, "구분").map((v) => ({ value: v, text: v }))], - sub, - (v) => ((sub = v), (detail = ""), draw()), - tw("Line_Sub"), - ), - select( - [all_, ...distinct(inSub, "상세구분").map((v) => ({ value: v, text: v }))], - detail, - (v) => ((detail = v), draw()), - tw("Line_Detail"), - ), - ); - list.replaceChildren( - ...inDetail.slice(0, 30).map((f) => { - const row = el("button", { - className: "m01w__row", - attrs: { type: "button", "data-ref": f.ref }, - text: `${f.이름 ?? ""} ${f.규격 ?? ""}`.trim() + ` · ${f.구분 ?? ""} ${f.상세구분 ?? ""}`, - }); - row.addEventListener("click", () => pick(f)); - return row; - }), - ); - }; - const pick = (f: Found): void => { - shown.textContent = `✔ ${f.이름 ?? ""} ${f.규격 ?? ""}`.trim(); - onPick(f); - }; - const load = (auto: boolean): void => { - const q = box.input.value.trim(); - void search(q) - .then((rows) => { - all = rows; - sub = detail = ""; - draw(); - const exact = rows.filter((r) => r.이름 === q); - if (auto && exact.length === 1) pick(exact[0]); - }) - .catch(fail); - }; - let timer = 0; - box.input.addEventListener("input", () => { - window.clearTimeout(timer); - timer = window.setTimeout(() => load(false), 250); - }); - load(true); - return el("div", { className: "m01w__stack", children: [box.root, filters, list, shown] }); -} - -const KINDS: { kind: LineKind; label: WizardTextKey }[] = [ - { kind: "인력", label: "K_job" }, - { kind: "재료", label: "K_mat" }, - { kind: "기계", label: "K_eq" }, - { kind: "로직", label: "K_logic" }, -]; - -const blankLine = (kind: LineKind = "인력"): Line => ({ - kind, - ref: "", - args: "", - name: "", - unit: kind === "인력" ? "인" : kind === "기계" ? "시간" : "", - qty: emptyQty(), - cost: COST_OF[kind], -}); - -const timesPicker = (d: Draft, q: Line["qty"], changed: () => void): HTMLElement => - select( - [ - { value: "", text: tw("Qty_NoTimes") }, - ...d.middles.map((m) => ({ value: m.name, text: m.name })), - ], - q.times, - (v) => { - q.times = v; - changed(); - }, - tw("Qty_Times"), - ); - -const linesStep: Step = { - key: "S_Lines", - question: "Q_Lines", - ready: (d) => (isMoney(d.unit) ? d.lines.length > 0 : qtyReady(d.result)), - render: ({ d, changed }) => { - if (!isMoney(d.unit)) { - return page( - "Result_Q", - atomEditor(d.result.atom, d.tables, changed), - timesPicker(d, d.result, changed), - ); - } - const done = el("div", { className: "m01w__stack" }); - const form = el("div", { className: "m01w__stack" }); - const chips = el("div", { className: "m01w__inline" }); - let cur = blankLine(); - const drawDone = (): void => - done.replaceChildren( - ...(d.lines.length - ? d.lines.map((l, i) => - el("div", { - className: "m01w__inline m01w__done", - children: [ - el("strong", { text: `${l.kind} · ${l.name}` }), - el("span", { className: "m01w__muted", text: `${l.unit} · ${qtyPlain(l.qty)}` }), - createButton({ - label: tw("Line_Del"), - variant: "ghost", - onClick: () => { - d.lines.splice(i, 1); - drawDone(); - changed(); - }, - }), - ], - }), - ) - : [note(tw("Line_None"))]), - ); - const drawForm = (start = ""): void => { - let unitBox: HTMLInputElement | null = null; - const picker = (): HTMLElement => { - const set = (f: Found): void => { - cur.name = `${f.이름 ?? ""}${cur.kind === "기계" && f.규격 ? ` ${f.규격}` : ""}`.trim(); - if (cur.kind === "재료") { - cur.ref = { 구분: f.구분 ?? "", ...(f.상세구분 ? { 상세구분: f.상세구분 } : {}) }; - if (typeof f.단위 === "string" && f.단위) cur.unit = f.단위; - if (unitBox) unitBox.value = cur.unit; - } else cur.ref = f.ref; - }; - if (cur.kind === "로직") { - const atom = { kind: "logic" as const, key: "", args: "" }; - const bridge = (): void => { - cur.ref = atom.key; - cur.args = atom.args; - cur.name ||= atom.key; - }; - return atomEditor(atom, d.tables, bridge, ["logic"]); - } - const search = - cur.kind === "인력" - ? (q: string) => searchJobs(q).then((r) => r.items as Found[]) - : cur.kind === "재료" - ? (q: string) => searchPrice(q).then((r) => r.items as Found[]) - : (q: string) => searchElements("기계", q).then((r) => r.items as Found[]); - return pickBox(search, set, start); - }; - const unit = createInputField({ - value: cur.unit, - placeholder: tw("Line_Unit"), - type: "text", - }); - unit.input.addEventListener("input", () => (cur.unit = unit.input.value.trim())); - unitBox = unit.input; - form.replaceChildren( - select( - KINDS.map((k) => ({ value: k.kind, text: tw(k.label) })), - cur.kind, - (v) => { - cur = blankLine(v as LineKind); - drawForm(); - }, - tw("Line_Kind"), - ), - picker(), - unit.root, - el("strong", { text: tw("Line_Qty") }), - atomEditor(cur.qty.atom, d.tables, changed), - timesPicker(d, cur.qty, changed), - select( - ["", ...COSTS].map((c) => ({ value: c, text: c || "(자동)" })), - cur.cost, - (v) => (cur.cost = v), - tw("Line_Cost"), - ), - createButton({ - label: tw("Line_Add"), - onClick: () => { - if (!cur.ref || !qtyReady(cur.qty)) return showToast(tw("Line_Need"), "warning"); - d.lines.push(cur); - cur = blankLine(cur.kind); - drawDone(); - drawForm(); - changed(); - }, - }), - ); - }; - // 표의 값 칸에서 바로 줄 넣기 — 값 칸 이름으로 찾아 두고 수량은 그 표 칸으로 - const drawChips = (): void => - chips.replaceChildren( - ...d.tables.flatMap((t) => - Object.keys(t.값칸 ?? {}).map((col) => - createButton({ - label: `${col} (${t.키})`, - variant: "pill", - onClick: () => { - const target = t.용도?.대상 ?? []; - const kind: LineKind = target.includes("기계") - ? "기계" - : target.includes("재료") - ? "재료" - : "인력"; - cur = blankLine(kind); - cur.qty.atom = { kind: "table", table: t.키, col, cond: {} }; - drawForm(col); - }, - }), - ), - ), - ); - drawDone(); - drawForm(); - drawChips(); - return page( - "Q_Lines", - done, - d.tables.length ? el("strong", { text: tw("Line_FromTable") }) : "", - chips, - form, - ); - }, -}; - -/* ── 5. 설계 입력 ────────────────────────────────────────────────────── */ - -const inputsStep: Step = { - key: "S_Inputs", - question: "Q_Inputs", - ready: () => true, - render: ({ d, changed }) => { - const shapes = collectInputs(d); - if (!shapes.length) return page("Q_Inputs", note(tw("Inputs_None"))); - const cards = shapes.map((s) => { - const meta = (d.meta[s.name] ??= { desc: "", unit: "", min: "", max: "" }); - const range = s.choices - ? [note(`${tw("In_Pick")} · ${s.choices.join(" · ")}`)] - : [ - note(tw("In_Number")), - el("div", { - className: "m01w__inline", - children: [ - textBox(meta.min, tw("In_Range"), (v) => ((meta.min = v), changed())), - textBox(meta.max, tw("In_Range"), (v) => ((meta.max = v), changed())), - ], - }), - ]; - return el("div", { - className: "m01w__card", - attrs: { "data-input": s.name }, - children: [ - el("strong", { text: s.name }), - textBox(meta.desc, tw("In_Desc"), (v) => ((meta.desc = v), changed())), - textBox(meta.unit, tw("In_Unit"), (v) => ((meta.unit = v), changed())), - ...range, - ], - }); - }); - return page("Q_Inputs", ...cards); - }, -}; - -/* ── 6. 할증·덧줄 · 끝수 ─────────────────────────────────────────────── */ - -const extraStep: Step = { - key: "S_Extra", - question: "Q_Extra", - ready: () => true, - render: ({ d, changed }) => { - const money = isMoney(d.unit); - if (!money) return page("Q_Extra", note("돈이 아닌 로직 — 덧줄·끝수 없음")); - const list = el("div", { className: "m01w__stack" }); - const draw = (): void => - list.replaceChildren( - ...d.extras.map((e, i) => - el("div", { - className: "m01w__inline m01w__card", - children: [ - textBox(e.name, tw("Extra_Name"), (v) => ((e.name = v), changed())), - select( - Object.keys(BASES).map((b) => ({ value: b, text: b })), - e.base, - (v) => ((e.base = v), changed()), - tw("Extra_Base"), - ), - textBox(e.pct, tw("Extra_Pct"), (v) => ((e.pct = v), changed())), - select( - COSTS.map((c) => ({ value: c, text: c })), - e.cost, - (v) => ((e.cost = v), changed()), - tw("Extra_Cost"), - ), - createButton({ - label: tw("Line_Del"), - variant: "ghost", - onClick: () => { - d.extras.splice(i, 1); - draw(); - changed(); - }, - }), - ], - }), - ), - ); - draw(); - return page( - "Q_Extra", - list, - createButton({ - label: tw("Extra_Add"), - variant: "ghost", - onClick: () => { - d.extras.push({ name: "", base: "노무비", pct: "", cost: "경비" }); - draw(); - changed(); - }, - }), - el("h4", { text: tw("Round_Title") }), - select( - [ - { value: "", text: tw("Round_None") }, - { value: "버림", text: tw("Round_Down") }, - { value: "올림", text: tw("Round_Up") }, - { value: "반올림", text: tw("Round_Half") }, - ], - d.round, - (v) => { - d.round = v; - changed(); - }, - ), - ); - }, -}; - -export const STEPS: Step[] = [nameStep, sourceStep, middleStep, linesStep, inputsStep, extraStep]; diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Style.css deleted file mode 100644 index d245fd85..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Style.css +++ /dev/null @@ -1,169 +0,0 @@ -/* 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; - } -} - -.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 deleted file mode 100644 index b59596f4..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Text.ts +++ /dev/null @@ -1,160 +0,0 @@ -/* ============================================================================= - * 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"], - 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"], - 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", - ], - 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_Up: ["원 미만 올림", "Round up"], - Round_Half: ["원 미만 반올림", "Round half"], - - 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: [ - "자체 로직으로 저장됩니다 — 키(GX…)는 저장할 때 서버가 붙임", - "Saved as your own logic; the server issues the key", - ], - 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_Logic_Wizard_Ui.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Ui.ts deleted file mode 100644 index ed3f904d..00000000 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Ui.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* ============================================================================= - * 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/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index c2952d3c..ef5c116d 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -54,8 +54,7 @@ function buildPage(): HTMLElement { let query = ""; let dispose = (): void => {}; let logicMounted = false; - let easyMounted = false; - let cviewMounted = false; + let labMounted = false; /* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */ const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") }); @@ -71,8 +70,7 @@ function buildPage(): HTMLElement { children: [title, searchField.root, summary, drop, save], }); const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); - const easyHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); - const cviewHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); + const labHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); const elementView = el("div", { className: "m01-master__elements", children: [head, notice, body], @@ -90,10 +88,9 @@ function buildPage(): HTMLElement { onDraftChange(refreshBar); refreshBar(); - const showMode = (mode: "elements" | "logic" | "easy" | "cview"): void => { + const showMode = (mode: "elements" | "logic" | "lab"): void => { logicHost.hidden = mode !== "logic"; - easyHost.hidden = mode !== "easy"; - cviewHost.hidden = mode !== "cview"; + labHost.hidden = mode !== "lab"; elementView.hidden = mode !== "elements"; }; @@ -199,34 +196,19 @@ function buildPage(): HTMLElement { }); }; - /** 「로직 쉽게 보기·만들기」 — 같은 로직 목록에 방식 C 흐름 그림 + 새로 만들기 모달 */ - const openEasyTab = (): void => { + /** 「로직 개선 시험」 — 정본 로직 화면의 복사본(개선은 여기서만) */ + const openLabTab = (): void => { dispose(); dispose = (): void => {}; current = null; - showMode("easy"); - if (easyMounted) return; - easyMounted = true; - void import("./M01_MasterData_UI_Logic_Page").then((m) => - m.mountM01Logic(easyHost, side, undefined, "easy"), - ); - }; - - /** 「로직 흐름 보기」 — 같은 로직 목록에 방식 C 흐름 그림(읽기 + 시험 계산) */ - const openCViewTab = (): void => { - dispose(); - dispose = (): void => {}; - current = null; - showMode("cview"); - if (cviewMounted) return; - cviewMounted = true; - void import("./M01_MasterData_UI_Logic_Page").then((m) => - m.mountM01Logic(cviewHost, side, undefined, "cview"), - ); + showMode("lab"); + if (labMounted) return; + labMounted = true; + void import("./M01_MasterData_UI_LogicLab").then((m) => m.mountM01LogicLab(labHost, side)); }; /* --- 좌측: 컨테이너 --- */ - const side = buildSide(openFile, () => openLogicTab(), openEasyTab, openCViewTab); + const side = buildSide(openFile, () => openLogicTab(), openLabTab); const layout = el("div", { className: "ui-workflow-layout m01-master" }); const main = el("main", { @@ -234,7 +216,7 @@ function buildPage(): HTMLElement { children: [ el("div", { className: "m01-master__panel", - children: [elementView, logicHost, easyHost, cviewHost], + children: [elementView, logicHost, labHost], }), ], }); diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index d2df4b16..8b16695e 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -75,9 +75,8 @@ export interface SideHandle { root: HTMLElement; /** 일위대가 로직 컨테이너 안(로직 목록이 들어갈 자리) */ logicHost: HTMLElement; - /** 「로직 쉽게 보기·만들기」 컨테이너 안(같은 로직 목록이 들어갈 자리) */ - easyHost: HTMLElement; - cviewHost: HTMLElement; + /** 「로직 개선 시험」 컨테이너 안 */ + labHost: HTMLElement; /** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */ refresh: (group: string) => Promise; setActive: (id: string | null) => void; @@ -133,14 +132,12 @@ const LEAF: Group[] = ["환율", "요율"]; /** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */ const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"]; const LOGIC_ID = "로직|"; -const EASY_ID = "쉬움|"; -const CVIEW_ID = "흐름|"; +const LAB_ID = "로직시험|"; export function buildSide( onOpen: (pick: Pick) => void, onLogic: () => void, - onEasy: () => void, - onCView: () => void, + onLab: () => void, ): SideHandle { const lists = new Map(); const bodies = new Map(); @@ -427,23 +424,15 @@ export function buildSide( onLogic(); }); - const easyHost = el("div", { className: "m01-side__items" }); - const easy = section(tx("Easy_Title"), easyHost); - easy.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { - if (!easy.classList.contains("is-collapsed")) return; - setActive(buttons.has(EASY_ID) ? EASY_ID : null); - onEasy(); + const labHost = el("div", { className: "m01-side__items" }); + const lab = section(tx("Lab_Title"), labHost); + lab.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { + if (!lab.classList.contains("is-collapsed")) return; + setActive(buttons.has(LAB_ID) ? LAB_ID : null); + onLab(); }); - const cviewHost = el("div", { className: "m01-side__items" }); - const cview = section(tx("CView_Title"), cviewHost); - cview.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { - if (!cview.classList.contains("is-collapsed")) return; - setActive(buttons.has(CVIEW_ID) ? CVIEW_ID : null); - onCView(); - }); - - const root = el("div", { className: "m01-side", children: [...groups, logic, easy, cview] }); + const root = el("div", { className: "m01-side", children: [...groups, logic, lab] }); attachCollapsible(root); void Promise.all(GROUPS.map(refresh)).catch((error) => showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"), @@ -451,5 +440,5 @@ export function buildSide( const setOnPicked = (fn: () => void): void => { onPicked = fn; }; - return { root, logicHost, easyHost, cviewHost, refresh, setActive, setOnPicked, filter }; + return { root, logicHost, labHost, refresh, setActive, setOnPicked, filter }; } diff --git a/resources/tester/helper_m01_flow_c.cjs b/resources/tester/helper_m01_flow_c.cjs deleted file mode 100644 index 9c4e1249..00000000 --- a/resources/tester/helper_m01_flow_c.cjs +++ /dev/null @@ -1,141 +0,0 @@ -/* M01 방식 C(흐름 그림) 뼈대 검증 헬퍼 — TS를 그 자리에서 트랜스파일해 Node로 돌린다. - * (프론트에 JS 테스트 러너가 없어 pytest가 이 스크립트를 부른다 — test_m01_flow_c.py) - * 확장자가 `.cjs` 인 까닭은 helper_b05_patch_skirt.cjs 머리말 참고(루트가 ESM). */ -const fs = require("fs"); -const path = require("path"); -const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); - -const source = fs.readFileSync( - path.join(__dirname, "..", "..", "M01_MasterData", "M01_MasterData_UI_Logic_Flow_Model.ts"), - "utf8", -); -const js = ts.transpileModule(source, { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const moduleBox = { exports: {} }; -new Function("exports", "module", "require", js)(moduleBox.exports, moduleBox, require); -const { buildFlow, logicRef } = moduleBox.exports; - -/** 산림품셈 13-4-1 메쌓기 모양 — 표 찾기 하나 · 인력 두 줄 · 덧줄 하나(할증) */ -const 메쌓기 = { - 키: "GF000219", - 원문번호: "13-4-1", - 이름: "돌쌓기 메쌓기(인력)", - 결과단위: "원/㎡", - 출처: "산림품셈 13-4-1", - 입력: [ - { 이름: "뒷길이", 단위: "㎝", 고르기: [25, 30] }, - { 이름: "높이", 단위: "m" }, - ], - 중간: [{ 이름: "증가율", 식: "찾기(QF000422, 높이=높이).증가율", 출처: "산림품셈 13-4-1 주③" }], - 호표: [ - { - 종류: "인력", - 요소: "LB000033", - 이름: "석공", - 단위: "인", - 수량: "찾기(QF000421, 뒷길이=뒷길이).석공", - 비목: "노무비", - }, - { - 종류: "로직", - 요소: "로직(GC000268, 기계='2702-0020')", - 이름: "굴착기", - 단위: "시간", - 수량: "1 / N", - 비목: "경비", - }, - ], - 덧줄: [{ 이름: "잡재료", 식: "노무비 * 0.03", 비목: "재료비", 출처: "산림품셈 13-4-1 주①" }], - 끝수: null, -}; - -const 답 = { - ok: true, - lines: [ - { 이름: "석공", 단위: "인", 수량: 1.2, 단가: 300000, 금액: 360000, 비목: { 노무비: 360000 } }, - { 이름: "굴착기", 단위: "시간", 수량: 0.5, 단가: 80000, 금액: 40000, 비목: { 경비: 40000 } }, - { 이름: "잡재료", 단위: "식", 수량: 1, 단가: 10800, 금액: 10800, 비목: { 재료비: 10800 } }, - ], - sums: { 노무비: 360000, 재료비: 10800, 경비: 40000, 계: 410800 }, - middle: { 증가율: 15 }, -}; - -/** 돈이 아닌 로직 — 결과 식 하나 */ -const 작업량 = { - 키: "GF000118", - 원문번호: "9-16-1", - 이름: "노체포설 시간당 작업량", - 결과단위: "㎥/hr", - 출처: "산림품셈 9-16-1", - 입력: [{ 이름: "토질", 고르기: ["보통토", "암괴"] }], - 중간: [{ 이름: "E", 식: "찾기(QF000300, 토질=토질).E" }], - 결과: { 식: "3600 * q * E / Cm" }, -}; - -const kinds = (columns) => columns.map((c) => c.kind); -const box = (columns, kind, i) => columns.find((c) => c.kind === kind).boxes[i]; - -const out = { - logicRef: [logicRef("로직(GC000268, 기계='2702-0020')"), logicRef("LB000033") ?? null], - 칸: kinds(buildFlow(메쌓기, 답, {}, { 뒷길이: "25" })), - 칸_계산전: kinds(buildFlow(메쌓기, null, {}, {})), - 칸_돈아님: kinds(buildFlow(작업량, { ok: true, result: 42.5, middle: { E: 0.8 } }, {}, {})), -}; - -{ - const columns = buildFlow(메쌓기, 답, {}, { 뒷길이: "25", 높이: "4" }); - const 입력 = box(columns, "입력", 0); - const 중간 = box(columns, "중간", 0); - const 수량 = box(columns, "수량", 0); - const 단가1 = box(columns, "단가", 0); - const 단가2 = box(columns, "단가", 1); - const 덧줄 = box(columns, "덧줄", 0); - const 계 = box(columns, "계", 0); - const 비목 = columns.find((c) => c.kind === "비목").boxes; - out.값 = { - 입력값: 입력.value, - 입력출처: 입력.detail, - 중간값: 중간.value, - 중간식: 중간.detail[0], - 수량값: 수량.value, - 수량식: 수량.detail[0], - 단가노트: 단가1.note, - 단가값: 단가1.value, - 로직: 단가2.logic ?? null, - 로직아님: 단가1.logic ?? null, - 덧줄값: 덧줄.value, - 덧줄식: 덧줄.detail[0], - 비목: 비목.map((b) => [b.label, b.value]), - 비목상세: 비목.map((b) => b.detail), - 계값: 계.value, - 묶음: columns.find((c) => c.kind === "수량").boxes.map((b) => b.cost), - id: [수량.id, 단가1.id, 덧줄.id, 계.id], - }; -} - -{ - const columns = buildFlow(메쌓기, null, { LB000033: null }, {}); - out.계산전 = { - 수량값: box(columns, "수량", 0).value, - 수량식: box(columns, "수량", 0).detail[0], - 단가막힘: box(columns, "단가", 0).bad === true, - 계값: box(columns, "계", 0).value, - }; -} - -{ - const columns = buildFlow(메쌓기, { ok: false, reason: "입력 없음" }, {}, {}); - out.멈춤 = { 칸: kinds(columns), 계값: box(columns, "계", 0).value }; -} - -{ - const columns = buildFlow(작업량, { ok: true, result: 42.5, middle: { E: 0.8 } }, {}, {}); - out.돈아님 = { - 중간값: box(columns, "중간", 0).value, - 결과값: box(columns, "계", 0).value, - 결과식: box(columns, "계", 0).detail[0], - }; -} - -process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m01_flow_c.py b/resources/tester/test_m01_flow_c.py deleted file mode 100644 index 9f5ed37c..00000000 --- a/resources/tester/test_m01_flow_c.py +++ /dev/null @@ -1,82 +0,0 @@ -# -*- coding: utf-8 -*- -"""M01 로직 테스트 방식 C(흐름 그림) 뼈대 검증 — `M01_MasterData_UI_Logic_Flow_Model.ts`. - -프론트에 JS 테스트 러너가 없어 Node 헬퍼(helper_m01_flow_c.cjs)가 TS를 트랜스파일해 -돌리고, pytest 는 그 결과(JSON)를 본다. 뼈대만 봄 — 금액은 엔진이 준 답을 그대로 옮기는지. -""" - -import json -import os -import subprocess - -import pytest - -HERE = os.path.dirname(os.path.abspath(__file__)) - - -@pytest.fixture(scope="module") -def flow(): - proc = subprocess.run( - ["node", os.path.join(HERE, "helper_m01_flow_c.cjs")], - capture_output=True, - text=True, - encoding="utf-8", - timeout=60, - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) - - -def test_칸_차례(flow): - """설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계.""" - assert flow["칸"] == ["입력", "중간", "수량", "단가", "덧줄", "비목", "계"] - # 계산 전에도 같은 뼈대 — 값만 비어 있음 - assert flow["칸_계산전"] == flow["칸"] - # 돈이 아닌 로직은 호표가 없음 - assert flow["칸_돈아님"] == ["입력", "중간", "계"] - - -def test_값은_엔진_답_그대로(flow): - v = flow["값"] - assert v["중간값"] == "15" - assert v["수량값"] == "1.2" - assert (v["단가노트"], v["단가값"]) == ("× 300,000", "360,000") - assert v["덧줄값"] == "10,800" - assert v["비목"] == [["노무비", "360,000"], ["재료비", "10,800"], ["경비", "40,000"]] - assert v["계값"] == "410,800" - - -def test_상자를_누르면_값과_출처(flow): - v = flow["값"] - assert v["중간식"] == ["식", "찾기(QF000422, 높이=높이).증가율"] - assert v["수량식"] == ["식", "찾기(QF000421, 뒷길이=뒷길이).석공"] - assert v["덧줄식"] == ["식", "노무비 * 0.03"] - assert ["단위", "㎝"] in v["입력출처"] - # 비목 상자 = 그 비목에 들어온 줄 - assert v["비목상세"] == [[["석공", "360,000"]], [["잡재료", "10,800"]], [["굴착기", "40,000"]]] - - -def test_로직이_로직을_부르는_줄(flow): - assert flow["logicRef"] == ["GC000268", None] - assert flow["값"]["로직"] == "GC000268" # 펼칠 상자 - assert flow["값"]["로직아님"] is None - - -def test_id_는_다시_그려도_같음(flow): - assert flow["값"]["id"] == ["수량:0", "단가:0", "덧줄:0", "계"] - assert flow["값"]["묶음"] == ["노무비", "경비"] # 비목으로 묶어 접기 - - -def test_계산_전과_멈춤(flow): - # 값이 없어도 식은 보임 · 값 없는 요소는 막힘 표시 - assert flow["계산전"]["수량값"] == "" - assert flow["계산전"]["수량식"][1].startswith("찾기(") - assert flow["계산전"]["단가막힘"] is True - assert flow["계산전"]["계값"] == "" - assert flow["멈춤"]["칸"] == flow["칸"] and flow["멈춤"]["계값"] == "" - - -def test_돈_아닌_로직(flow): - assert flow["돈아님"]["중간값"] == "0.8" - assert flow["돈아님"]["결과값"] == "42.5" - assert flow["돈아님"]["결과식"] == ["식", "3600 * q * E / Cm"] diff --git a/resources/tester/test_m01_test_container.py b/resources/tester/test_m01_test_container.py deleted file mode 100644 index e27904eb..00000000 --- a/resources/tester/test_m01_test_container.py +++ /dev/null @@ -1,58 +0,0 @@ -"""M01 로직 테스트 컨테이너 — 견본 10 개가 실제 산림품셈 로직이고 막히지 않았는지. - -흐름 그림(방식 C)과 고급 화면은 같은 `/calc` 를 씀 — 여기서는 10 개가 모두 첫 선택값으로 셈이 끝까지 가는지만 봄. -""" - -from __future__ import annotations - -from M01_MasterData import M01_MasterData_Store as store - -# 견본 10 개 — 복잡도(입력·표 찾기·로직 부르기·덧줄·재료)로 잰 순서 · 재는 법 `resources/master_data/scripts/로직_복잡도_재기.py` -KEYS = [ - "GF000181", # 12-16 맨홀 - "GF000177", # 12-12 날개벽 - "GF000173", # 12-11-1 VR관(소켓식) 부설 - "GF000174", # 12-11-2-1 흄관 부설 - "GF000160", # 12-4 합판거푸집 - "GF000182", # 12-17-1 펌프카 타설 - "GF000069", # 7-11 동력상하차기 집재 - "GF000219", # 13-4-1 돌쌓기 메쌓기(인력) - "GF000119", # 9-16-1 노체포설 - "GF000137", # 10-4 중기운반 트레일러 -] - - -def _first_inputs(logic: dict) -> dict: - """고르기 = 첫 값 · 범위 = 아래 끝 · 나머지 = 10 (마법사가 흔히 넣는 값).""" - out = {} - for spec in logic.get("입력") or []: - if spec.get("고르기"): - out[spec["이름"]] = spec["고르기"][0] - elif spec.get("범위"): - out[spec["이름"]] = spec["범위"][0] - else: - out[spec["이름"]] = 10 - return out - - -def test_ten_keys(): - assert len(KEYS) == 10 and len(set(KEYS)) == 10 - - -def test_keys_are_forest_logics_and_not_blocked(): - for key in KEYS: - one = store.logic(key) - assert one["file"].startswith("로직_산림품셈_"), key - assert not one["blocked"], (key, one["reasons"]) - - -def test_required_three_present(): - # 메쌓기 13-4-1 · 노체 9-16-1 · 트럭 운반 10-4 — 끝까지 눌러 보는 셋 - assert {"GF000219", "GF000119", "GF000137"} <= set(KEYS) - - -def test_calc_answers_for_each(): - for key in KEYS: - one = store.logic(key) - answer = store.calc(key, _first_inputs(one["logic"]), None, None) - assert isinstance(answer, dict) and "ok" in answer, key