diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry.css b/M01_MasterData/LogicBuilder/M01_LB_Entry.css new file mode 100644 index 000000000..9975c9b32 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry.css @@ -0,0 +1,203 @@ +/* 로직 만들기 판 — 큰 창 · 시작 화면 · 위 띠 · 세 칸 */ + +/* 공용 모달(위 고정)을 화면 대부분으로 — 높이 고정 · 칸마다 따로 굴림 */ +.ui-modal.m01-lb { + --modal-max: 92vh; + width: min(1680px, 98vw); + height: var(--modal-max); + font-size: var(--text-body); +} + +.m01-lb .ui-modal__body { + flex: 1; + min-height: 0; + gap: var(--spacing-12); +} + +.m01-lb__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01-lb__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: pre-line; +} + +.m01-lb__note:empty { + display: none; +} + +.m01-lb__big { + min-height: 56px; + padding: 0 var(--spacing-24); + font-size: var(--text-body); +} + +.m01-lb .is-pending { + opacity: 0.45; +} + +/* 시작 화면 */ +.m01-lb__start { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + gap: var(--spacing-24); + min-height: 0; + overflow: auto; +} + +.m01-lb__start-col { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m01-lb__start-col h4 { + margin: var(--spacing-8) 0 0; +} + +.m01-lb__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-lb__item { + display: flex; + align-items: center; + gap: var(--spacing-8); + width: 100%; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.m01-lb__item:hover { + border-color: var(--color-accent); +} + +.m01-lb__item-name { + flex: 1; + min-width: 0; +} + +.m01-lb__item-unit { + color: var(--color-text-muted); + font-size: var(--text-body-sm); + white-space: nowrap; +} + +/* 위 띠 */ +.m01-lb__strip { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-12); + padding-bottom: var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.m01-lb__name { + flex: 1 1 280px; +} + +.m01-lb__applied { + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: nowrap; +} + +.m01-lb__actions { + display: flex; + gap: var(--spacing-8); + margin-left: auto; +} + +/* 세 칸 — 원문 · 산출근거 · 결과 */ +.m01-lb__cols { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1fr); + gap: var(--spacing-12); +} + +.m01-lb__col { + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.m01-lb__col-head { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.m01-lb__col-head h4 { + flex: 1; + margin: 0; +} + +.m01-lb__col-body { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); +} + +.m01-lb__col-body h5 { + margin: var(--spacing-8) 0 0; +} + +.m01-lb__total { + font-size: var(--text-subheading); + font-weight: 700; +} + +.m01-lb__examples { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8); + border-radius: var(--radius-lg); + background: var(--color-surface); +} + +.m01-lb__example-list { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.m01-lb__example { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + font-size: var(--text-body-sm); +} + +/* 좁은 화면 — 세 칸을 위아래로 */ +@media (max-width: 1100px) { + .m01-lb__cols { + grid-template-columns: minmax(0, 1fr); + overflow: auto; + } +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry.ts b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts new file mode 100644 index 000000000..0ceb0e6cd --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts @@ -0,0 +1,505 @@ +/* ============================================================================= + * M01_LB_Entry.ts + * 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸 틀(원문 · 산출근거 · 결과) · 위 띠 + * 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). + * 1걸음 = 틀 — 원문 · 산출근거 칸은 자리만 · 결과 칸은 EasyCalc 계산 · 일위대가표로 바로 보임. + * 2걸음에 Blocks · Api · Source · Steps · Grid · Wizard 를 이음. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createTag, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; +import { + calcLater, + copyEasy, + listEasy, + openEasy, + plainError, + type EasyListItem, +} from "../EasyCalc/M01_EasyCalc_Api"; +import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet"; +import { lbCalcLater, type LbTables } from "./M01_LB_Api"; +import { readLogic, type LbLogic } from "./M01_LB_Blocks"; +import "./M01_LB_Entry.css"; + +const TEXT = { + Open: ["로직 만들기(시험)", "Logic builder (trial)"], + Title: ["로직 만들기", "Logic builder"], + CloseWindow: ["창 닫기", "Close window"], + Close: ["닫기", "Close"], + Loading: ["불러오는 중", "Loading"], + LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"], + Start_Trial: ["먼저 볼 시험 로직", "Trial logics to start with"], + Start_Search: ["이름으로 찾기 — 예: 돌쌓기", "Search by name — e.g. stone wall"], + Start_Found: ["찾은 것", "Found"], + Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"], + Start_None: ["찾은 것 없음", "Nothing found"], + Start_Field: ["현장 일위대가", "Field unit prices"], + Start_FieldNone: ["아직 없음", "None yet"], + Start_Wizard: ["단계 안내로 새로 만들기", "Create step by step"], + Standard: ["표준", "Standard"], + Field: ["현장", "Field"], + Per: ["{u} 당", "per {u}"], + Name: ["이름", "Name"], + Applied: ["적용 품셈", "Applied standard"], + Undo: ["실행 취소", "Undo"], + Save: ["저장", "Save"], + CopyEdit: ["복사 편집", "Copy and edit"], + Copying: ["현장 일위대가로 복사하는 중", "Copying to a field unit price"], + Copied: ["현장 일위대가로 복사했음 — 이제 고칠 수 있음", "Copied — you can edit it now"], + Unsaved: ["저장 안 함", "Not saved"], + StandardNote: [ + "표준은 읽기만 — 고치려면 「복사 편집」으로 현장 일위대가를 만듦", + "Standard items are read-only — use “Copy and edit” to make a field copy", + ], + Col_Source: ["원문", "Source"], + Col_Basis: ["산출근거", "Basis"], + Col_Result: ["결과", "Result"], + View_Sentence: ["문장", "Sentences"], + View_Grid: ["격자", "Grid"], + Money: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"], + Value: ["결과 {v} {u}", "Result {v} {u}"], + Examples: ["예시값", "Example values"], + Examples_Note: [ + "예시값은 계산을 보려는 값 — 저장 안 됨", + "Example values are only for trying — not saved", + ], + Pick: ["선택", "Choose"], + Sheet: ["일위대가표", "Unit-cost sheet"], + NotYet: ["준비 중", "Coming soon"], + Leave_Ask: [ + "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", + "There are unsaved changes — close without saving?", + ], + Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], +} as const; + +type TextKey = keyof typeof TEXT; + +const te = (key: TextKey, vars: Record = {}): string => + Object.entries(vars).reduce( + (text, [k, v]) => text.replace(`{${k}}`, String(v)), + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, + ); + +/** 시작 화면에 먼저 보이는 시험 로직 여섯(정함 10) */ +const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133", "GC000051"]; +/** 찾은 것 목록에 한 번에 보이는 줄 수 */ +const SHOWN = 50; + +/** 「원/㎡」 → 「㎡」 */ +const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, ""); +/** 「㎡」 → 「1㎡」 · 「100㎡」 은 그대로 */ +const amountOf = (resultUnit: string): string => { + const unit = unitOf(resultUnit); + return /^\d/.test(unit) ? unit : `1${unit}`; +}; +const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); + +/** 고치는 화면이 쥔 것 — row = 저장할 로직 줄 · lb = 블록(못 읽으면 null) · tables = 고친 표 사본(저장 안 함) */ +interface Paper { + key: string; + version: string; + isBase: boolean; + row: LogicRow; + trial: Record; + lb: LbLogic | null; + tables: LbTables; +} + +/* ponytail: Blocks(sub7) · LB Api(sub4) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */ +const blocksOf = (row: LogicRow, key: string, version: string, isBase: boolean): LbLogic | null => { + try { + return readLogic(row, { key, version, isBase }); + } catch { + return null; + } +}; +const calcWith = ( + p: Paper, + onDone: (ans: CalcAnswer | null, plain: string | null) => void, +): void => { + try { + lbCalcLater(p.row, p.trial, p.tables, onDone); + } catch { + calcLater(p.row, p.trial, onDone); + } +}; + +/** 「쉬운 단가산출(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ +export function createLogicBuilderButton(): HTMLButtonElement { + return createButton({ label: te("Open"), variant: "filled", onClick: openLogicBuilder }); +} + +function openLogicBuilder(): void { + let body: HTMLElement; + let paper: Paper | null = null; + let dirty = false; + let logics: EasyListItem[] | null = null; + let query = ""; + let view: "sentence" | "grid" = "sentence"; + + const mayLeave = async (): Promise => + !paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); + + const notYet = (): void => showToast(te("NotYet"), "info"); + + /* ---------- 시작 화면 ---------- */ + + const listRow = (item: EasyListItem): HTMLElement => { + const row = el("button", { + className: "m01-lb__item", + attrs: { type: "button", title: item.where }, + children: [ + el("span", { className: "m01-lb__item-name", text: item.name }), + createTag(item.isBase ? te("Standard") : te("Field"), item.isBase ? "neutral" : "accent"), + el("span", { + className: "m01-lb__item-unit", + text: isMoney(item.resultUnit) + ? te("Per", { u: amountOf(item.resultUnit) }) + : item.resultUnit, + }), + ], + }); + row.addEventListener("click", () => void openPaper(item.key)); + return row; + }; + + const showStart = async (): Promise => { + paper = null; + dirty = false; + const status = el("p", { className: "m01-lb__muted", text: te("Loading") }); + const trialHost = el("div", { className: "m01-lb__list" }); + const fieldHost = el("div", { className: "m01-lb__list" }); + const foundHost = el("div", { className: "m01-lb__list" }); + const search = createInputField({ + type: "search", + placeholder: te("Start_Search"), + value: query, + onInput: (v) => { + query = v; + drawFound(); + }, + }); + + const drawFound = (): void => { + if (!logics) return; + const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + const hits = words.length + ? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w))) + : []; + status.textContent = !words.length + ? "" + : !hits.length + ? te("Start_None") + : hits.length > SHOWN + ? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN }) + : ""; + foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow)); + }; + + body.replaceChildren( + el("div", { + className: "m01-lb__start", + children: [ + el("div", { + className: "m01-lb__start-col", + children: [ + el("h4", { text: te("Start_Trial") }), + trialHost, + search.root, + el("h4", { text: te("Start_Found") }), + status, + foundHost, + ], + }), + el("div", { + className: "m01-lb__start-col", + children: [ + bigButton(te("Start_Wizard"), notYet), + el("h4", { text: te("Start_Field") }), + fieldHost, + ], + }), + ], + }), + ); + search.input.focus(); + + try { + logics ??= await listEasy(""); + } catch (error) { + status.textContent = te("LoadFailed", { v: plainError(error) }); + return; + } + const byKey = new Map(logics.map((x) => [x.key, x])); + trialHost.replaceChildren( + ...TRIAL_KEYS.flatMap((k) => { + const item = byKey.get(k); + return item ? [listRow(item)] : []; + }), + ); + const field = logics.filter((x) => !x.isBase); + fieldHost.replaceChildren( + ...(field.length + ? field.map(listRow) + : [el("p", { className: "m01-lb__muted", text: te("Start_FieldNone") })]), + ); + drawFound(); + }; + + /* ---------- 세 칸 화면 ---------- */ + + const openPaper = async (key: string): Promise => { + body.replaceChildren(el("p", { className: "m01-lb__muted", text: te("Loading") })); + try { + const opened = await openEasy(key); + const lb = blocksOf(opened.row, key, opened.version, opened.isBase); + paper = { key, ...opened, lb, tables: {} }; + dirty = false; + showPaper(); + } catch (error) { + showToast(plainError(error), "error"); + void showStart(); + } + }; + + const showPaper = (): void => { + const p = paper; + if (!p) return; + let ans: CalcAnswer | null = null; + let busy = false; + + const unsaved = createTag(te("Unsaved"), "warning"); + unsaved.hidden = !dirty; + const total = el("div", { className: "m01-lb__total" }); + const note = el("p", { className: "m01-lb__note", attrs: { role: "status" } }); + const sheetHost = el("div"); + const examplesHost = el("div", { className: "m01-lb__examples" }); + const sourceHost = el("div", { className: "m01-lb__slot" }); + const basisHost = el("div", { className: "m01-lb__slot" }); + + const name = createInputField({ label: te("Name"), value: p.row.이름 }); + name.input.disabled = true; // ponytail: 이름 고치기는 2걸음(Blocks · writeLogic) 에 이음 + name.root.classList.add("m01-lb__name"); + + const drawResult = (pending: boolean): void => { + const unit = p.row.결과단위 ?? ""; + const value = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined; + total.textContent = isMoney(unit) + ? te("Money", { u: amountOf(unit), v: value === undefined ? "—" : formatMoney(value) }) + : te("Value", { u: unit, v: value === undefined ? "—" : formatNumber(value) }); + total.classList.toggle("is-pending", pending); + sheetHost.classList.toggle("is-pending", pending); + renderSheet(sheetHost, { name: p.row.이름, resultUnit: unit }, ans); + }; + + const calc = (): void => { + drawResult(true); + calcWith(p, (next, plain) => { + ans = next; + note.textContent = plain ?? ""; + drawResult(false); + }); + }; + + const doCopy = async (): Promise => { + if (busy) return; + busy = true; + note.textContent = te("Copying"); + try { + const saved = await copyEasy(p.key); + paper = { + key: saved.key, + version: saved.version, + isBase: false, + row: saved.row, + trial: p.trial, + lb: blocksOf(saved.row, saved.key, saved.version, false), + tables: {}, + }; + logics = null; + showToast(te("Copied"), "success"); + showPaper(); + } catch (error) { + note.textContent = plainError(error); + } finally { + busy = false; + } + }; + + const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); + undo.disabled = true; + const actions = el("div", { + className: "m01-lb__actions", + children: [ + ...(p.isBase + ? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() })] + : [undo, createButton({ label: te("Save"), onClick: notYet })]), + createButton({ + label: te("Close"), + variant: "ghost", + onClick: () => + void (async () => { + if (await mayLeave()) void showStart(); + })(), + }), + ], + }); + + const viewButton = (which: "sentence" | "grid", label: string): HTMLButtonElement => { + const b = createButton({ + label, + variant: view === which ? "filled" : "ghost", + size: "sm", + onClick: () => { + view = which; + showPaper(); + }, + }); + b.setAttribute("aria-pressed", String(view === which)); + return b; + }; + + sourceHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") })); + basisHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") })); + drawExamples(examplesHost, p.row.입력 ?? [], p.trial, (next) => { + p.trial = next; + calc(); + }); + + body.replaceChildren( + el("div", { + className: "m01-lb__strip", + children: [ + name.root, + createTag(p.isBase ? te("Standard") : te("Field"), p.isBase ? "neutral" : "accent"), + el("span", { + className: "m01-lb__applied", + text: `${te("Applied")} · ${p.row.출처 || "—"}`, + }), + unsaved, + actions, + ], + }), + note, + ...(p.isBase ? [el("p", { className: "m01-lb__muted", text: te("StandardNote") })] : []), + el("div", { + className: "m01-lb__cols", + children: [ + column(te("Col_Source"), [sourceHost]), + column( + te("Col_Basis"), + [basisHost], + [viewButton("sentence", te("View_Sentence")), viewButton("grid", te("View_Grid"))], + ), + column(te("Col_Result"), [ + total, + examplesHost, + el("h5", { text: te("Sheet") }), + sheetHost, + ]), + ], + }), + ); + calc(); + }; + + // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 + openModal({ + title: te("Title"), + closeLabel: te("CloseWindow"), + dialogClass: "m01-lb", + beforeClose: mayLeave, + mount: (host) => { + body = host; + void showStart(); + }, + }); +} + +/** 세 칸 가운데 하나 — 머리(제목 · 단추) + 몸 */ +function column(title: string, children: HTMLElement[], tools: HTMLElement[] = []): HTMLElement { + return el("section", { + className: "m01-lb__col", + children: [ + el("div", { + className: "m01-lb__col-head", + children: [el("h4", { text: title }), ...tools], + }), + el("div", { className: "m01-lb__col-body", children }), + ], + }); +} + +/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */ +function drawExamples( + host: HTMLElement, + inputs: LogicInput[], + trial: Record, + onChange: (trial: Record) => void, +): void { + host.hidden = !inputs.length; + if (!inputs.length) return void host.replaceChildren(); + const state = { ...trial }; + const field = (spec: LogicInput): HTMLElement => { + const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름; + let box: HTMLInputElement | HTMLSelectElement; + if (spec.고르기?.length) { + box = el("select", { + className: "ui-select ui-select--sm", + children: [ + el("option", { attrs: { value: "" }, text: te("Pick") }), + ...spec.고르기.map((v) => el("option", { attrs: { value: String(v) }, text: String(v) })), + ], + }); + box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]); + box.addEventListener("change", () => { + const picked = spec.고르기?.find((v) => String(v) === box.value); + if (picked === undefined) delete state[spec.이름]; + else state[spec.이름] = picked; + onChange({ ...state }); + }); + } else { + box = el("input", { + className: "ui-input ui-input--sm", + attrs: { + type: "number", + ...(spec.범위 ? { min: String(spec.범위[0]), max: String(spec.범위[1]) } : {}), + }, + }); + box.value = state[spec.이름] == null ? "" : String(state[spec.이름]); + box.addEventListener("change", () => { + if (box.value === "") delete state[spec.이름]; + else state[spec.이름] = Number(box.value); + onChange({ ...state }); + }); + } + box.setAttribute("aria-label", spec.이름); + return el("label", { + className: "m01-lb__example", + children: [el("span", { text: label }), box], + }); + }; + host.replaceChildren( + el("strong", { text: te("Examples") }), + el("div", { className: "m01-lb__example-list", children: inputs.map(field) }), + el("p", { className: "m01-lb__muted", text: te("Examples_Note") }), + ); +} + +function bigButton(label: string, onClick: () => void): HTMLButtonElement { + const button = createButton({ label, variant: "filled", onClick }); + button.classList.add("m01-lb__big"); + return button; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 05942c5a5..934988e52 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -41,6 +41,8 @@ import { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New"; import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; // 쉬운 단가산출 시험 길 — 폴더째 지우면 이 두 줄도 지움 import { createEasyCalcButton } from "./EasyCalc/M01_EasyCalc_Entry"; +// 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움 +import { createLogicBuilderButton } from "./LogicBuilder/M01_LB_Entry"; import "./M01_MasterData_UI_Logic_Style.css"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ @@ -402,6 +404,8 @@ export async function mountM01LogicLab( createButton({ label: tn("Open"), variant: "filled", onClick: startNew }), // 쉬운 단가산출 시험 길 — 폴더째 지우면 이 두 줄도 지움 createEasyCalcButton(), + // 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createLogicBuilderButton(), createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, saveButton,