diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index a74a98bb9..8bef8f3c8 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -1,13 +1,13 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab.ts - * 관리자 화면 — 로직 칸. 가운데 단가산출 상세(호표) 고치기 / 오른쪽 시험 계산 + * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 단가산출 상세(호표) · 시험 계산 / 오른쪽 흐름도 * 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안 * * 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`. * 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임. * ========================================================================== */ -import { currentLanguageIndex, t as L } from "@ui/ui_template_locale"; +import { t as L } from "@ui/ui_template_locale"; import { createButton, el, @@ -37,12 +37,7 @@ import { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail" import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal"; import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; -import { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New"; -import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; -// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 -import { createCostFlowButton } from "../M02_MasterTemplete/CostFlow/M02_CF_Entry"; -// 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움 -import { createDataFlowButton } from "../M02_MasterTemplete/DataFlow/M02_DF_Entry"; +import { buildSplit } from "./M01_MasterData_UI_LogicLab_Flow"; import "./M01_MasterData_UI_Logic_Style.css"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ @@ -72,49 +67,6 @@ interface Opened extends Draft { const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); -/** 시험판 묶음 — 「시험판 ▾」 단추 하나 · 누르면 시험판 단추들이 목록으로(바깥 · Esc · 고르면 닫힘) */ -function trialMenu(items: HTMLElement[]): HTMLElement { - const list = el("div", { attrs: { role: "menu" }, children: items }); - Object.assign(list.style, { - position: "absolute", - top: "calc(100% + 4px)", - left: "0", - zIndex: "20", - display: "none", - flexDirection: "column", - gap: "var(--spacing-4)", - padding: "var(--spacing-8)", - border: "1px solid var(--color-border)", - borderRadius: "var(--radius-lg)", - background: "var(--color-surface-raised)", - boxShadow: "var(--shadow-lg)", - whiteSpace: "nowrap", - }); - const shut = (): void => { - list.style.display = "none"; - button.setAttribute("aria-expanded", "false"); - document.removeEventListener("click", outside, true); - }; - const outside = (ev: Event): void => { - if (!wrap.contains(ev.target as Node)) shut(); - }; - const button = createButton({ - label: ["시험판 ▾", "Trials ▾"][currentLanguageIndex === 1 ? 1 : 0], - variant: "ghost", - onClick: () => { - if (list.style.display !== "none") return shut(); - list.style.display = "flex"; - button.setAttribute("aria-expanded", "true"); - document.addEventListener("click", outside, true); - }, - }); - button.setAttribute("aria-haspopup", "menu"); - list.addEventListener("click", () => shut()); // 고른 시험판은 제 단추가 엶 - const wrap = el("div", { children: [button, list] }); - Object.assign(wrap.style, { position: "relative", display: "inline-flex" }); - wrap.addEventListener("keydown", (ev) => ev.key === "Escape" && shut()); - return wrap; -} const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; function loadDrafts(): Record { @@ -137,13 +89,13 @@ export async function mountM01LogicLab( ): Promise { let drafts = loadDrafts(); let opened: Opened | null = null; - let creating = false; // 「식으로 새로 만들기」 화면이 열려 있음 let calcInputs = ""; let recalc: number | undefined; const editor = el("div", { className: "m01-logic__editor" }); + // 로직 상세 두 칸 — 왼쪽 지금 내용 · 오른쪽 흐름도(패널 자리) · 너비 비율 기억 + const split = buildSplit(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 줄」 빈 표를 보이지 않음 @@ -233,12 +185,11 @@ export async function mountM01LogicLab( const drawEditor = (): void => { // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산 - const listing = !opened && !creating; + const listing = !opened; list.root.hidden = !listing || !loaded; waiting.hidden = loaded; - editor.hidden = !opened; + split.root.hidden = !opened; back.hidden = listing; - newHost.hidden = !creating; if (!opened) return; if (!opened.row) { editor.replaceChildren( @@ -264,7 +215,6 @@ export async function mountM01LogicLab( const show = (next: Opened | null): void => { opened = next; - creating = false; errors.hidden = true; drawEditor(); drawCalc(); @@ -326,7 +276,7 @@ export async function mountM01LogicLab( restore: true, onPick: (sub, detail) => { list.setPick(sub, detail); - if (opened || creating) show(null); + if (opened) show(null); }, }), ); @@ -410,65 +360,36 @@ 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"), variant: "ghost", onClick: () => void onDiscard(), }); + const copyButton = createButton({ + label: tl("Copy_Open"), + variant: "ghost", + onClick: () => + opened?.origKey + ? void openCopyScreen({ + key: opened.origKey, + afterSave: async (newKey) => { + await reload(); + await open(logicId("자체", newKey), "자체", newKey); + }, + }) + : showToast(tl("Copy_NeedOpen"), "info"), + }); const bar = el("div", { className: "m01-logic__bar", children: [ back, el("h2", { text: tx("Title") }), pending, - createButton({ label: tn("Open"), variant: "filled", onClick: startNew }), - trialMenu([ - // 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createCostFlowButton(), - // 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createDataFlowButton(), - ]), + copyButton, createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, saveButton, - createButton({ - label: tl("Copy_Open"), - variant: "ghost", - onClick: () => - opened?.origKey - ? void openCopyScreen({ - key: opened.origKey, - afterSave: async (newKey) => { - await reload(); - await open(logicId("자체", newKey), "자체", newKey); - }, - }) - : showToast(tl("Copy_NeedOpen"), "info"), - }), ], }); host.replaceChildren( @@ -477,7 +398,7 @@ export async function mountM01LogicLab( children: [ el("main", { className: "m01-logic__main", - children: [bar, errors, waiting, list.root, editor, newHost], + children: [bar, errors, waiting, list.root, split.root], }), ], }), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css new file mode 100644 index 000000000..64a4c5500 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css @@ -0,0 +1,40 @@ +/* 로직 상세 두 칸 — 왼쪽 지금 내용 · 끌기 막대 · 오른쪽 흐름도 */ +.m01lab-flow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: start; + gap: 0; +} + +.m01lab-flow[hidden] { + display: none; +} + +.m01lab-flow__left, +.m01lab-flow__right { + min-width: 0; +} + +.m01lab-flow__bar { + align-self: stretch; + width: 8px; + margin: 0 var(--spacing-4); + border-radius: var(--radius-sm); + background: var(--color-border); + cursor: col-resize; + touch-action: none; +} + +.m01lab-flow__bar:hover, +.m01lab-flow__bar:focus-visible { + background: var(--color-primary); +} + +.m01lab-flow__wait { + margin: 0; + padding: var(--spacing-16); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-secondary); + text-align: center; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts new file mode 100644 index 000000000..15c729006 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts @@ -0,0 +1,95 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Flow.ts + * 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 자리(패널은 LogicLab 이 붙임) + * 너비 비율은 브라우저에 기억(localStorage · 막히면 기본 반반). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 5 · 10 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import "./M01_MasterData_UI_LogicLab_Flow.css"; + +const TEXT = { + Resize: ["두 칸 너비 — 끌어서 조절", "Drag to resize the two columns"], + Waiting: ["흐름도 자리", "Flow diagram"], +} as const; + +export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +const STORE = "m01.logic.flowRatio"; +const MIN = 0.25; +const MAX = 0.75; + +const clamp = (r: number): number => Math.min(MAX, Math.max(MIN, r)); + +function readRatio(): number { + try { + const r = Number(localStorage.getItem(STORE)); + return r ? clamp(r) : 0.5; + } catch { + return 0.5; + } +} + +export interface SplitHandle { + root: HTMLElement; + /** 오른쪽 칸 — 흐름도 패널을 붙이는 곳 */ + right: HTMLElement; +} + +/** 두 칸 만들기 — left 는 지금 화면 그대로 왼쪽에 둠 */ +export function buildSplit(left: HTMLElement): SplitHandle { + const right = el("div", { + className: "m01lab-flow__right", + children: [el("p", { className: "m01lab-flow__wait", text: tf("Waiting") })], + }); + const bar = el("div", { + className: "m01lab-flow__bar", + attrs: { + role: "separator", + "aria-orientation": "vertical", + "aria-label": tf("Resize"), + title: tf("Resize"), + tabindex: "0", + }, + }); + const leftCol = el("div", { className: "m01lab-flow__left", children: [left] }); + const root = el("div", { className: "m01lab-flow", children: [leftCol, bar, right] }); + let ratio = readRatio(); + const apply = (): void => { + root.style.gridTemplateColumns = `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)`; + }; + const keep = (): void => { + try { + localStorage.setItem(STORE, String(ratio)); + } catch { + /* 기억 못 해도 지금 화면은 그대로 */ + } + }; + bar.addEventListener("pointerdown", (ev) => { + if (ev.button !== 0) return; + ev.preventDefault(); + const box = root.getBoundingClientRect(); + const move = (m: PointerEvent): void => { + ratio = clamp((m.clientX - box.left) / box.width); + apply(); + }; + const up = (): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + keep(); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }); + // 키보드 — 왼쪽 · 오른쪽 화살표로 조금씩 + bar.addEventListener("keydown", (ev) => { + const step = ev.key === "ArrowLeft" ? -0.05 : ev.key === "ArrowRight" ? 0.05 : 0; + if (!step) return; + ev.preventDefault(); + ratio = clamp(ratio + step); + apply(); + keep(); + }); + apply(); + return { root, right }; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New.css b/M01_MasterData/M01_MasterData_UI_LogicLab_New.css deleted file mode 100644 index bd23745e2..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New.css +++ /dev/null @@ -1,105 +0,0 @@ -/* 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 deleted file mode 100644 index 7cc035b22..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New.ts +++ /dev/null @@ -1,272 +0,0 @@ -/* ============================================================================= - * 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 deleted file mode 100644 index 08761a627..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts +++ /dev/null @@ -1,218 +0,0 @@ -/* ============================================================================= - * 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 deleted file mode 100644 index 272d234fb..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* ============================================================================= - * 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 deleted file mode 100644 index 7cfb70d18..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* ============================================================================= - * 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_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 29a081d9a..283d7a063 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -81,7 +81,7 @@ const TEXT = { "지금 값으로는 걸린 줄이 없음 — 값을 바꿔 보거나, 이 표를 안 쓰는 갈래일 수 있음", "No row matches the current values — change them, or this table is on an unused branch", ], - Copy_Open: ["본떠 만들기", "Copy as mine"], + Copy_Open: ["로직 복사/생성", "Copy / create logic"], Copy_NeedOpen: ["먼저 로직을 고르세요", "Pick a logic first"], Copy_Title: ["복사해서 만들기", "Copy to edit"], Copy_Name: ["새 이름", "New name"],