diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index 344337994..c2c91174a 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -13,7 +13,7 @@ import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula"; import { buildEst } from "./M01_MasterData_UI_LogicLab_Est"; -import { buildHo, type HoContext, type HoEdit } from "./M01_MasterData_UI_LogicLab_Ho"; +import { buildHo, lineOpener, type HoContext, type HoEdit } from "./M01_MasterData_UI_LogicLab_Ho"; import { trial } from "./M01_MasterData_UI_LogicLab_Cover"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; @@ -38,7 +38,7 @@ export interface DetailContext extends HoContext { /** 722-2 시험 고침 뒤 — 시험 계산 칸을 고친 줄로 다시 그림(있으면 그 자리 고침 켜짐 · 셈은 여기서 누름) */ onTrial?: () => void; /** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */ - cover?: { ho: HTMLElement; est: HTMLElement }; + cover?: { ho: HTMLElement; est: HTMLElement; open?: ReturnType }; } const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement => @@ -217,7 +217,9 @@ function hoBox(ctx: DetailContext): HTMLElement { export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { const formula = el("div", { className: "m01lab__formula" }); const fx = { inputs: ctx.row.입력, values: ctx.values, onInput: ctx.onInput }; - buildFormula(formula, { text: ctx.text, ...fx }); + // 748-2 텍스트 수식 변수 · 값 누름 = 상세 줄과 같은 모달(M02 = 그 상세가 준 열기 · M01 = 시험 고침 고리) + const open = ctx.cover ? ctx.cover.open : lineOpener({ ...ctx, edit: trialEdit(ctx) }); + buildFormula(formula, { text: ctx.text, ...fx, open }); // 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림 const active = document.activeElement; const kept = diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css index 0b09d07e7..8ff594587 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css @@ -22,14 +22,13 @@ grid-column: 1 / -1; } -.m01lab-fx__row { - cursor: pointer; - border-radius: var(--radius-lg); -} - -.m01lab-fx__row.is-on, -.m01lab-fx__row.is-pin { - background: color-mix(in srgb, gold 35%, transparent); +/* 748-2 한 줄 = 변수 이름 · 값을 하나하나 누름(행 고르기 없음) */ +.m01lab-fx__toks, +.m01lab-fx__val { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); } .m01lab-fx__cell { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index 24af10577..99523db45 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -15,6 +15,7 @@ import { optionGrid, varRow } from "../A00_Common/calc_sheet"; import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; +import { nameTokens, tokenRow, valueTokens } from "./M01_MasterData_UI_LogicLab_FormulaTokens"; import "./M01_MasterData_UI_LogicLab_Formula.css"; /** 로직 열자마자 값 없이 부른 미리보기 응답은 `계산됨` 을 false 로 표시 — [계산] 을 거친 응답은 안 붙어 true 취급 */ @@ -26,6 +27,8 @@ export interface FormulaContext { inputs?: LogicInput[]; values?: Record; onInput?: (name: string, value: string) => void; + /** 748-2 줄 → 그 줄 자료 모달 열기(변수 · 값을 누르면) — 없으면 글자만 */ + open?: (line: TextLine) => (() => void) | undefined; } /* ── 순수 셈: 줄이 쓰는 입력 ── */ @@ -128,7 +131,6 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { el("strong", { text: tl("Formula_Right") }), ], }); - const rows: HTMLElement[] = []; const specs = ctx.onInput ? (ctx.inputs ?? []) : []; // 737-2 옵션 칸 = 비목 상자 맨 위 — 그 상자 줄이 쓰는 입력 + 어느 줄도 안 쓰는 입력(공통 · 737-3) const names = specs.map((x) => x.이름); @@ -149,22 +151,41 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { ); }; const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false - const light = (pair: string, on: boolean): void => - rows.forEach((r) => r.dataset.pair === pair && r.classList.toggle("is-on", on)); + const onInput = ctx.onInput; + /** 변수 이름이 시험 입력이면 옵션 고르기 · 아니면 그 줄 모달 */ + const spec = (name: string): LogicInput | undefined => + onInput ? specs.find((x) => x.이름 === name) : undefined; // 737-2 비목마다 구분 상자 — 맨 위 옵션 칸 · 아래 두 칸 식 줄들 · 소계 const boxes = text.groups.map((g) => { const lines = g.줄.map((line, i) => { const pair = String(line.짝 ?? `${g.비목}${i}`); - const row = el("div", { + const open = ctx.open?.(line); + return el("div", { className: `m01lab-fx__row m01lab__text${line.까닭 ? " m01lab__text--off" : ""}`, attrs: { "data-text-line": line.이름, "data-pair": pair }, children: [ - el("div", { className: "m01lab-fx__cell", text: line.이름글 ?? line.이름 }), + el("div", { + className: "m01lab-fx__cell", + children: [ + tokenRow(nameTokens(line.이름글 ?? line.이름), open, (name) => { + const s = spec(name); + return s && onInput + ? inputChip(s, ctx.values?.[name] ?? "", (v) => onInput(name, v)) + : null; + }), + ], + }), el("div", { className: "m01lab-fx__cell", children: calculated ? [ - el("div", { text: `${line.이름} : ${line.글}` }), + el("div", { + className: "m01lab-fx__val", + children: [ + el("span", { text: `${line.이름} :` }), + tokenRow(valueTokens(line.글), open), + ], + }), ...(line.까닭 ? [ el("div", { @@ -178,15 +199,6 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { }), ], }); - row.addEventListener("mouseenter", () => light(pair, true)); - row.addEventListener("mouseleave", () => light(pair, false)); - row.addEventListener("click", () => { - const pinned = row.classList.toggle("is-pin"); - rows.forEach((r) => r !== row && r.classList.remove("is-pin")); - if (!pinned) row.classList.remove("is-pin"); - }); - rows.push(row); - return row; }); const card = createCard({ title: g.비목, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FormulaTokens.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FormulaTokens.ts new file mode 100644 index 000000000..4db6aa219 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FormulaTokens.ts @@ -0,0 +1,71 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_FormulaTokens.ts + * 텍스트 수식 한 줄 글 → 누를 수 있는 조각(PLAN 748-2) — + * 왼쪽 변수 이름 식(`이름글`) = 변수 이름 하나하나 · 오른쪽 값 든 식(`글`) = 값(수) 하나하나 + * 누르면 그 줄 자료 모달(748-1) · 변수가 시험 입력(옵션)이면 옵션 고르기(`inputChip`) + * 조각 나누기는 순수 셈(화면 · fetch · sessionStorage 없음) — 칸 그리기는 `tokenRow` 만 화면. + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_button"; +import { el } from "@ui/ui_template_elements"; + +/* ── 순수 셈 ── */ +export interface Tok { + text: string; + /** 눌러서 모달을 열 조각 */ + hot: boolean; +} + +const NAME_RE = /\s+|[*+/=()×÷,%:]|[^\s*+/=()×÷,%:]+/g; + +/** 변수 이름 식 → 조각 — 글자(한글 · 영문)가 든 덩이 = 변수 이름(함수 이름 「이름(」 · 수 · 기호는 아님) · 빈칸은 버림 */ +export function nameTokens(text: string): Tok[] { + const out: Tok[] = []; + for (const m of text.matchAll(NAME_RE)) { + const t = m[0]; + if (/^\s+$/.test(t)) continue; + const next = text[(m.index ?? 0) + t.length]; + out.push({ text: t, hot: /[가-힣A-Za-z]/.test(t) && next !== "(" }); + } + return out; +} + +/** 값 든 식 → 조각 — 수(콤마 · 소수점 든 것까지) 하나하나 = 값 · 나머지(기호 · 글)는 그대로 */ +export function valueTokens(text: string): Tok[] { + const out: Tok[] = []; + let last = 0; + const plain = (s: string): void => { + const t = s.trim(); + if (t) out.push({ text: t, hot: false }); + }; + for (const m of text.matchAll(/\d[\d,]*(?:\.\d+)?/g)) { + plain(text.slice(last, m.index)); + out.push({ text: m[0], hot: true }); + last = (m.index ?? 0) + m[0].length; + } + plain(text.slice(last)); + return out; +} +/* ── 순수 셈 끝 ── */ + +/** 조각 한 줄 — `hot` 은 낮은 단추(요소 연결 갈래 색) · `make` 가 단추를 주면 그걸로(옵션 칩) · 아니면 글자 */ +export function tokenRow( + toks: Tok[], + open: (() => void) | undefined, + make?: (text: string) => HTMLElement | null, +): HTMLElement { + return el("div", { + className: "m01lab-fx__toks", + children: toks.map((t) => { + if (!t.hot) return el("span", { text: t.text }); + const own = make?.(t.text); + if (own) return own; + if (!open) return el("span", { text: t.text }); + const b = createButton({ label: t.text, variant: "ghost", size: 20, tone: "link" }); + b.classList.add("m01lab-fx__tok"); + b.dataset.fxTok = t.text; + b.addEventListener("click", open); + return b; + }), + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts index 1af682d93..754d8e28c 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -35,7 +35,6 @@ import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick"; import { coverForm, type CoverPart, type LineCover } from "./M01_MasterData_UI_LogicLab_Cover"; -import { inputChip } from "./M01_MasterData_UI_LogicLab_Formula"; import { condText, openMaterialModal, @@ -419,31 +418,14 @@ const COLS: Column[] = [ const money = (v: number | null): string => (v === null ? "" : formatMoney(v)); -/** 이 줄이 쓰는 입력 칩 — 서버 `쓰는입력` ∩ 로직 입력 */ -function chips(e: Entry, ctx: HoContext): HTMLElement[] { - const onInput = ctx.onInput; - const uses = e.textLine?.쓰는입력 ?? []; - const specs = (ctx.row.입력 ?? []).filter((s) => uses.includes(s.이름)); - if (!onInput || !specs.length) return []; - return [ - el("div", { - className: "m01lab-fx__vars", - children: specs.map((s) => inputChip(s, ctx.values[s.이름] ?? "", (v) => onInput(s.이름, v))), - }), - ]; -} - -/** 계산식 칸 — 값 든 식(계산 뒤) · 계산 전 = 수량 식 · 알약 · 입력 칩 · 이름 식은 풍선 */ -function formulaCell(e: Entry, ctx: HoContext, text = ""): HTMLElement { +/** 계산식 칸 — 값 든 식(계산 뒤) · 계산 전 = 수량 식 · 알약 · 이름 식은 풍선(748-3 옵션 값 꼬리표 없음) */ +function formulaCell(e: Entry, text = ""): HTMLElement { const tline = e.textLine; const main = text || (tline ? sheetFormula(tline.글) : e.qty); const pills = e.qtyFrag.some((f) => "pill" in f) ? [fragmentRow(e.qtyFrag)] : []; const cell = el("div", { className: "m01lab__qty-cell", - children: [ - el("div", { className: "m01lab__qty-text", text: main }), - ...(text ? [] : [...pills, ...chips(e, ctx)]), - ], + children: [el("div", { className: "m01lab__qty-text", text: main }), ...(text ? [] : pills)], }); cell.title = tline?.이름글 ?? e.qty; return cell; @@ -485,7 +467,7 @@ function lineRow(e: Entry, ctx: HoContext, label = e.name): HTMLTableRowElement nameCell(e, label, e.share ? "" : tag), e.qtyValue, e.unit, - formulaCell(e, ctx), + formulaCell(e), e.price, e.line?.까닭 ? gapCell(e.line.까닭) : money(e.amount), ]); @@ -528,7 +510,7 @@ function mergedRows(m: HoMerged, ctx: HoContext): HTMLTableRowElement[] { nameCell(first, `▸ ${first.name}`, tag), first.qtyValue, first.unit, - formulaCell(first, ctx, short), + formulaCell(first, short), first.linePrice, money(m.금액), ]); @@ -657,6 +639,13 @@ function commonCard(rows: HTMLElement[]): HTMLElement { return card; } +/** 748-2 텍스트 수식 줄 → 그 줄 자료 모달 열기(상세 줄과 같은 모달 · 못 찾으면 없음) */ +export function lineOpener(ctx: HoContext): (line: TextLine) => (() => void) | undefined { + const by = new Map void>(); + for (const e of entries(ctx)) if (e.textLine) by.set(e.textLine, () => e.open(e)); + return (line) => by.get(line); +} + /** 공용 단가산출 상세 몸 — 박스들(맨 위 옵션 칸 · 공통 옵션 포함) · 맨 아래 한 줄(비목 소계 셋 · 계) */ export function buildHo(ctx: HoContext): HTMLElement[] { const all = entries(ctx); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 187e032ef..1af48839b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -33,7 +33,7 @@ import { } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { applyCovers } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Cover"; import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail"; -import { buildHo, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho"; +import { buildHo, lineOpener, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho"; import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead"; @@ -318,6 +318,17 @@ export function mountSangeunDetail( ); const cut = (t: T): T => (t ? dropText(t, baseNames, bare, doc) : t) as T; + const hoCtx = { + row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row, + prices: base?.prices ?? {}, + lines: (view?.lines ?? null) as CalcLine[] | null, + text: cut(text ?? undefined) ?? null, + lastText: cut(lastText), + values, + onInput, + calcHost, + edit: view && edit ? hoEdit(view, rows) : undefined, + }; const ho = el("div", { attrs: { "data-sk-cover": "" }, children: [ @@ -338,17 +349,7 @@ export function mountSangeunDetail( }, onDrop: (k, name) => (dropBase(doc, k, name), touch()), }), - ...buildHo({ - row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row, - prices: base?.prices ?? {}, - lines: (view?.lines ?? null) as CalcLine[] | null, - text: cut(text ?? undefined) ?? null, - lastText: cut(lastText), - values, - onInput, - calcHost, - edit: view && edit ? hoEdit(view, rows) : undefined, - }), + ...buildHo(hoCtx), ], }); const est = el("div", { @@ -378,7 +379,7 @@ export function mountSangeunDetail( onChange: () => undefined, calcHost, onInput, - cover: { ho, est }, + cover: { ho, est, open: lineOpener(hoCtx) }, // 748-2 텍스트 수식 변수 · 값 누름 = 같은 줄 모달 }); } diff --git a/resources/tester/helper_748_2_formula_tokens.cjs b/resources/tester/helper_748_2_formula_tokens.cjs new file mode 100644 index 000000000..b6ca5ad15 --- /dev/null +++ b/resources/tester/helper_748_2_formula_tokens.cjs @@ -0,0 +1,20 @@ +/* PLAN 748-2 헬퍼 — 텍스트 수식 조각 나누기(`M01_MasterData_UI_LogicLab_FormulaTokens.ts` 순수 셈). + * 짝 = `test_748_2_formula_tokens.py`. 결과 = 표준출력 JSON. + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const { load } = makeLoader({ root: path.join(__dirname, "..", "..") }); +const m = load("M01_MasterData", "M01_MasterData_UI_LogicLab_FormulaTokens.ts"); +const hot = (list) => list.filter((t) => t.hot).map((t) => t.text); +const name = "굴착기노무비단가 * 뒤채움및다짐소형장비.굴착기 * 8 / 시공량 = 굴착기노무비"; +const value = "35,412 * 1 * 8 / 110 = 2,575"; +const out = { + nameHot: hot(m.nameTokens(name)), + nameAll: m.nameTokens(name).map((t) => t.text), + fnCall: hot(m.nameTokens("올림(단가) + 1.5 - 보정")), + valueHot: hot(m.valueTokens(value)), + valueAll: m.valueTokens("13,873 = 13,873 · 21 %").map((t) => t.text), + empty: [m.nameTokens("").length, m.valueTokens("").length], +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_722_ho_detail.py b/resources/tester/test_722_ho_detail.py index 34d4eed23..c57fa313f 100644 --- a/resources/tester/test_722_ho_detail.py +++ b/resources/tester/test_722_ho_detail.py @@ -46,10 +46,10 @@ def test_상세가_공용_부품을_씀(): "tableRow(", "createCard(", "varRow(", - "inputChip(", "sheetFormula(", ): assert part in ho, part + assert "inputChip(" not in ho # 748-3 줄마다 붙던 옵션 값 칩 걷음(위 옵션 칸이 대신) for s in (ho, _text(COVER)): assert 'el("table"' not in s and 'el("button"' not in s and 'el("input"' not in s assert "document.createElement" not in s and "sessionStorage" not in s diff --git a/resources/tester/test_748_2_formula_tokens.py b/resources/tester/test_748_2_formula_tokens.py new file mode 100644 index 000000000..041f10fb1 --- /dev/null +++ b/resources/tester/test_748_2_formula_tokens.py @@ -0,0 +1,35 @@ +"""748-2 지킴 — 텍스트 수식 한 줄을 변수 이름 · 값 하나하나로 나눔(누르면 줄 모달).""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +HERE = Path(__file__).resolve().parent + + +def test_변수_이름_값_조각(): + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_748_2_formula_tokens.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + # 이름식 — 변수 이름(점 든 표 찾기 이름 포함)만 · 수 · 기호는 아님 + assert out["nameHot"] == [ + "굴착기노무비단가", + "뒤채움및다짐소형장비.굴착기", + "시공량", + "굴착기노무비", + ] + assert "8" in out["nameAll"] and "=" in out["nameAll"] + # 함수 이름(「이름(」)은 변수가 아님 · 수 · 홀로 선 빼기는 아님 + assert out["fnCall"] == ["단가", "보정"] + # 값식 — 수 하나하나(콤마 · 소수점 포함) · 기호는 그대로 + assert out["valueHot"] == ["35,412", "1", "8", "110", "2,575"] + assert out["valueAll"][:3] == ["13,873", "=", "13,873"] + assert out["empty"] == [0, 0]