diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 8bef8f3c8..14210902b 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -19,6 +19,7 @@ import { import { ApiError, fetchLogic, + fetchLogicFiles, fetchLogics, fetchLogicSubs, saveFiles, @@ -37,7 +38,17 @@ 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 { buildSplit } from "./M01_MasterData_UI_LogicLab_Flow"; +import { + buildSplit, + emptyFlow, + fetchFlow, + mountLogicFlow, + tf, + type LogicFlow, +} from "./M01_MasterData_UI_LogicLab_Flow"; +import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; import "./M01_MasterData_UI_Logic_Style.css"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ @@ -62,8 +73,14 @@ interface Opened extends Draft { lastText?: TextAnswer; ownHost: HTMLElement; values: Record; + /** 흐름 로직의 왼쪽 「지금 내용」 자리 — 흐름도 왼쪽 보기가 그림 */ + flowHost: HTMLElement; } +/** 줄의 흐름(있으면 흐름도가 정본) */ +const flowOf = (row: LogicRow | null): CfLogic | null => (row?.흐름 as CfLogic | undefined) ?? null; +const OWN_FILE = "로직_자체.json"; + const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); @@ -90,6 +107,11 @@ export async function mountM01LogicLab( let drafts = loadDrafts(); let opened: Opened | null = null; let calcInputs = ""; + /** 오른쪽 흐름도 패널 — 로직을 열 때마다 새로 */ + let flowNow: LogicFlow | null = null; + const admin = await fetchSessionUser() + .then((u) => u?.role === "SYSTEM_ADMIN") + .catch(() => false); let recalc: number | undefined; const editor = el("div", { className: "m01-logic__editor" }); // 로직 상세 두 칸 — 왼쪽 지금 내용 · 오른쪽 흐름도(패널 자리) · 너비 비율 기억 @@ -210,14 +232,54 @@ export async function mountM01LogicLab( ownHost: current.ownHost, onChange: touch, calcHost: calc, + flowHost: flowOf(current.row) ? current.flowHost : undefined, }); + flowNow?.redrawLeft(); + }; + + /** 오른쪽 흐름도 — 줄의 흐름 · 없으면 서버 변환(흐름 받기) · 그것도 없으면 빈 흐름 */ + const startFlow = async (o: Opened): Promise => { + const row = o.row; + if (!row) return; + split.right.replaceChildren(el("p", { className: "m01lab-flow__wait", text: tf("Waiting") })); + const own = flowOf(row); + const got = own || o.origKey === null ? null : await fetchFlow(o.origKey, o.file); + const flow = own ?? got?.flow ?? (await emptyFlow(row.이름, row.결과단위)); + if (opened !== o) return; + try { + const made = await mountLogicFlow({ + split, + flow: clone(flow), + title: row.이름 || row.키, + readOnly: !admin, + notes: got?.notes ?? [], + converted: !!got?.converted, + leftHost: () => (flowOf(o.row) ? o.flowHost : null), + onChange: (lg) => { + if (!o.row || opened !== o) return; + const was = flowOf(o.row); + o.row.흐름 = lg; + touch(); + if (!was) drawEditor(); // 이제 흐름 로직 — 왼쪽을 흐름 보기로 + }, + }); + if (opened !== o) return made.destroy(); + flowNow = made; + } catch (e) { + split.right.replaceChildren( + el("p", { className: "m01-logic__muted", text: tf("Failed", String(e)) }), + ); + } }; const show = (next: Opened | null): void => { + flowNow?.destroy(); + flowNow = null; opened = next; errors.hidden = true; drawEditor(); drawCalc(); + if (next?.row) void startFlow(next); }; const open = async (id: string, sub: string, key: string): Promise => { @@ -233,6 +295,7 @@ export async function mountM01LogicLab( text: null, ownHost: ownHost(), values: {}, + flowHost: el("div"), }); return; } @@ -252,6 +315,7 @@ export async function mountM01LogicLab( text: null, ownHost: ownHost(), values: {}, + flowHost: el("div"), }); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); @@ -330,12 +394,18 @@ export async function mountM01LogicLab( } showLoadingOverlay(); try { - await saveFiles([...byFile.values()]); + // 키 없는 새 줄은 서버가 새 키를 줌(added) — 저장 뒤 그 로직을 엶 + const saved = (await saveFiles([...byFile.values()])) as { + added?: { file: string; key: string }[]; + }; 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.키); + const newKey = was?.row && !was.row.키 ? saved.added?.at(-1)?.key : undefined; + const made = newKey ? allLogics.find((x) => x.키 === newKey) : undefined; + if (made) await open(logicId(made.구분, made.키), made.구분, made.키); + else if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키); else show(null); } catch (error) { failed(error); @@ -360,6 +430,28 @@ export async function mountM01LogicLab( } }; + const onNew = async (): Promise => { + const units = [...new Set(allLogics.map((x) => x.결과단위).filter(Boolean))]; + const info = await askBasicInfo(units.length ? units : ["원"]); + if (!info) return; + try { + const own = (await fetchLogicFiles()).find((f) => f.file === OWN_FILE); + const row: LogicRow = { + 키: "", + ...info, + 입력: [], + 중간: [], + 흐름: await emptyFlow(info.이름, info.결과단위), + }; + const id = logicId("자체", `새${Date.now()}`); + drafts[id] = { sub: "자체", file: OWN_FILE, version: own?.version ?? "", origKey: null, row }; + persist(); + await open(id, "자체", ""); + } catch (error) { + showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); + } + }; + const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() }); const discardButton = createButton({ label: tx("Bar_Discard"), @@ -386,6 +478,7 @@ export async function mountM01LogicLab( back, el("h2", { text: tx("Title") }), pending, + createButton({ label: tf("New"), variant: "filled", onClick: () => void onNew() }), copyButton, createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Basic.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Basic.ts new file mode 100644 index 000000000..e4783675e --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Basic.ts @@ -0,0 +1,105 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Basic.ts + * 「신규 로직」 기본정보 칸 — 이름 · 원문번호 · 결과 단위 · 출처(구분 = 자체). 확인하면 값을 돌려줌 · 취소면 null + * 저장은 하지 않음 — 부른 쪽이 빈 흐름도와 함께 캐시(drafts)에 새 줄로 넣고 [저장] 때 서버가 GX 새 키를 줌. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; + +const TEXT = { + Title: ["신규 로직", "New logic"], + Name: ["이름", "Name"], + NamePh: ["예: 돌쌓기 메쌓기(인력)", "e.g. dry stone wall (labour)"], + Number: ["원문번호", "Source number"], + NumberPh: ["예: 13-4-1 · 없으면 비움", "e.g. 13-4-1 · may be empty"], + Unit: ["결과 단위", "Result unit"], + Source: ["출처", "Source"], + Ok: ["만들기", "Create"], + Cancel: ["취소", "Cancel"], + Close: ["닫기", "Close"], + NeedName: ["이름을 넣어 주세요", "Enter a name"], +} as const; + +const tb = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +export interface BasicInfo { + 이름: string; + 원문번호: string; + 결과단위: string; + 출처: string; +} + +/** 기본정보 묻기 — units = 결과 단위 후보(마스터 로직의 결과단위) */ +export function askBasicInfo(units: string[]): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (v: BasicInfo | null): void => { + if (done) return; + done = true; + resolve(v); + }; + const name = createInputField({ label: tb("Name"), placeholder: tb("NamePh"), compact: true }); + const number = createInputField({ + label: tb("Number"), + placeholder: tb("NumberPh"), + compact: true, + }); + const unit = createSelectField({ + label: tb("Unit"), + options: units.map((u) => ({ value: u, text: u })), + value: units[0] ?? "원", + compact: true, + }); + const source = createInputField({ label: tb("Source"), value: "자체", compact: true }); + const why = el("p", { className: "m01-logic__muted", attrs: { role: "alert" } }); + const modal = openModal({ + title: tb("Title"), + closeLabel: tb("Close"), + beforeClose: () => { + finish(null); + return true; + }, + mount: (body, close) => { + const ok = (): void => { + const 이름 = name.input.value.trim(); + if (!이름) { + why.textContent = tb("NeedName"); + return void name.input.focus(); + } + finish({ + 이름, + 원문번호: number.input.value.trim(), + 결과단위: unit.select.value, + 출처: source.input.value.trim() || "자체", + }); + close(); + }; + name.input.addEventListener("keydown", (ev) => ev.key === "Enter" && ok()); + body.append( + name.root, + number.root, + unit.root, + source.root, + why, + el("div", { + className: "m01-logic__bar", + children: [ + createButton({ label: tb("Ok"), size: "sm", onClick: ok }), + createButton({ + label: tb("Cancel"), + variant: "ghost", + size: "sm", + onClick: () => { + finish(null); + modal.close(); + }, + }), + ], + }), + ); + }, + }); + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css index 64a4c5500..4ef2696d4 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css @@ -38,3 +38,17 @@ color: var(--color-text-secondary); text-align: center; } + +.m01lab-flow__bar[hidden] { + display: none; +} + +.m01lab-flow__note { + margin: 0 0 var(--spacing-8); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01lab-flow__note[hidden] { + display: none; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts index 15c729006..5f554f83e 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts @@ -1,19 +1,31 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Flow.ts - * 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 자리(패널은 LogicLab 이 붙임) - * 너비 비율은 브라우저에 기억(localStorage · 막히면 기본 반반). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 5 · 10 + * 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 패널 + * 너비 — 패널이 없으면 두 칸 비율(끌기 막대 · localStorage) · 패널이 있으면 패널이 알린 너비(px · 패널 가장자리 끌기) + * 잇기(mountLogicFlow) — 패널(M02_DF_Panel) · 왼쪽 보기(LogicLab_FlowView) · 입력 값 · 다른 로직 흐름 받기 + * DataFlow 파일은 import() 로 늦게(엔진이 M01 첫 묶음에 안 들어가게). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 2 · 5 · 6 · 7 * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { el } from "@ui/ui_template_elements"; +import { API_BASE_URL } from "@config/config_frontend"; +import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; +import type { CfValues } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; import "./M01_MasterData_UI_LogicLab_Flow.css"; const TEXT = { Resize: ["두 칸 너비 — 끌어서 조절", "Drag to resize the two columns"], - Waiting: ["흐름도 자리", "Flow diagram"], + New: ["신규 로직", "New logic"], + Waiting: ["흐름도 불러오는 중", "Loading the flow diagram"], + Converted: [ + "옛 식을 흐름도로 바꿔 보임 — 저장하면 흐름도가 정본", + "Converted from the old formula — saving makes the flow the master", + ], + Failed: ["흐름도를 못 띄움 — {v}", "Couldn't open the flow diagram — {v}"], } as const; -export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; +export const tf = (key: keyof typeof TEXT, v = ""): string => + TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v); const STORE = "m01.logic.flowRatio"; const MIN = 0.25; @@ -34,14 +46,13 @@ export interface SplitHandle { root: HTMLElement; /** 오른쪽 칸 — 흐름도 패널을 붙이는 곳 */ right: HTMLElement; + /** 패널이 정한 너비(px) — 끌기 막대는 숨김(패널 가장자리로 조절) · null = 비율로 돌아감 */ + setRight(px: number | null): void; } /** 두 칸 만들기 — 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 right = el("div", { className: "m01lab-flow__right" }); const bar = el("div", { className: "m01lab-flow__bar", attrs: { @@ -55,8 +66,12 @@ export function buildSplit(left: HTMLElement): SplitHandle { const leftCol = el("div", { className: "m01lab-flow__left", children: [left] }); const root = el("div", { className: "m01lab-flow", children: [leftCol, bar, right] }); let ratio = readRatio(); + let panelPx: number | null = null; const apply = (): void => { - root.style.gridTemplateColumns = `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)`; + root.style.gridTemplateColumns = + panelPx === null + ? `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)` + : `minmax(0, 1fr) ${panelPx}px`; }; const keep = (): void => { try { @@ -90,6 +105,159 @@ export function buildSplit(left: HTMLElement): SplitHandle { apply(); keep(); }); + const setRight = (px: number | null): void => { + panelPx = px; + bar.hidden = px !== null; + apply(); + }; apply(); - return { root, right }; + return { root, right, setRight }; +} + +/* ── 흐름 받기 · 잇기 ─────────────────────────────────────────────────── */ + +export interface FlowAnswer { + flow: CfLogic; + converted: boolean; + notes: string[]; +} + +/** 마스터 로직의 흐름 — 줄에 흐름이 있으면 그대로 · 없으면 서버가 옛 식을 바꿔 줌 · 길이 없거나 못 받으면 null */ +export async function fetchFlow(key: string, file = ""): Promise { + try { + const q = new URLSearchParams({ key, ...(file ? { file } : {}) }); + const r = await fetch(`${API_BASE_URL}/m01/logic/flow?${q}`, { credentials: "include" }); + if (!r.ok) return null; + const got = (await r.json()) as Partial; + return got.flow ? { flow: got.flow, converted: !!got.converted, notes: got.notes ?? [] } : null; + } catch { + return null; + } +} + +const loadFlowMods = () => + Promise.all([ + import("../M02_MasterTemplete/DataFlow/M02_DF_Panel"), + import("./M01_MasterData_UI_LogicLab_FlowView"), + import("../M02_MasterTemplete/DataFlow/M02_DF_Model"), + import("../M02_MasterTemplete/DataFlow/M02_DF_Eval"), + import("../M02_MasterTemplete/DataFlow/M02_DF_Api"), + import("../M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail"), + ]).then(([panel, view, model, ev, api, detail]) => ({ panel, view, model, ev, api, detail })); + +/** 빈 흐름 — 신규 로직 · 흐름을 못 받은 로직 */ +export async function emptyFlow(name: string, unit: string): Promise { + return (await import("../M02_MasterTemplete/DataFlow/M02_DF_Model")).newLogic(name, unit); +} + +export interface LogicFlowOpts { + split: SplitHandle; + flow: CfLogic; + title: string; + readOnly: boolean; + /** 변환 알림 — 패널 위 한 줄 */ + notes: string[]; + converted: boolean; + /** 흐름 로직이면 왼쪽 「지금 내용」 자리(LogicLab_Detail 의 flowHost) */ + leftHost(): HTMLElement | null; + onChange(lg: CfLogic): void; +} + +export interface LogicFlow { + /** 밖에서 바꿈(고친 것 버리기) — 패널 · 왼쪽 함께 */ + set(lg: CfLogic): void; + /** 왼쪽만 다시(왼쪽 화면을 새로 그린 뒤) */ + redrawLeft(): void; + destroy(): void; +} + +/** 오른쪽 패널을 띄우고 왼쪽 보기 · 입력 값 · 다른 로직을 이음 */ +export async function mountLogicFlow(opts: LogicFlowOpts): Promise { + const M = await loadFlowMods(); + const host = el("div", { className: "m01lab-flow__panel" }); + const note = el("p", { className: "m01lab-flow__note" }); + const notes = [...(opts.converted ? [tf("Converted")] : []), ...opts.notes]; + note.textContent = notes.join(" · "); + note.hidden = !notes.length; + opts.split.right.replaceChildren(note, host); + let lg = opts.flow; + let values: CfValues = {}; + let valueSig = ""; + let logics: Record = {}; + let logicSig = ""; + let gone = false; + + /** 다른 로직 — 흐름도 저장본(DF-) · 마스터 로직(흐름 받기) */ + const getLogic = async (key: string): Promise => + key.startsWith("DF-") ? M.api.cfGet(key) : ((await fetchFlow(key))?.flow ?? null); + + const left = (): void => { + const at = opts.leftHost(); + if (!at || gone) return; + M.view.buildFlowView(at, lg, values, { logics }); + }; + /** 입력 값 · 다른 로직 — 물음이 바뀐 때만 다시 받음 · 받으면 왼쪽 다시 */ + const refresh = (): void => { + let ports: Record> = {}; + try { + ports = M.ev.evalLogic(lg, values, { logics }).ports; + } catch { + /* 셈이 막혀도 물음은 보냄 */ + } + const pill = (p: { box: string; name: string }): unknown => ports[p.box]?.[p.name] ?? null; + const asks = M.detail.queriesOf(lg, pill, M.model.elementPort); + const sig = JSON.stringify(asks.map((a) => a.q)); + if (sig !== valueSig) { + valueSig = sig; + void M.detail.fetchValues(M.api.cfValues, lg, pill, M.model.elementPort).then( + (got) => { + if (sig !== valueSig || gone) return; + values = got.values; + left(); + refresh(); // 받은 값으로 조건 찾기 값이 바뀌면 한 번 더 + }, + () => undefined, + ); + } + const keys = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); + if (keys !== logicSig) { + logicSig = keys; + void M.detail.loadLogics(lg, getLogic).then((got) => { + if (keys !== logicSig || gone) return; + logics = got.logics; + left(); + }); + } + }; + + const panel = M.panel.mountFlowPanel(host, { + logic: lg, + title: opts.title, + readOnly: opts.readOnly, + logics: getLogic, + onResize: (px) => opts.split.setRight(px), + onChange: (next) => { + lg = next; + opts.onChange(next); + left(); + refresh(); + }, + }); + left(); + refresh(); + return { + set: (next) => { + lg = next; + panel.set(next); + left(); + refresh(); + }, + redrawLeft: left, + destroy: () => { + gone = true; + panel.destroy(); + opts.split.setRight(null); + opts.split.right.replaceChildren(); + }, + }; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts index a6a67a002..cce05141a 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts @@ -24,6 +24,9 @@ import { cfPlain, cfUserElementAdd, cfUserElements, cfValues } from "./M02_DF_Ap import type { CfBox, CfGroup, CfLogic } from "./M02_DF_Model"; import { GROUPS, + RULE_GROUPS, + matchesRow, + removeElementNamed, M01_OF, addElement, elementOf, @@ -36,6 +39,7 @@ import { type M01Row, } from "./M02_DF_InputPanel_Build"; import { renderLogicInput } from "./M02_DF_InputPanel_Logic"; +import { renderGroupRule } from "./M02_DF_InputPanel_Rule"; import { NO_UNIT, createUnitPick } from "./M02_DF_UnitPick"; import { renderMaterial } from "./M02_DF_InputPanel_Material"; import { renderTablePick } from "./M02_DF_InputPanel_Table"; @@ -63,6 +67,10 @@ const apiAdd = cfUserElementAdd as unknown as (e: Omit) => Promise const TEXT = { Kind: ["무엇을 넣을까요?", "What goes in?"], Variable: ["변수", "Variable"], + ModeFixed: ["고정", "Fixed"], + ModeRule: ["조건 찾기", "Find by rule"], + RuleTag: ["조건 찾기", "Rule"], + RuleLine: ["{f} 이(가) 알약 「{n}」 값과 같음", "{f} equals pill “{n}”"], Logic: ["다른 로직", "Another logic"], 인력: ["인력", "Labor"], 재료: ["재료", "Material"], @@ -193,10 +201,52 @@ function renderGroup( on: { change(lg: CfLogic): void }, ): void { const chosen = el("div", { className: "m02-dfip__chosen" }); - const find = el("div", { className: "m02-dfip__find" }); - host.replaceChildren(chosen, find, newForm(lg, box, group, on)); + const body = el("div", { className: "m02-dfip__stack" }); + let mode: "fixed" | "rule" = "fixed"; + const draw = (): void => { + if (mode === "rule") + renderGroupRule(body, lg, box.id, group, (l, e) => addElement(l, box.id, e), rulePreview, on); + else { + const find = el("div", { className: "m02-dfip__find" }); + body.replaceChildren(find, newForm(lg, box, group, on)); + void drawFind(find, lg, box, group, on); + } + }; + // 인력 · 기계 · 유가전력은 방식(고정 / 조건 찾기)을 고름 — 재료는 재료 판 · 그 밖은 고정만 + const chips = el("div", { className: "m02-dfip__chips" }); + const drawChips = (): void => + chips.replaceChildren( + ...(["fixed", "rule"] as const).map((m) => + createButton({ + label: m === "fixed" ? t("ModeFixed") : t("ModeRule"), + variant: mode === m ? "filled" : "pill", + size: "sm", + onClick: () => ((mode = m), drawChips(), draw()), + }), + ), + ); + host.replaceChildren(chosen, ...(RULE_GROUPS.includes(group) ? [chips] : []), body); drawChosen(chosen, lg, box, group, on); - void drawFind(find, lg, box, group, on); + if (RULE_GROUPS.includes(group)) drawChips(); + draw(); +} + +/** 조건 찾기 미리 보기 — 알약 값과 이름(키)이 같은 마스터 줄 */ +async function rulePreview( + group: CfGroup, + field: "이름" | "키", + value: unknown, +): Promise<{ found: number; names: string[]; unit: string }> { + if (value === null || value === undefined || value === "") + return { found: 0, names: [], unit: "" }; + const page = await fetchRows(await fileOf(group), 1, 50, String(value), false, "", ""); + const hit = (page.rows as M01Row[]).filter((r) => matchesRow(r, field, value)); + const units = new Set(hit.map((r) => elementOf(group, r).unit)); + return { + found: hit.length, + names: hit.slice(0, 3).map((r) => String(r["이름"] ?? "")), + unit: units.size === 1 ? [...units][0] : "", + }; } /** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */ @@ -214,26 +264,48 @@ function drawChosen( ...(els.length ? els.map((e) => { const val = el("strong", { className: "m02-dfip__value", text: t("NoValue") }); - values.set(e.ref, val); + const rule = e.mode === "rule"; + if (!rule) values.set(e.ref, val); return el("div", { className: "m02-dfip__item", children: [ - el("span", { className: "m02-dfip__name", text: e.name }), + el("span", { + className: "m02-dfip__name", + children: [ + ...(rule ? [el("span", { className: "m02-dfip__tag", text: t("RuleTag") })] : []), + el("strong", { text: ` ${e.name}` }), + ...(rule && e.rule + ? [ + el("div", { + className: "m02-dfip__muted", + text: t("RuleLine", { f: e.rule.field, n: e.rule.from.name }), + }), + ] + : []), + ], + }), val, el("span", { className: "m02-dfip__muted", text: e.unit || "-" }), createButton({ label: t("Remove"), variant: "ghost", size: "sm", - onClick: () => on.change(removeElement(lg, box.id, e.ref)), + onClick: () => + on.change( + rule + ? removeElementNamed(lg, box.id, e.name) + : removeElement(lg, box.id, e.ref), + ), }), ], }); }) : [el("p", { className: "m02-dfip__muted", text: t("NoneChosen") })]), ); - if (!els.length) return; - const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) })); + const items = els + .filter((e) => e.mode !== "rule") + .map((e) => ({ id: e.ref, ...queryOf(group, e.ref) })); + if (!items.length) return; void apiValues(items, lg.userElements).then( (answers) => answers.forEach((a) => { diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index 3125278d4..46d22c4ae 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -162,16 +162,21 @@ export function matches( return rule.op === "같음" ? target === v : target.includes(v); } -/** 지금 잡히는 품목 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림 */ +/** 지금 잡히는 것 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림(noun = 재료 · 줄) */ export function catchNote( found: number, firstName = "", + noun = "재료", ): { kind: "none" | "one" | "many"; text: string } { - if (found === 0) return { kind: "none", text: "맞는 재료가 없음 — 범위나 규칙을 넓혀 봄" }; - if (found === 1) return { kind: "one", text: `재료 하나가 잡힘 — ${firstName}` }; + if (found === 0) + return { + kind: "none", + text: `맞는 ${noun}${noun === "줄" ? "이" : "가"} 없음 — 범위나 규칙을 넓혀 봄`, + }; + if (found === 1) return { kind: "one", text: `${noun} 하나가 잡힘 — ${firstName}` }; return { kind: "many", - text: `재료 ${found} 개가 잡힘 — 둘 이상이면 가장 싼 값을 씀 · 규칙을 더 좁히면 하나로 잡힘`, + text: `${noun} ${found} 개가 잡힘 — 둘 이상이면 ${noun === "줄" ? "첫 줄" : "가장 싼 값"}을 씀 · 규칙을 더 좁히면 하나로 잡힘`, }; } @@ -329,6 +334,28 @@ export function masterRef( : ref; } +/** 옛 로직 줄(입력 · 자동값)에서 바로 읽은 다른 로직 입력 값 — 흐름 변환이 아직 비어 있을 때 · 받는 알약 = 입력마다 · 예시값 = 자동값 */ +export function rowRef( + key: string, + name: string, + resultUnit: string, + inputs: { 이름: string; 단위?: string }[], + auto: Record, +): CfLogicRef { + const examples: Record = {}; + for (const i of inputs) { + const v = auto[i.이름]; + if (typeof v === "number" || (typeof v === "string" && v !== "")) examples[i.이름] = v; + } + return { + key, + name, + outputs: COST_OUTPUTS.map((n) => ({ name: n, unit: resultUnit || "-" })), + designs: inputs.map((i) => ({ name: i.이름, unit: i.단위 || "-" })), + ...(Object.keys(examples).length ? { examples } : {}), + }; +} + /** 이 로직이 자기 자신을 부르거나 돌고 도나 — `load` 가 로직을 주면 입력 상자의 「다른 로직」 을 따라가 봄(깊이 8) */ export async function wouldCycle( selfKey: string | null, @@ -566,3 +593,36 @@ export function reconcileUnit(lg: CfLogic, id: string): CfLogic { ? lg : { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) }; } + +/* ── 조건 찾기(rule) — 인력 · 기계 · 유가전력도 씀: 이름 또는 키가 변수 알약 값과 같은 줄(옛 로직 LB:{직종} · EQ:{기계} 꼴) ── */ + +/** 조건 찾기를 쓰는 그룹 — 재료는 재료 판(규격 · 이름 포함) · 선택 자리(choice)는 재료만 */ +export const RULE_GROUPS: CfGroup[] = ["인력", "기계", "유가전력"]; + +export type GroupRuleField = "이름" | "키"; + +/** 이 줄이 규칙에 맞나 — 이름 또는 키(원문번호도)가 알약 값과 같음(띄어쓰기 · 대소문자는 가리지 않음) */ +export function matchesRow(row: M01Row, field: GroupRuleField, value: unknown): boolean { + const v = squash(value); + if (!v) return false; + const targets = field === "키" ? [row["키"], row["원문번호"]] : [row["이름"]]; + return targets.some((x) => squash(x) === v); +} + +/** 조건 찾기 요소 — 품목 키는 비우고 알약 이름 = 자리 이름 · 규칙 = 같음(Model 규칙 꼴에 「키」 칸을 더해 씀) */ +export function groupRuleElement( + name: string, + field: GroupRuleField, + from: CfMaterialRule["from"], +): CfElementRef { + const rule = { field, op: "같음", from } as unknown as CfMaterialRule; + return { ref: "", name: name.trim(), slot: name.trim(), spec: "", unit: "-", mode: "rule", rule }; +} + +/** 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */ +export function checkGroupRule(el: CfElementRef, others: CfElementRef[]): string | null { + if (!el.name) return "자리 이름을 적어 주세요"; + if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음"; + if (!el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요"; + return null; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts index 3ac1c5507..d23404fed 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts @@ -14,7 +14,11 @@ import { el, showToast, } from "@ui/ui_template_elements"; -import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { + fetchAuto, + fetchLogic, + fetchLogics, +} from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api"; import type { CfLogic, CfLogicRef } from "./M02_DF_Model"; import { @@ -23,6 +27,7 @@ import { logicChapters, logicRefOf, masterRef, + rowRef, pageRange, setKind, setLogicInput, @@ -235,9 +240,14 @@ export function renderLogicInput( if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error"); const got = await flowOf(i); readNotes.set(i.키, { converted: got.converted, notes: got.notes }); - const picked = got.converted - ? masterRef(i.키, got.flow, true, i.결과단위) - : { ...logicRefOf(i.키, got.flow), name: i.이름 }; + // 옛 로직을 흐름도로 바꾼 것이 아직 비어 있으면 옛 줄의 입력 · 자동값에서 바로 읽음 + const empty = got.converted && got.flow.boxes.length === 0; + const row = empty ? await fetchLogic(i.키).catch(() => null) : null; + const picked = row + ? rowRef(i.키, i.이름, i.결과단위, row.logic.입력 ?? [], await fetchAuto(i.키)) + : got.converted + ? masterRef(i.키, got.flow, true, i.결과단위) + : { ...logicRefOf(i.키, got.flow), name: i.이름 }; const done = setLogicInput(lg, boxId, picked); // 기본 이름이면 고른 로직 이름으로 on.change({ diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts new file mode 100644 index 000000000..98d23ec15 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts @@ -0,0 +1,138 @@ +/* ============================================================================= + * M02_DF_InputPanel_Rule.ts + * 입력 판 — 인력 · 기계 · 유가전력의 「조건 찾기」: 이름 또는 키가 이 로직 변수 알약 값과 같은 줄을 잡음 + * (옛 로직의 LB:{직종} · EQ:{기계} 꼴) · 지금 잡히는 줄 미리 보기(0 · 여럿이면 까닭) · 글 [한, 영] 짝은 아래 + * 선택 자리(choice)는 재료만 — 이 판에 없음 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import type { CfElementRef, CfGroup, CfLogic } from "./M02_DF_Model"; +import { + catchNote, + checkGroupRule, + groupRuleElement, + variablePills, + type GroupRuleField, +} from "./M02_DF_InputPanel_Build"; + +const TEXT = { + Help: [ + "변수 알약 값과 이름(또는 키)이 같은 줄을 잡음 — 예: 직종 알약 값이 「석공」이면 석공 줄", + "Catches the row whose name (or key) equals a variable pill's value", + ], + SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"], + Field: ["같아야 하는 칸", "Column"], + FieldName: ["이름", "Name"], + FieldKey: ["키(원문번호)", "Key (source no.)"], + Pill: ["변수 알약", "Variable pill"], + NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"], + Preview: ["지금 잡히는 줄", "Rows caught now"], + Put: ["넣기", "Add"], + Failed: ["불러오지 못함", "Could not load"], +} as const; + +type TKey = keyof typeof TEXT; +const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string; + +/** 미리 보기 길 — 그룹 · 칸 · 알약 값으로 마스터 줄을 읽어 잡힌 수 · 이름 · 단위(부르는 쪽이 줌) */ +export type RulePreview = ( + group: CfGroup, + field: GroupRuleField, + value: unknown, +) => Promise<{ found: number; names: string[]; unit: string }>; + +/** 조건 찾기 요소 추가 판 */ +export function renderGroupRule( + host: HTMLElement, + lg: CfLogic, + boxId: string, + group: CfGroup, + addEl: (lg: CfLogic, el: CfElementRef) => CfLogic, + preview: RulePreview, + on: { change(lg: CfLogic): void }, +): void { + const els = lg.boxes.find((b) => b.id === boxId)?.input?.elements ?? []; + const pills = variablePills(lg); + const name = createInputField({ + type: "text", + label: t("SlotName"), + compact: true, + placeholder: group === "인력" ? "예: 직종" : "예: 기종", + }); + const field = createSelectField({ + label: t("Field"), + options: [ + { value: "이름", text: t("FieldName") }, + { value: "키", text: t("FieldKey") }, + ], + value: "이름", + compact: true, + onChange: () => void refresh(), + }); + const pill = createSelectField({ + label: t("Pill"), + options: pills.map((p, i) => ({ + value: String(i), + text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`, + })), + value: "0", + compact: true, + onChange: () => void refresh(), + }); + const note = el("p", { className: "m02-dfip__note" }); + const build = (): CfElementRef => + groupRuleElement( + name.input.value, + field.select.value as GroupRuleField, + pills[Number(pill.select.value)]?.port ?? { box: "", name: "" }, + ); + let seq = 0; + const refresh = async (): Promise => { + const mine = ++seq; + const value = pills[Number(pill.select.value)]?.example; + note.textContent = `${t("Preview")}: …`; + try { + const p = await preview(group, field.select.value as GroupRuleField, value); + if (mine !== seq) return; + const n = catchNote(p.found, p.names[0], "줄"); + note.textContent = `${t("Preview")}: ${n.text}`; + note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`; + } catch { + if (mine === seq) note.textContent = t("Failed"); + } + }; + const add = createButton({ + label: t("Put"), + size: "sm", + onClick: () => { + const cand = build(); + const why = checkGroupRule(cand, els); + name.setError(why ?? undefined); + if (why) return void showToast(why, "error"); + // 잡히는 줄이 하나면 그 단위를 알약 단위로 + void preview( + group, + field.select.value as GroupRuleField, + pills[Number(pill.select.value)]?.example, + ).then( + (p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)), + () => on.change(addEl(lg, cand)), + ); + }, + }); + host.replaceChildren( + el("p", { className: "m02-dfip__muted", text: t("Help") }), + name.root, + ...(pills.length + ? [field.root, pill.root, note, add] + : [el("p", { className: "m02-dfip__muted is-bad", text: t("NoPill") })]), + ); + if (pills.length) void refresh(); +} diff --git a/resources/tester/test_m02_df_input_logic.py b/resources/tester/test_m02_df_input_logic.py index cc91ee13d..d5351d187 100644 --- a/resources/tester/test_m02_df_input_logic.py +++ b/resources/tester/test_m02_df_input_logic.py @@ -228,3 +228,41 @@ def test_자기_자신과_돌고_도는_로직은_막음(): assert call("wouldCycle", "SELF", "C", graph) is False assert call("wouldCycle", None, "A", graph) is False # 새 로직은 아직 키가 없어 돌 수 없음 assert call("wouldCycle", "SELF", "없음", graph) is False # 못 읽으면 막지 않음 + + +def test_조건_찾기는_인력_기계_유가전력에_쓰고_이름_또는_키가_같은_줄(): + labor = {"키": "LB000033", "원문번호": "1033", "이름": "석공"} + assert call("matchesRow", labor, "이름", "석공") is True + assert call("matchesRow", labor, "이름", "석 공") is True # 띄어쓰기는 가리지 않음 + assert call("matchesRow", labor, "이름", "할석공") is False # 같음이라 일부만 맞아선 안 잡음 + assert call("matchesRow", labor, "키", "lb000033") is True + assert call("matchesRow", labor, "키", "1033") is True # 원문번호도 키로 침 + assert call("matchesRow", labor, "이름", "") is False # 알약 값이 비면 안 잡음 + + +def test_조건_찾기_요소와_검사(): + port = {"box": "b2", "name": "직종"} + el = call("groupRuleElement", "노임", "이름", port) + assert ( + el["mode"] == "rule" and el["ref"] == "" and el["name"] == "노임" and el["slot"] == "노임" + ) + assert el["rule"] == {"field": "이름", "op": "같음", "from": port} + assert call("checkGroupRule", el, []) is None + assert call("checkGroupRule", {**el, "name": ""}, []) == "자리 이름을 적어 주세요" + assert call("checkGroupRule", el, [el]) == "같은 이름의 알약이 이미 있음" + no_pill = call("groupRuleElement", "노임", "이름", {"box": "", "name": ""}) + assert "변수 알약" in call("checkGroupRule", no_pill, []) + + +def test_옛_줄_입력과_자동값으로_다른_로직_입력을_읽음(): + inputs = [{"이름": "뒷길이", "단위": "㎝"}, {"이름": "돌"}, {"이름": "쌓기"}] + auto = {"뒷길이": 25, "돌": "깬잡석", "쌓기": "", "남는": 1} + ref = call("rowRef", "GF000219", "돌쌓기 메쌓기(인력)", "원/㎡", inputs, auto) + assert ref["key"] == "GF000219" + assert [o["name"] for o in ref["outputs"]] == ["노무비", "재료비", "경비", "계"] + assert ref["designs"] == [ + {"name": "뒷길이", "unit": "㎝"}, + {"name": "돌", "unit": "-"}, + {"name": "쌓기", "unit": "-"}, + ] + assert ref["examples"] == {"뒷길이": 25, "돌": "깬잡석"} # 빈 글 · 입력에 없는 이름은 뺌