diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 236bc196..77ecbd8b 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -34,6 +34,8 @@ import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterDa import type { SideHandle } from "./M01_MasterData_UI_Side"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { buildLabDetail } from "./M01_MasterData_UI_LogicLab_Detail"; +import { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New"; +import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; import "./M01_MasterData_UI_Logic_Style.css"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ @@ -80,10 +82,12 @@ export async function mountM01LogicLab( ): Promise { let drafts = loadDrafts(); let opened: Opened | null = null; + let creating = false; // 「식으로 새로 만들기」 화면이 열려 있음 let calcInputs = ""; const editor = el("div", { className: "m01-logic__editor" }); const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } }); const calc = el("div", { className: "m01lab__calc" }); + const newHost = el("div", { className: "m01lab__new", attrs: { hidden: "" } }); const filterHost = el("div"); const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") }); let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음 @@ -165,10 +169,12 @@ export async function mountM01LogicLab( const drawEditor = (): void => { // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산 - const listing = !opened; + const listing = !opened && !creating; list.root.hidden = !listing || !loaded; waiting.hidden = loaded; - editor.hidden = back.hidden = listing; + editor.hidden = !opened; + back.hidden = listing; + newHost.hidden = !creating; if (!opened) return; if (!opened.row) { editor.replaceChildren( @@ -192,6 +198,7 @@ export async function mountM01LogicLab( const show = (next: Opened | null): void => { opened = next; + creating = false; errors.hidden = true; drawEditor(); drawCalc(); @@ -251,7 +258,7 @@ export async function mountM01LogicLab( restore: true, onPick: (sub, detail) => { list.setPick(sub, detail); - if (opened) show(null); + if (opened || creating) show(null); }, }), ); @@ -335,6 +342,29 @@ export async function mountM01LogicLab( } }; + /** 식으로 새로 만들기 — 저장하면 목록을 다시 받아 그 로직의 상세 화면을 엶 */ + const startNew = (): void => { + opened = null; + creating = true; + newHost.replaceChildren( + buildNewLogic({ + onSaved: (key) => { + void (async () => { + try { + await reload(); + await openKeyOn(key); + } catch (error) { + showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); + } + })(); + }, + onClose: () => show(null), + }), + ); + drawEditor(); + drawCalc(); + }; + const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() }); const discardButton = createButton({ label: tx("Bar_Discard"), @@ -347,6 +377,7 @@ export async function mountM01LogicLab( back, el("h2", { text: tx("Title") }), pending, + createButton({ label: tn("Open"), variant: "filled", onClick: startNew }), createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, saveButton, @@ -358,7 +389,7 @@ export async function mountM01LogicLab( children: [ el("main", { className: "m01-logic__main", - children: [bar, errors, waiting, list.root, editor], + children: [bar, errors, waiting, list.root, editor, newHost], }), ], }), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New.css b/M01_MasterData/M01_MasterData_UI_LogicLab_New.css new file mode 100644 index 00000000..bd23745e --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_New.css @@ -0,0 +1,105 @@ +/* M01 로직 개선 시험 — 식으로 새 로직 만들기 (좁은 화면에서도 한 칸으로 쌓임) */ +.m01lab-new { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.m01lab-new__step { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m01lab-new__text { + width: 100%; + min-height: 140px; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-bg); + color: var(--color-text); + font-family: ui-monospace, Consolas, monospace; + font-size: var(--text-body); + resize: vertical; +} + +.m01lab-new__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m01lab-new__vars { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m01lab-new__var { + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.m01lab-new__var-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8); +} + +.m01lab-new__var-body { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8); + margin-top: var(--spacing-8); +} + +.m01lab-new__wide { + flex: 1 1 220px; +} + +.m01lab-new__picked { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + min-width: 0; +} + +.m01lab-new__src { + display: flex; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-lg); + background: var(--color-paper); + overflow-x: auto; +} + +.m01lab-new__no { + flex: 0 0 auto; + color: var(--color-text-secondary); +} + +.m01lab-new__src mark { + background: var(--color-danger); + color: var(--color-bg); + border-radius: 2px; +} + +.m01lab-new__bad { + color: var(--color-danger); +} + +.m01lab-new__soft { + color: var(--color-text-secondary); +} + +.m01lab-new__calc { + min-width: 0; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New.ts new file mode 100644 index 00000000..eb828837 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_New.ts @@ -0,0 +1,272 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_New.ts + * 「로직 개선 시험」 — 식으로 새 로직 만들기 (PLAN 3-3 · 계약 `_화면_계약.md` 7장) + * ① 식을 줄로 적음 ② [분석] → 변수 표에서 변수마다 무엇인지·단위·비목을 고름 + * ③ 이름·결과 단위 ④ 시험 계산으로 값을 보고 저장(자체 로직 · 키 GX) + * 검사에 걸린 것은 그 줄·자리를 짚어 쉬운 말로 보임 — 서버 글 그대로(`문제`) + * ========================================================================== */ + +import { + createButton, + createInputField, + el, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { + ApiError, + draftLogic, + type DraftAnswer, + type DraftDecided, + type DraftHead, + type DraftProblem, + type DraftVar, +} from "./M01_MasterData_UI_Logic_Api"; +import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; +import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; +import { varRow, type VarRow } from "./M01_MasterData_UI_LogicLab_New_Var"; +import "./M01_MasterData_UI_LogicLab_New.css"; + +/** 보기 글 — 넓이 → 품 → 인부 한 줄 + 잡재료비 덧줄 */ +const SAMPLE = `# 보기 — 분석을 누른 뒤 변수마다 무엇인지 골라 주세요 +넓이 = 길이 * 폭 +인부 = 넓이 * 품 +잡재료비 = 노무비 * 0.05 +공사비 = 인부 * 보통인부 + 잡재료비 +`; + +export interface NewOptions { + /** 저장 끝 — 새 자체 로직의 키 */ + onSaved: (key: string) => void; + onClose: () => void; +} + +export function buildNewLogic(opts: NewOptions): HTMLElement { + const decided: Record = {}; + const rows = new Map(); + const values: Record = {}; + let analyzed = ""; // 분석한 식 — 그 뒤 글이 바뀌면 다시 분석하라고 알림 + let answer: DraftAnswer | null = null; + let calcSig = ""; + let seq = 0; + + const text = el("textarea", { + className: "m01lab-new__text", + attrs: { rows: "7", spellcheck: "false", "aria-label": tn("Step1") }, + }); + text.value = SAMPLE; + const stale = el("p", { + className: "m01-logic__muted", + text: tn("Stale"), + attrs: { hidden: "" }, + }); + const varsHost = el("div", { className: "m01lab-new__vars" }); + const problemHost = el("div", { className: "m01lab-new__problems" }); + const calcHost = el("div", { className: "m01lab__calc m01lab-new__calc" }); + const nameBox = createInputField({ + type: "text", + label: tn("Name"), + onInput: () => onEdit(), + }); + nameBox.input.setAttribute("data-key", "name"); + const unitBox = createInputField({ + type: "text", + label: tn("ResultUnit"), + placeholder: "원/㎡", + onInput: () => onEdit(), + }); + unitBox.input.setAttribute("data-key", "unit"); + const ownerBox = createInputField({ type: "text", label: tn("Owner") }); + const state = el("p", { className: "m01-logic__muted" }); + const save = createButton({ label: tn("Save"), onClick: () => void doSave() }); + save.setAttribute("data-act", "save"); + save.disabled = true; + + const head = (): DraftHead | undefined => { + const 이름 = nameBox.input.value.trim(); + const 결과단위 = unitBox.input.value.trim(); + return 이름 && 결과단위 ? { 이름, 결과단위, 출처: "자체" } : undefined; + }; + + /** 문제가 걸린 줄을 그대로 보이고 자리 글자에 표시 · 아래에 쉬운 말 목록 */ + const drawProblems = (problems: DraftProblem[]): void => { + if (!analyzed) return problemHost.replaceChildren(); + const lines = analyzed.split("\n"); + const flagged = new Map(); + for (const p of problems) { + if (p.줄) flagged.set(p.줄, [...(flagged.get(p.줄) ?? []), p]); + } + const shown = [...flagged.keys()] + .sort((a, b) => a - b) + .filter((n) => n <= lines.length) + .map((n) => { + const raw = lines[n - 1]; + const at = flagged.get(n)?.find((p) => p.자리 !== null)?.자리 ?? -1; + return el("div", { + className: "m01lab-new__src", + children: [ + el("span", { className: "m01lab-new__no", text: String(n) }), + at >= 0 + ? el("code", { + children: [ + raw.slice(0, at), + el("mark", { text: raw.slice(at, at + 1) }), + raw.slice(at + 1), + ], + }) + : el("code", { text: raw }), + ], + }); + }); + const items = problems.map((p) => + el("li", { + className: p.갈래 === "덜정함" ? "m01lab-new__soft" : "m01lab-new__bad", + attrs: { "data-kind": p.갈래 }, + text: `${p.줄 ? `${tn("Line", { n: p.줄 })} · ` : ""}${p.말}`, + }), + ); + problemHost.replaceChildren( + el("h4", { text: tn("Problems") }), + ...(items.length + ? [...shown, el("ul", { children: items })] + : [el("p", { className: "m01-logic__muted", text: tn("AllGood") })]), + ); + }; + + const drawCalc = (): void => { + const row = answer?.logic ?? null; + save.disabled = !row || (answer?.문제.length ?? 1) > 0; + state.textContent = !answer + ? "" + : answer.문제.some((p) => p.갈래 !== "덜정함") + ? tn("SaveBlocked") + : !head() + ? tn("NeedHead") + : answer.다됨 + ? "" + : tn("Undecided"); + if (!row) { + calcSig = ""; + calcHost.replaceChildren(); + return; + } + const sig = JSON.stringify(row.입력 ?? []); + if (sig === calcSig) return; // 입력 칸이 그대로면 넣은 값·결과를 안 지우려고 다시 안 그림 + calcSig = sig; + buildCalc(calcHost, { + savedKey: null, + row: { ...row, 키: row.키 ?? "" }, // 아직 번호 없는 새 초안 — 키 칸은 비워 보냄 + dirty: () => true, + values, + onLines: () => undefined, + }); + }; + + const drawVars = (vars: DraftVar[]): void => { + rows.clear(); + const shown = vars.filter((v) => v.갈래 !== "비목합"); + for (const name of Object.keys(decided)) { + if (!shown.some((v) => v.이름 === name)) delete decided[name]; + } + const result = answer?.결과 ?? ""; + const order = [ + ...shown.filter((v) => v.갈래 === "미정"), + ...shown.filter((v) => v.갈래 !== "미정"), + ]; + for (const v of order) rows.set(v.이름, varRow(v, v.이름 === result, { decided, onEdit })); + varsHost.replaceChildren(...[...rows.values()].map((r) => r.root)); + }; + + const evaluate = async (rebuild: boolean): Promise => { + if (!analyzed) return; + const mine = ++seq; + try { + const got = await draftLogic({ text: analyzed, decided, head: head() }); + if (mine !== seq) return; // 최신 응답만 + answer = got; + if (rebuild) drawVars(got.변수); + for (const v of got.변수) rows.get(v.이름)?.update(v); + drawProblems(got.문제); + drawCalc(); + } catch (error) { + showToast(error instanceof Error ? error.message : tn("Failed"), "error"); + } + }; + let timer = 0; + function onEdit(): void { + window.clearTimeout(timer); + timer = window.setTimeout(() => void evaluate(false), 250); + } + + const analyzeButton = createButton({ + label: tn("Analyze"), + onClick: () => { + analyzed = text.value; + stale.hidden = true; + void evaluate(true); + }, + }); + analyzeButton.setAttribute("data-act", "analyze"); + text.addEventListener("input", () => (stale.hidden = !analyzed || text.value === analyzed)); + + async function doSave(): Promise { + const h = head(); + if (!h) return void showToast(tn("NeedHead"), "info"); + save.disabled = true; + showLoadingOverlay(); // 서버가 파일을 검사하며 저장하느라 몇 초 걸림 + try { + const owner = ownerBox.input.value.trim(); + const done = await draftLogic({ + text: analyzed, + decided, + head: h, + save: true, + ...(owner ? { owner } : {}), + }); + showToast(tn("Saved", { v: done.key ?? "" }), "success"); + if (done.key) opts.onSaved(done.key); + } catch (error) { + const detail = error instanceof ApiError ? (error.detail as { 문제?: DraftProblem[] }) : null; + if (detail?.문제) drawProblems(detail.문제); + else showToast(error instanceof Error ? error.message : tn("Failed"), "error"); + save.disabled = false; + } finally { + hideLoadingOverlay(); + } + } + + const section = (title: string, ...children: HTMLElement[]): HTMLElement => + el("section", { + className: "m01lab__box m01lab-new__step", + children: [el("h3", { text: title }), ...children], + }); + + return el("div", { + className: "m01lab-new", + children: [ + el("div", { + className: "m01-logic__section-head", + children: [ + el("h2", { text: tn("Title") }), + createButton({ label: tn("Close"), variant: "ghost", onClick: opts.onClose }), + ], + }), + section( + tn("Step1"), + el("p", { className: "m01-logic__muted", text: tn("Guide") }), + text, + el("div", { className: "m01lab-new__row", children: [analyzeButton, stale] }), + ), + section(tn("Step2"), varsHost, problemHost), + section( + tn("Step3"), + el("div", { + className: "m01lab-new__row", + children: [nameBox.root, unitBox.root, ownerBox.root], + }), + ), + section(tn("Step4"), calcHost, state, save), + ], + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts new file mode 100644 index 00000000..08761a62 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts @@ -0,0 +1,218 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_New_Find.ts + * 새 로직 만들기의 고르기 창 둘 + * ① 마스터 요소 — 종류(인력·재료·기계) → 구분 → 상세구분 → 이름 찾기 → 한 줄 고르기 + * ② 표 찾기 — 표(소요량·계수)와 값 칸을 고른 뒤 표의 조건 칸마다 식을 적음 + * ========================================================================== */ + +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { + fetchFiles, + fetchRows, + fetchSubsAll, + type FileInfo, + type SubInfo, +} from "./M01_MasterData_Api_Fetch"; +import type { DraftFind } from "./M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; +import { loadTable } from "./M01_MasterData_UI_Logic_Note"; +import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; +import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; + +export interface PickedElement { + ref: string; + /** 이름 + 규격 */ + name: string; + /** 수량에 쓰는 단위(「원/일」 → 「일」) */ + unit: string; + group: string; +} + +const GROUPS = ["인력", "재료", "기계"]; +const SHOWN = 50; + +/** 창 뼈대 — 제목 · 닫기 · 몸통 */ +function shell(title: string, body: HTMLElement[]): { close: () => void } { + const close = (): void => backdrop.remove(); + const dialog = el("div", { + className: "m01-logic__pick", + attrs: { role: "dialog", "aria-label": title }, + children: [ + el("div", { + className: "m01-logic__section-head", + children: [ + el("h3", { text: title }), + createButton({ label: tn("Close"), variant: "ghost", onClick: close }), + ], + }), + ...body, + ], + }); + const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] }); + backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); + backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); + document.body.append(backdrop); + return { close }; +} + +/** 그룹의 파일 — 재료는 자재품목(구분·상세구분이 있는 것) */ +async function fileOf(group: string): Promise { + const files = await fetchFiles(group); + return group === "재료" ? (files.find((f) => f.file.includes("자재품목")) ?? files[0]) : files[0]; +} + +const options = (values: string[]): { value: string; text: string }[] => [ + { value: "", text: tn("All") }, + ...values.map((v) => ({ value: v, text: v })), +]; + +export function openElementPick(done: (picked: PickedElement) => void): void { + let file: FileInfo | undefined; + let subs: SubInfo[] = []; + const groupBox = createSelectField({ + options: GROUPS.map((g) => ({ value: g, text: g })), + value: GROUPS[0], + compact: true, + label: tn("Group"), + }); + const subBox = createSelectField({ + options: options([]), + value: "", + compact: true, + label: tn("Sub"), + }); + const detailBox = createSelectField({ + options: options([]), + value: "", + compact: true, + label: tn("Detail"), + }); + const searchField = createInputField({ type: "search", placeholder: tn("Search") }); + const count = el("span", { className: "m01-logic__muted" }); + const list = el("div", { className: "m01-logic__pick-list" }); + const win = shell(tn("K_master"), [ + el("div", { + className: "m01-logic__pick-bar", + children: [groupBox.root, subBox.root, detailBox.root, searchField.root, count], + }), + list, + ]); + + let seq = 0; + const load = async (): Promise => { + if (!file) return; + const mine = ++seq; + try { + const got = await fetchRows( + file.file, + 1, + SHOWN, + searchField.input.value.trim(), + false, + subBox.select.value, + detailBox.select.value, + ); + if (mine !== seq) return; + count.textContent = tn("Count", { n: got.total, m: Math.min(got.total, SHOWN) }); + list.replaceChildren( + ...got.rows.map((row) => { + const name = `${row["이름"] ?? ""} ${row["규격"] ?? ""}`.trim(); + const unitText = String(row["단위"] ?? ""); + const unit = groupBox.select.value === "인력" ? "인" : (unitText.split("/").pop() ?? ""); + const button = el("button", { + className: "m01-logic__pick-row m01-logic__pick-row--button", + attrs: { type: "button", "data-ref": String(row["키"]) }, + children: [ + el("div", { + className: "m01-logic__stack", + children: [ + el("strong", { text: name }), + el("span", { className: "m01-logic__muted", text: String(row["키"]) }), + ], + }), + el("span", { + className: "m01-logic__money", + text: `${formatNumber(row["값"])} ${unitText}`.trim(), + }), + ], + }); + button.addEventListener("click", () => { + win.close(); + done({ ref: String(row["키"]), name, unit, group: groupBox.select.value }); + }); + return button; + }), + ); + } catch (error) { + showToast(error instanceof Error ? error.message : tn("Failed"), "error"); + } + }; + + const changeGroup = async (): Promise => { + file = await fileOf(groupBox.select.value); + subs = file ? (await fetchSubsAll({ file: file.file })).subs : []; + subBox.setOptions(options(subs.map((s) => s.name)), ""); + detailBox.setOptions(options([]), ""); + detailBox.select.disabled = true; + await load(); + }; + groupBox.select.addEventListener("change", () => void changeGroup()); + subBox.select.addEventListener("change", () => { + const details = subs.find((s) => s.name === subBox.select.value)?.details ?? []; + detailBox.setOptions(options(details), ""); + detailBox.select.disabled = details.length === 0; + void load(); + }); + detailBox.select.addEventListener("change", () => void load()); + let timer = 0; + searchField.input.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void load(), 250); + }); + void changeGroup(); +} + +/** 표 찾기 — 표·값 칸을 고른 뒤 조건 칸마다 식을 받음 · 표의 조건 칸이 없으면 바로 끝 */ +export function openTablePick(done: (find: DraftFind, label: string) => void): void { + openPicker("table", "소요량", (item, column) => { + void (async () => { + const table = await loadTable(item.ref); + const conds = Object.keys(table?.조건 ?? {}); + const label = `${item.이름 ?? item.ref} · ${item.ref} → ${column ?? ""}`; + const find = { 표: item.ref, 값칸: column ?? "", 조건: {} as Record }; + if (!conds.length) { + done(find, label); + return; + } + const inputs = conds.map((c) => { + const box = createInputField({ type: "text", value: c }); + box.input.setAttribute("data-cond", c); + return { c, box }; + }); + const win = shell(tn("TableTitle"), [ + el("p", { className: "m01-logic__muted", text: label }), + el("p", { text: tn("Cond") }), + ...inputs.map(({ c, box }) => + el("label", { + className: "m01-logic__field", + children: [el("span", { text: c }), box.root], + }), + ), + createButton({ + label: tn("Ok"), + onClick: () => { + for (const { c, box } of inputs) find.조건[c] = box.input.value.trim() || c; + win.close(); + done(find, label); + }, + }), + ]); + })(); + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts new file mode 100644 index 00000000..272d234f --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts @@ -0,0 +1,71 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_New_Text.ts + * 「식으로 새 로직 만들기」 글자 — [한국어, 영어] + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +const TEXT = { + Open: ["식으로 새로 만들기", "New from formula"], + Title: ["식으로 새 로직 만들기", "New logic from a formula"], + Close: ["닫기", "Close"], + Step1: ["① 식 적기", "① Write the formula"], + Guide: [ + "한 줄에 「이름 = 식」 · 마지막 줄 왼쪽이 이 로직의 결과 · 「#」 로 시작한 줄은 건너뜀", + "One line each as “name = formula” · the last line's left side is the result · lines starting with # are skipped", + ], + Analyze: ["분석", "Analyze"], + Stale: ["식이 바뀌었음 — 다시 [분석] 을 눌러 주세요", "Formula changed — press Analyze again"], + Step2: ["② 변수 정하기", "② Decide each variable"], + Step3: ["③ 이름·단위", "③ Name and unit"], + Step4: ["④ 시험 계산 · 저장", "④ Trial calculation and save"], + Problems: ["고칠 곳", "To fix"], + Line: ["{n}번 줄", "Line {n}"], + AllGood: ["검사에 걸린 것 없음", "No problems found"], + Result: ["결과", "Result"], + Undecided: ["아직 정하지 않은 칸이 있음", "Some cells are not decided yet"], + Kind: ["무엇인가", "What is it"], + K_master: ["마스터 요소", "Master element"], + K_input: ["설계 입력", "Design input"], + K_fixed: ["고정값", "Fixed value"], + K_table: ["표 찾기", "Table lookup"], + Pick: ["고르기", "Pick"], + Repick: ["다시 고르기", "Pick again"], + NotPicked: ["아직 안 골랐음", "Not picked"], + Unit: ["단위", "Unit"], + Cost: ["비목", "Cost item"], + CostNone: ["넣지 않음", "None"], + CostExtra: ["덧줄 비목", "Extra-line cost item"], + Value: ["값", "Value"], + Note: ["설명", "Note"], + Derived: ["식으로 정해짐", "Set by the formula"], + SumName: ["비목 합 — 덧줄에서만 쓰는 이름", "Cost total — only for extra lines"], + Name: ["로직 이름", "Logic name"], + ResultUnit: ["결과 단위", "Result unit"], + Owner: ["소유", "Owner"], + Save: ["저장", "Save"], + Saved: ["저장했음 — 「{v}」", "Saved — “{v}”"], + SaveBlocked: ["위 고칠 곳을 먼저 고치세요", "Fix the problems above first"], + NeedHead: ["이름과 결과 단위를 적어야 시험 계산·저장이 됨", "Enter the name and result unit"], + Group: ["종류", "Kind"], + Sub: ["구분", "Group"], + Detail: ["상세구분", "Detail"], + All: ["(전체)", "(All)"], + Search: ["이름 찾기", "Search name"], + Count: ["{n} 개 중 앞 {m} 개", "first {m} of {n}"], + TableTitle: ["표 찾기 — 표와 조건", "Table lookup — table and conditions"], + TablePick: ["표 고르기", "Pick a table"], + TableCol: ["값 칸", "Value column"], + Cond: ["조건 — 표의 칸마다 식(설계 값 이름)", "Conditions — a formula per table column"], + Ok: ["확인", "OK"], + Cancel: ["취소", "Cancel"], + Failed: ["못 함", "Failed"], +} as const satisfies Record; + +export type NewTextKey = keyof typeof TEXT; + +export function tn(key: NewTextKey, 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_LogicLab_New_Var.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts new file mode 100644 index 00000000..7cfb70d1 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts @@ -0,0 +1,178 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_New_Var.ts + * 변수 한 칸 — 무엇인지(마스터 요소 / 설계 입력 / 고정값 / 표 찾기) · 단위 · 비목을 고름 + * 고른 것은 `decided[이름]` 에 쌓음(서버 계약 `_화면_계약.md` 7장의 `decided` 그대로) + * ========================================================================== */ + +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import type { DraftDecided, DraftVar } from "./M01_MasterData_UI_Logic_Api"; +import { openElementPick, openTablePick } from "./M01_MasterData_UI_LogicLab_New_Find"; +import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; + +export interface VarCtx { + decided: Record; + /** 고른 것이 바뀜 — 화면이 검사를 다시 부름 */ + onEdit: () => void; +} +export interface VarRow { + root: HTMLElement; + /** 서버가 다시 준 변수(식으로 정해진 단위 등)를 반영 */ + update: (v: DraftVar) => void; +} + +const KINDS: [string, () => string][] = [ + ["마스터요소", () => tn("K_master")], + ["설계입력", () => tn("K_input")], + ["고정값", () => tn("K_fixed")], + ["표찾기", () => tn("K_table")], +]; +const COSTS = ["노무비", "재료비", "경비"]; +/** 요소 종류 → 비목 첫 제안 */ +const COST_OF: Record = { 인력: "노무비", 재료: "재료비", 기계: "경비" }; + +const costOptions = (): { value: string; text: string }[] => [ + { value: "", text: tn("CostNone") }, + ...COSTS.map((c) => ({ value: c, text: c })), +]; + +const field = (label: string, control: HTMLElement): HTMLElement => + el("label", { + className: "m01-logic__field", + children: [el("span", { text: label }), control], + }); + +export function varRow(v: DraftVar, isResult: boolean, ctx: VarCtx): VarRow { + const name = v.이름; + const info = el("span", { className: "m01-logic__muted m01lab-new__derived" }); + const body = el("div", { className: "m01lab-new__var-body" }); + const root = el("div", { + className: "m01lab-new__var", + attrs: { "data-var": name }, + children: [ + el("div", { + className: "m01lab-new__var-head", + children: [el("strong", { text: name }), info], + }), + body, + ], + }); + const now = (): DraftDecided => (ctx.decided[name] ??= {}); + const set = (patch: DraftDecided): void => { + ctx.decided[name] = { ...now(), ...patch }; + for (const [k, val] of Object.entries(ctx.decided[name])) { + if (val === "" || val === undefined) delete (ctx.decided[name] as Record)[k]; + } + ctx.onEdit(); + }; + const text = (label: string, key: "단위" | "값" | "설명", wide = false): HTMLElement => { + const box = createInputField({ + type: "text", + value: now()[key] ?? "", + onInput: (val) => set({ [key]: val.trim() }), + }); + box.input.setAttribute("data-key", key); + const cell = field(label, box.root); + if (wide) cell.classList.add("m01lab-new__wide"); + return cell; + }; + + let picked = ""; // 마스터 요소·표 찾기에서 고른 것의 글 + const draw = (): void => { + const kind = now().무엇 ?? ""; + if (v.갈래 === "비목합") { + body.replaceChildren(el("span", { className: "m01-logic__muted", text: tn("SumName") })); + return; + } + if (v.갈래 === "정의됨") { + if (isResult) return body.replaceChildren(); + const cost = createSelectField({ + options: costOptions(), + value: now().비목 ?? "", + compact: true, + label: tn("CostExtra"), + onChange: (val) => set({ 비목: val }), + }); + body.replaceChildren(cost.root); + return; + } + const kindBox = createSelectField({ + options: [ + { value: "", text: "—" }, + ...KINDS.map(([value, label]) => ({ value, text: label() })), + ], + value: kind, + compact: true, + label: tn("Kind"), + onChange: (val) => { + ctx.decided[name] = val ? { 무엇: val, ...(now().단위 ? { 단위: now().단위 } : {}) } : {}; + picked = ""; + draw(); + ctx.onEdit(); + }, + }); + const parts: HTMLElement[] = [kindBox.root]; + if (kind === "마스터요소") { + const cost = createSelectField({ + options: costOptions(), + value: now().비목 ?? "", + compact: true, + label: tn("Cost"), + onChange: (val) => set({ 비목: val }), + }); + const choose = createButton({ + label: now().요소 ? tn("Repick") : tn("Pick"), + variant: "ghost", + onClick: () => + openElementPick((p) => { + picked = `${p.name} · ${p.ref}`; + set({ 요소: p.ref, 단위: p.unit, 비목: COST_OF[p.group] ?? now().비목 }); + draw(); + }), + }); + choose.setAttribute("data-act", "pick-element"); + parts.push( + el("div", { + className: "m01lab-new__picked", + children: [choose, el("span", { text: picked || now().요소 || tn("NotPicked") })], + }), + text(tn("Unit"), "단위"), + cost.root, + ); + } else if (kind === "설계입력") { + parts.push(text(tn("Unit"), "단위"), text(tn("Note"), "설명", true)); + } else if (kind === "고정값") { + parts.push(text(tn("Value"), "값"), text(tn("Unit"), "단위")); + } else if (kind === "표찾기") { + const choose = createButton({ + label: now().찾기 ? tn("Repick") : tn("TablePick"), + variant: "ghost", + onClick: () => + openTablePick((find, label) => { + picked = label; + set({ 찾기: find }); + draw(); + }), + }); + choose.setAttribute("data-act", "pick-table"); + parts.push( + el("div", { + className: "m01lab-new__picked", + children: [choose, el("span", { text: picked || tn("NotPicked") })], + }), + text(tn("Unit"), "단위"), + ); + } + body.replaceChildren(...parts); + }; + draw(); + + const update = (next: DraftVar): void => { + v = next; + info.textContent = + next.갈래 === "정의됨" + ? `${tn("Derived")}${next.단위 ? ` · ${next.단위}` : ""}` + : (next.단위 ?? ""); + }; + update(v); + return { root, update }; +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index f2c06d1e..4a8ce52b 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -214,3 +214,66 @@ export const copyLogic = ( key: string, ): Promise<{ file: string; version: string; key: string; logic: LogicRow }> => request("/logic/copy", { key }); + +/* ── 식으로 새 로직 만들기(계약 7장) ── */ +export interface DraftFind { + 표: string; + 값칸: string; + 조건: Record; +} +/** 변수마다 정한 것 */ +export interface DraftDecided { + 무엇?: string; + 단위?: string; + 비목?: string; + 요소?: string; + 찾기?: DraftFind; + 값?: string; + 설명?: string; +} +export interface DraftVar { + 이름: string; + 처음: number; + 갈래: "정의됨" | "미정" | "비목합"; + 정의줄: number | null; + 쓰임: number[]; + 무엇: string | null; + 단위: string | null; + 비목: string | null; + 정할것: string[]; +} +export interface DraftProblem { + 줄: number | null; + 자리: number | null; + 갈래: string; + 말: string; +} +export interface DraftAnswer { + 줄: { 줄: number; 이름: string; 식: string; 앞: number }[]; + 변수: DraftVar[]; + 결과: string; + 문제: DraftProblem[]; + 다됨: boolean; + logic: LogicRow | null; + /** 저장했을 때만 */ + file?: string; + version?: string; + key?: string; +} +export interface DraftHead { + 이름: string; + 결과단위: string; + 원문번호?: string; + 출처?: string; + 비고?: string; + 끝수?: string; +} + +/** 검사·미리보기 · `save` 면 자체 로직(키 `GX`)으로 저장 — 문제가 있으면 422 */ +export const draftLogic = (body: { + text: string; + decided: Record; + head?: DraftHead; + save?: boolean; + owner?: string; +}): Promise => request("/logic/draft", body); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index e01d490f..9582a37b 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -19,7 +19,8 @@ import { tx } from "./M01_MasterData_UI_Logic_Text"; export interface CalcContext { /** 저장된 키 — 새 로직은 null */ savedKey: string | null; - file: string; + /** 저장 전 새 초안이면 비움 — 서버가 임시 자리에 얹어 셈 */ + file?: string; row: LogicRow; dirty: () => boolean; /** 로직마다 넣은 값 — 다시 그려도 남음 */