Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -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;
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user