Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-11-02 22:15:47 +09:00
9 changed files with 192 additions and 63 deletions
@@ -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<typeof lineOpener> };
}
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 =
@@ -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 {
@@ -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<string, string>;
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.비목,
@@ -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;
}),
});
}
+12 -23
View File
@@ -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<Entry>, 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<TextLine, () => 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);
@@ -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 extends TextAnswer | undefined>(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 텍스트 수식 변수 · 값 누름 = 같은 줄 모달
});
}
@@ -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));
+1 -1
View File
@@ -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
@@ -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]