feat(M01): 로직 화면 — 자동값 채움 · 서버 조각 알약 · 재료 고르기 모달 · 돈 모양 한 벌 (PLAN 3-4 화면)

- 시험 계산: 로직을 열 때 GET /logic/auto 값을 빈 입력에 채우고 「견본」 딱지(사용자가
  고치면 빠짐) 뒤 바로 계산 · 텍스트 수식까지. 서버 길이 없으면 안 채움
- 단가산출 상세 수량 칸: 화면 쪽 식 풀이(qtyFragments) 걷고 /text 줄의 조각으로 그림 —
  알약 글 = 조각 글 · 깊이 있는 조각은 흐리게 · 묶음별 줄 수가 안 맞으면 식 글자 그대로
- 표 알약 모달: 참조.행·열에 색(행을 못 받으면 열만)
- 재료 고르기 줄(요소가 객체): 모달이 객체를 받게 — 잡힌 품목 단가 + 후보 목록
- 돈 모양 한 벌(Logic_Money.ts): 상세·모달·텍스트 수식 소계와 계·시험 계산 결과·조합
  미리 보기 정수 · 수량·비율은 그대로
- 서버 파일은 안 만짐

검증 — typecheck 통과 · GF000160 ORCA: 알약 7 서로 다름 · 계 141,882 · 재료 줄 모달
· GF000219·GF000001 회귀 없음.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EY2vdBQkHjdih9GgCsp9Fd
This commit is contained in:
2026-09-24 09:10:58 +09:00
co-authored by Claude Sonnet 5
parent 6eaa539fde
commit cb1b82c1dc
11 changed files with 369 additions and 120 deletions
@@ -8,6 +8,7 @@ import { createInputField, createSelectField, el } from "@ui/ui_template_element
import { fetchLogic, type LogicInput } from "./M01_MasterData_UI_Logic_Api";
import { previewCombo, type Combo } from "./M01_MasterData_UI_Combo_Api";
import { tc, type ComboTextKey } from "./M01_MasterData_UI_Combo_Text";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
const COSTS = ["노무비", "재료비", "경비"] as const;
const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = {
@@ -17,8 +18,8 @@ const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = {
};
const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v);
/** 원 단위 소수점 0 — 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */
const fmt = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 0 });
/** 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */
const fmt = formatMoney;
/** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */
const initial = (i: LogicInput): string => String(i.?.[0] ?? i.?.[0] ?? "");
@@ -14,57 +14,43 @@ import type {
LogicRow,
NamedFormula,
TextAnswer,
TextPiece,
} from "./M01_MasterData_UI_Logic_Api";
import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
import { buildEditor, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
import { openMaterialModal, openTableModal } from "./M01_MasterData_UI_LogicLab_Modal";
import {
condText,
openMaterialModal,
openNoteModal,
openRefModal,
openTableModal,
type PickCond,
} from "./M01_MasterData_UI_LogicLab_Modal";
import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill";
import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text";
import "./M01_MasterData_UI_LogicLab_Style.css";
import "./M01_MasterData_UI_LogicLab_Pill.css";
/** 원 단위 소수점 0 — 돈 자리(단가·금액·소계·계)만. 수량·비율은 `formatNumber` 그대로 둠. */
const formatMoney = (value: unknown): string =>
typeof value === "number"
? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 })
: formatNumber(value);
/** 조각 계약(`ref/_분석_알약_상세줄.md` ㉮ 표) — 수량 식 안 찾기(...) 표 호출만 알약,
* 그 밖(숫자·연산기호·중간값·설계입력 이름)은 글자. 서버가 조각 목록을 아직 안 주므로
* 여기서 임시로 쪼갬(`Logic_Note.ts` 찾기() 정규식과 같은 규칙) — 서버가 붙으면 이 파싱을 걷어냄. */
const FIND_RE = /찾기\(\s*([A-Za-z0-9_]+)\s*,([^)]*)\)\s*\.\s*([A-Za-z0-9_가-힣]+)/g;
function splitFindConds(raw: string): [string, string][] {
return raw
.split(",")
.map((p) => p.trim())
.filter(Boolean)
.map((p): [string, string] => {
const i = p.indexOf("=");
return i < 0 ? [p, p] : [p.slice(0, i).trim(), p.slice(i + 1).trim()];
});
}
function qtyFragments(expr: string, values: Record<string, string>): Fragment[] {
const out: Fragment[] = [];
let last = 0;
for (const m of expr.matchAll(FIND_RE)) {
const at = m.index ?? 0;
if (at > last) out.push({ text: expr.slice(last, at) });
const table = m[1];
const label = `${tl("Pill_Table")} ${table}`;
out.push({
pill: {
label,
onClick: () => openTableModal({ title: label, table, conds: splitFindConds(m[2]), values }),
},
});
last = at + m[0].length;
}
if (last < expr.length) out.push({ text: expr.slice(last) });
return out.length ? out : [{ text: expr }];
/** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */
function toFragments(pieces: TextPiece[], ctx: DetailContext): Fragment[] {
return pieces.map((piece): Fragment => {
const dim = piece. ? { dim: true } : {};
if (piece.kind !== "요소" && piece.kind !== "표" && piece.kind !== "로직")
return { text: piece.글, ...dim };
const ref = piece.;
const open = (): void => {
if (!ref) return openNoteModal(piece., tl("Pill_NoPick"));
if (piece.kind === "표")
openTableModal({ title: piece.글, table: ref.키, row: ref.행, col: ref.열 });
else if (piece.kind === "요소")
openRefModal({ title: piece.글, ref: ref.키, prices: ctx.prices });
else openNoteModal(piece., `${tl("Modal_Logic")} · ${ref.}`);
};
return { pill: { label: piece.글, onClick: open }, ...dim };
});
}
export interface DetailContext {
@@ -108,6 +94,12 @@ interface Entry {
open: () => void;
}
/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */
const condOf = (item: HoLine): PickCond | null =>
typeof item. === "object" && item. !== null ? (item. as unknown as PickCond) : null;
const condLabel = (cond: PickCond): string =>
[cond., cond., cond.].filter(Boolean).join(" · ");
const groupName = (group: Group): string => tl(`G_${group}` as LabTextKey);
const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
@@ -204,24 +196,25 @@ function entries(ctx: DetailContext): Entry[] {
const ho = ctx.row. ?? [];
const out: Entry[] = ho.flatMap((item, i) => {
const line = ctx.lines?.[i];
const brief = ctx.prices[item.];
const cond = condOf(item);
const brief = cond ? ctx.prices[condText(cond)] : ctx.prices[item.];
const price = line ? line.단가 : brief?.값;
const entry: Entry = {
group: groupOfHo(item),
name: item.이름 ?? item.,
name: item.이름 ?? (cond ? condLabel(cond) : item.),
spec: item.규격 ?? brief?. ?? "",
unit: item.단위 ?? "",
qty: item.수량,
qtyTag: qtyKind(item.) === tx("Ho_QtyTable") ? tl("Tag_Table") : qtyKind(item.),
qtyFrag: qtyFragments(item., ctx.values),
qtyFrag: [{ text: item.수량 }],
price: price === undefined || price === null ? "" : formatMoney(price),
amount: line ? line.금액 : null,
extra: false,
open: () =>
openMaterialModal({
title: item.이름 ?? item.,
title: item.이름 ?? (cond ? condLabel(cond) : item.),
expr: item.수량,
element: item.요소,
...(cond ? { cond, unit: item.단위 } : { element: item.요소 }),
prices: ctx.prices,
middles,
values: ctx.values,
@@ -238,7 +231,7 @@ function entries(ctx: DetailContext): Entry[] {
unit: "",
qty: extra.식,
qtyTag: tl("Extra"),
qtyFrag: qtyFragments(extra., ctx.values),
qtyFrag: [{ text: extra.식 }],
price: "",
amount: line ? line.금액 : null,
extra: true,
@@ -253,9 +246,25 @@ function entries(ctx: DetailContext): Entry[] {
};
out.push(...spread(entry, line));
});
attachPieces(out, ctx);
return out;
}
/** 서버 `/text` 줄의 조각을 묶음별로 차례대로 이음 — 줄 수가 서로 안 맞으면(계산 전 로직 부르는 줄 등)
* 그 묶음은 식 글자 그대로 둠(화면에서 식을 다시 풀지 않음) */
function attachPieces(all: Entry[], ctx: DetailContext): void {
if (!ctx.text?.ok) return;
for (const [cost, group] of Object.entries(OF_COST)) {
const lines = ctx.text.groups.find((g) => g. === cost)?. ?? [];
const mine = all.filter((e) => e.group === group);
if (lines.length !== mine.length) continue;
mine.forEach((e, i) => {
const pieces = lines[i].;
if (pieces?.length) e.qtyFrag = toFragments(pieces, ctx);
});
}
}
const cellOf = (text: string, className = ""): HTMLElement => el("td", { className, text });
function lineRow(e: Entry): HTMLElement {
@@ -7,7 +7,7 @@
import { el } from "@ui/ui_template_elements";
import type { TextAnswer } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import "./M01_MasterData_UI_LogicLab_Formula.css";
@@ -82,7 +82,7 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
el("p", {
className: "m01lab__subtotal-text m01lab-fx__wide",
attrs: { "data-text-sub": String(g.) },
text: `${g.} ${tl("Subtotal")} ${formatNumber(g.)}${g. ? ` (${g.})` : ""}`,
text: `${g.} ${tl("Subtotal")} ${formatMoney(g.)}${g. ? ` (${g.})` : ""}`,
}),
]);
blocks.unshift(heads);
@@ -90,7 +90,7 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
el("p", {
className: "m01lab__total m01lab-fx__wide",
attrs: { "data-text-sum": String(text.) },
text: `${tl("Total")} ${formatNumber(text.)}`,
text: `${tl("Total")} ${formatMoney(text.)}`,
}),
);
host.replaceChildren(el("div", { className: "m01lab-fx", children: blocks }));
@@ -9,17 +9,33 @@
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
import {
fetchMaterials,
searchElements,
type ElementBrief,
type NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { explain, loadTable, matchRow, type TableRow } from "./M01_MasterData_UI_Logic_Note";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { explain, loadTable, type TableRow } from "./M01_MasterData_UI_Logic_Note";
import { jumpToMaster, type MasterJump } from "./M01_MasterData_UI_Side";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
/** 원 단위 소수점 0 — 돈 자리만(수량·비율은 `formatNumber` 그대로) */
const formatMoney = (value: unknown): string =>
typeof value === "number"
? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 })
: formatNumber(value);
/** 재료 고르기 조건(호표 줄 `요소` 가 글 대신 객체일 때) — 서버 `master_material.PICK_KEYS` 와 같은 칸 */
export interface PickCond {
구분?: string;
상세구분?: string;
이름?: string;
규격?: string;
대표?: string;
[extra: string]: unknown;
}
/** 서버가 `prices` 열쇠로 쓰는 조건 글 — 「구분|상세구분|이름|규격|대표|지역|계약종별」 */
export const condText = (cond: PickCond): string =>
["구분", "상세구분", "이름", "규격", "대표", "지역", "계약종별"]
.map((k) => String(cond[k] ?? ""))
.join("|");
export interface ModalTarget {
title: string;
@@ -27,40 +43,47 @@ export interface ModalTarget {
expr: string;
/** 호표 줄의 단가 요소 키 — 덧줄은 없음 */
element?: string;
/** 재료 고르기 줄 — `element` 대신 조건 객체 · 잡힌 품목 단가 + 후보 목록을 보임 */
cond?: PickCond;
/** 호표 줄 단위 — 후보를 같은 단위 줄로만 거름 */
unit?: string;
prices: Record<string, ElementBrief | null>;
middles: NamedFormula[];
/** 시험 계산 칸에 넣은 값 — 걸린 줄을 맞히는 데 씀 */
values: Record<string, string>;
}
/** 수량 칸 표 알약 하나가 여는 자료 — 조각 계약의 「표 찾기」 자리 */
/** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */
export interface TableTarget {
title: string;
table: string;
conds: [string, string][];
values: Record<string, string>;
/** 찾은 줄 — 줄의 키·원문번호 글, 또는 줄 차례(0부터) */
row?: string | number | null;
/** 찾은 값 칸 이름 */
col?: string | null;
}
function tableView(
table: TableRow,
conds: [string, string][],
values: Record<string, string>,
): HTMLElement {
// 표 줄의 빈 칸은 「무엇이든」 — 서버 찾기()와 같게 그 칸은 조건에서 뺌(줄마다 따로 봄)
function tableView(table: TableRow, target: TableTarget): HTMLElement {
const lines = table. ?? [];
const at = target.row;
const hit =
(table. ?? []).find((line) =>
matchRow(
{ ...table, : [line] },
conds.filter(([col]) => line[col] !== undefined && line[col] !== null && line[col] !== ""),
{ values, middle: {} },
),
) ?? null;
at === undefined || at === null
? null
: typeof at === "number"
? (lines[at] ?? null)
: (lines.find((line) => line["키"] === at || line["원문번호"] === at) ?? null);
const cols = [...Object.keys(table. ?? {}), ...Object.keys(table. ?? {})];
const rows = (table. ?? []).map((line) =>
const rows = lines.map((line) =>
el("tr", {
className: hit === line ? "m01lab__hit" : "",
attrs: hit === line ? { "data-hit": "1" } : {},
children: cols.map((c) => el("td", { text: formatNumber(line[c]) })),
children: cols.map((c) =>
el("td", {
// 찾은 줄을 알면 그 줄의 칸만 · 줄을 못 받았으면(표 줄에 키가 없는 표) 값 칸 세로줄만 색
className: c === target.col && (hit === line || !hit) ? "m01lab__hit-cell" : "",
text: formatNumber(line[c]),
}),
),
}),
);
const use = table.
@@ -77,7 +100,7 @@ function tableView(
}),
el("p", {
className: "m01-logic__muted",
text: hit ? tl("Modal_Hit") : tl("Modal_NoHit"),
text: hit ? tl("Modal_Hit") : target.col ? tl("Modal_ColOnly") : tl("Modal_NoHit"),
}),
goButton({ kind: "table", key: table.키 }),
el("div", {
@@ -111,6 +134,7 @@ function goButton(target: MasterJump): HTMLElement {
function priceView(ref: string, brief: ElementBrief): HTMLElement {
const cols = Object.entries(brief. ?? {});
const slots = Object.entries(brief. ?? {}).filter(([, v]) => v !== null && v !== undefined);
return el("div", {
className: "m01lab__data",
attrs: { "data-price": ref },
@@ -128,6 +152,10 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement {
? [el("dt", { text: tl("Modal_Source") }), el("dd", { text: brief.file })]
: []),
...cols.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: String(v) })]),
...slots.flatMap(([k, v]) => [
el("dt", { text: k }),
el("dd", { className: "m01-logic__money", text: formatMoney(v) }),
]),
],
}),
goButton({ kind: "element", group: groupOf(ref), ref: brief.ref ?? ref, file: brief.file }),
@@ -162,35 +190,133 @@ function openDialog(title: string, mount: (body: HTMLElement) => void): void {
dialog.focus();
}
/** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) */
export function openMaterialModal(target: ModalTarget): void {
openDialog(target.title, (body) => {
const base = target.element?.split(".")[0] ?? "";
const price = target.element
? (target.prices[target.element] ?? target.prices[base])
: undefined;
const logicRef = target.element?.startsWith("로직(") ? target.element : "";
const rateParts: HTMLElement[] = [];
if (price && target.element) {
rateParts.push(priceView(target.element, price));
} else if (logicRef) {
rateParts.push(el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` }));
} else if (target.element) {
rateParts.push(
el("p", {
className: "m01-logic__muted",
text: `${target.element}${tl(target.element.includes("{") ? "Pill_NoPick" : "Pill_NoValue")}`,
const muted = (text: string): HTMLElement => el("p", { className: "m01-logic__muted", text });
/** 재료 고르기 줄 후보 목록 — 잡힌 품목(`pickedRef`)을 노란 줄로 · 후보가 100건을 넘으면 앞 100건만 옴 */
function candidateView(
cond: PickCond,
unit: string,
pickedRef: string,
onPicked: (item: ElementBrief | null) => void,
): HTMLElement {
const box = el("div", { className: "m01lab__data", attrs: { "data-candidates": "1" } });
box.append(muted("…"));
void fetchMaterials({
sub: String(cond["구분"] ?? ""),
detail: String(cond["상세구분"] ?? ""),
spec: String(cond["규격"] ?? ""),
region: "",
unit,
})
.then((got) => {
onPicked(got.items.find((it) => it.ref === pickedRef) ?? null);
const rows = got.items.map((it) =>
el("tr", {
className: it.ref === pickedRef ? "m01lab__hit" : "",
attrs: it.ref === pickedRef ? { "data-hit": "1" } : {},
children: [
el("td", { text: it.이름 ?? it.ref }),
el("td", { text: it.규격 ?? "" }),
el("td", { text: String(it. ?? "") }),
el("td", { className: "m01-logic__money", text: formatMoney(it.) }),
],
}),
);
const more = got.total > got.items.length ? ` (${got.items.length})` : "";
box.replaceChildren(
el("strong", { text: `${tl("Modal_Candidates")} ${got.total}${more}` }),
el("div", {
className: "m01-logic__scroll",
children: [
el("table", {
className: "m01-logic__grid m01lab__table",
children: [
el("thead", {
children: [
el("tr", {
children: [
tl("Col_Name"),
tl("Modal_Spec"),
tl("Col_Unit"),
tl("Modal_Value"),
].map((h) => el("th", { text: h })),
}),
],
}),
el("tbody", { children: rows }),
],
}),
],
}),
);
})
.catch(() => box.replaceChildren(muted(tl("Pill_NoPick"))));
return box;
}
/** 고르기 줄에서 시험 계산이 잡은 품목 키 — 대표가 `{입력이름}` 이면 넣은 값 · 글자 키면 그대로 · 없으면 서버 미리보기 줄 */
function pickedOf(cond: PickCond, values: Record<string, string>, fallback: string): string {
const rep = String(cond["대표"] ?? "");
const named = /^\{(.+)\}$/.exec(rep);
return (named ? (values[named[1]] ?? "") : rep) || fallback;
}
/** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) ·
* 재료 고르기 줄(`cond`)은 잡힌 품목 단가 + 후보 목록 */
export function openMaterialModal(target: ModalTarget): void {
openDialog(target.title, (body) => {
const cond = target.cond;
const element = target.element ?? "";
const base = element.split(".")[0];
// 고르기 줄 = 서버가 조건 글을 열쇠로 준 줄(대표가 안 정해졌으면 없음)
const known = cond
? target.prices[condText(cond)]
: element
? (target.prices[element] ?? target.prices[base])
: undefined;
const priceRef = cond ? pickedOf(cond, target.values, known?.ref ?? "") : element;
const logicRef = element.startsWith("로직(") ? element : "";
const sum = el("p", { className: "m01lab__sum" });
const setSum = (price: ElementBrief | null | undefined): void => {
const unit = price ? `${tl("Modal_UnitPrice")} ${formatMoney(price.)} × ` : "";
sum.textContent = `${target.title} = ${unit}${explain(target.expr)}`;
};
setSum(known);
const rate = el("div", { className: "m01lab__modal-body" });
if (known && priceRef) {
rate.append(priceView(priceRef, known));
} else if (logicRef) {
rate.append(el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` }));
} else if (cond && priceRef) {
rate.append(muted("…")); // 후보 목록이 오면 그 줄로 채움 — 100건 밖이면 키로 따로 찾아 옴
} else if (cond) {
rate.append(muted(tl("Pill_NoPick")));
} else if (element) {
rate.append(
muted(`${element}${tl(element.includes("{") ? "Pill_NoPick" : "Pill_NoValue")}`),
);
}
const unit = price ? `${tl("Modal_UnitPrice")} ${formatMoney(price.)} × ` : "";
body.replaceChildren(
...rateParts,
el("p", { className: "m01-logic__muted", text: tl("Modal_Sum") }),
el("p", {
className: "m01lab__sum",
text: `${target.title} = ${unit}${explain(target.expr)}`,
}),
rate,
...(cond
? [
candidateView(cond, target.unit ?? "", priceRef, (item) => {
if (known || !priceRef) return;
const show = (brief: ElementBrief | null): void => {
rate.replaceChildren(
brief ? priceView(priceRef, brief) : muted(`${priceRef}${tl("Pill_NoValue")}`),
);
setSum(brief);
};
if (item) return show(item);
void searchElements("재료", priceRef)
.then((got) => show(got.items.find((it) => it.ref === priceRef) ?? null))
.catch(() => show(null));
}),
]
: []),
muted(tl("Modal_Sum")),
sum,
el("details", {
className: "m01lab__raw",
children: [
@@ -202,6 +328,31 @@ export function openMaterialModal(target: ModalTarget): void {
});
}
/** 수량 칸 요소 알약을 눌렀을 때 — 그 요소의 단가 자료(이미 받은 `prices` 에 없으면 키로 찾아 옴) */
export function openRefModal(target: {
title: string;
ref: string;
prices: Record<string, ElementBrief | null>;
}): void {
openDialog(target.title, (body) => {
const show = (brief: ElementBrief | null): void =>
body.replaceChildren(
brief ? priceView(target.ref, brief) : muted(`${target.ref}${tl("Pill_NoValue")}`),
);
const known = target.prices[target.ref];
if (known) return show(known);
body.replaceChildren(muted("…"));
void searchElements(groupOf(target.ref), target.ref)
.then((got) => show(got.items.find((it) => it.ref === target.ref) ?? null))
.catch(() => show(null));
});
}
/** 알약이 가리킬 자료를 못 찾을 때(참조 없음) · 다른 로직을 부르는 알약 — 한 마디만 */
export function openNoteModal(title: string, text: string): void {
openDialog(title, (body) => body.replaceChildren(el("p", { text })));
}
/** 수량 칸 표 알약을 눌렀을 때 — 그 표만 보임(걸린 줄 강조) */
export function openTableModal(target: TableTarget): void {
openDialog(target.title, (body) => {
@@ -209,7 +360,7 @@ export function openTableModal(target: TableTarget): void {
void loadTable(target.table).then((table) => {
body.replaceChildren(
table
? tableView(table, target.conds, target.values)
? tableView(table, target)
: el("p", { className: "m01-logic__bad", text: `${target.table} ?` }),
);
});
@@ -33,3 +33,14 @@
.m01lab__pill-stage {
min-width: 0;
}
/* 계산 안 된 만약() 가지 조각 — 흐리게 */
.m01lab__qty-dim {
opacity: 0.45;
}
/* 표 모달 — 찾은 칸(참조.열) 한 번 더 진하게 */
td.m01lab__hit-cell,
.m01lab__hit td.m01lab__hit-cell {
background: color-mix(in srgb, orange 65%, transparent);
}
@@ -28,17 +28,20 @@ export function pillButton(p: Pill): HTMLButtonElement {
return b;
}
/** 수량 칸 조각 — 알약 아니면 그냥 글자(연산기호·숫자·중간값 이름) */
export type Fragment = { pill: Pill } | { text: string };
/** 수량 칸 조각 — 알약 아니면 그냥 글자(연산기호·숫자·중간값 이름) · `dim` = 계산 안 된 갈래(흐리게) */
export type Fragment = { pill: Pill; dim?: boolean } | { text: string; dim?: boolean };
/** 조각을 이어 그림 — 칸 폭을 넘으면 자동으로 줄바꿈(`flex-wrap`) */
export function fragmentRow(fragments: Fragment[]): HTMLElement {
return el("span", {
className: "m01lab__qty-frags",
children: fragments.map((f) =>
"pill" in f
? pillButton(f.pill)
: el("span", { className: "m01lab__qty-text", text: f.text }),
),
children: fragments.map((f) => {
const one =
"pill" in f
? pillButton(f.pill)
: el("span", { className: "m01lab__qty-text", text: f.text });
if (f.dim) one.classList.add("m01lab__qty-dim");
return one;
}),
});
}
@@ -64,9 +64,17 @@ const TEXT = {
Modal_Value: ["값", "Value"],
Modal_Source: ["출처", "Source"],
Modal_Spec: ["규격", "Spec"],
Modal_Candidates: [
"후보 목록 (노란 줄 = 시험 계산이 쓰는 품목)",
"Candidates (yellow = used in the trial)",
],
Modal_Logic: ["다른 로직을 부름", "Calls another logic"],
Modal_NoData: ["표나 단가 자료가 없는 줄 — 수량 식만 있음", "No table or price data"],
Modal_Hit: ["노란 줄 = 지금 넣은 값으로 걸린 줄", "Highlighted row matches the trial values"],
Modal_ColOnly: [
"진한 칸 = 찾은 값 칸 — 서버가 줄 자리는 안 줌",
"Dark cells = the value column found; the server gave no row",
],
Modal_NoHit: [
"지금 값으로는 걸린 줄이 없음 — 값을 바꿔 보거나, 이 표를 안 쓰는 갈래일 수 있음",
"No row matches the current values — change them, or this table is on an unused branch",
@@ -66,6 +66,8 @@ export interface ElementBrief {
단위?: unknown;
값?: unknown;
값칸?: Record<string, string> | null;
/** 재료 후보 줄 — 값 열 다섯(지역·계약종별별 값) */
값들?: Record<string, unknown>;
}
export interface LogicOne {
@@ -97,11 +99,20 @@ export type CalcAnswer =
}
| { ok: false; reason: string };
/** `/text` 줄의 조각 — 알약 글 = 조각 글 · `깊이` 있으면 계산 안 된 만약() 가지(흐리게) */
export interface TextPiece {
kind: "요소" | "표" | "로직" | "값" | "기호" | "글";
: string;
?: { 테이블?: string; : string; 행?: string | number | null; 열?: string | null };
깊이?: number;
}
export interface TextLine {
이름: string;
: string;
/** 같은 줄을 변수 이름으로 적은 식 — 왼쪽 칸 */
이름글?: string;
/** 수량 식을 쪼갠 조각 — 알약(요소·표·로직) · 글자(값·기호·글) */
조각?: TextPiece[];
/** 줄 번호(답 전체에서 하나씩) — 두 칸 줄 맞춤 · 밝히기 */
짝?: number;
금액: number;
@@ -192,9 +203,17 @@ export const fetchMaterials = (cond: {
detail?: string;
spec?: string;
region?: string;
/** 호표 줄 단위 — 주면 그 단위 줄만 */
unit?: string;
}): Promise<{ total: number; 기본: string | null; items: ElementBrief[] }> =>
request(`/materials?${query({ ...cond, limit: "100" })}`);
/** 자동값 길(`GET /logic/auto`) — 입력 이름 → 견본값 · 서버에 아직 없거나 값이 없으면 빈 채로(화면은 그냥 안 채움) */
export const fetchAuto = (key: string): Promise<Record<string, unknown>> =>
request<{ 입력?: Record<string, unknown> }>(`/logic/auto?${query({ key })}`)
.then((d) => d. ?? {})
.catch(() => ({}));
export const runCalc = (body: {
key: string;
inputs: Record<string, unknown>;
+43 -8
View File
@@ -6,6 +6,7 @@
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
fetchAuto,
runCalc,
runText,
searchElements,
@@ -15,6 +16,7 @@ import {
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { tx } from "./M01_MasterData_UI_Logic_Text";
/** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */
@@ -55,10 +57,19 @@ export interface CalcContext {
const SUMS = ["노무비", "재료비", "경비", "계"];
/** 로직마다 자동값을 한 번만 받음(다시 그려도 안 부름) · 견본으로 채운 입력 이름 — 사용자가 고치면 빠짐 */
const autoAsked = new WeakSet<Record<string, string>>();
const sampled = new WeakMap<Record<string, string>, Set<string>>();
/** 로직을 열 때마다 새로 부르는 미리보기 — 늦게 온 응답이 새 로직을 덮지 않게 순번으로 거름 */
let previewSeq = 0;
export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
const marks = sampled.get(ctx.values) ?? new Set<string>();
const edited = (name: string, tag?: HTMLElement): void => {
marks.delete(name);
tag?.remove();
};
// 그릇 폭 280px 고정 칸은 옛 테스트 컨테이너(UI_Test*, 걷힘)의 죽은 CSS 규칙 —
// 지금 쓰는 LogicLab 화면은 그 칸에 안 걸려 폭이 이미 넓음(실측 730px) · 따로 안 풂
const optionText = (o: string | number): string =>
@@ -67,12 +78,18 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
const name = spec.;
let control: HTMLElement;
const picks = spec.;
const tag = marks.has(name)
? el("span", { className: "m01-logic__tag", text: tx("Calc_Sample") })
: undefined;
if (picks?.length) {
const pick = createSelectField({
options: ["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
value: ctx.values[name] ?? "",
compact: true,
onChange: (v) => (ctx.values[name] = v),
onChange: (v) => {
ctx.values[name] = v;
edited(name, tag);
},
});
control = pick.root;
const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o));
@@ -91,7 +108,10 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
});
if (spec.) box.placeholder = `${spec.[0]} ${spec.[1]}`;
box.value = ctx.values[name] ?? "";
box.addEventListener("input", () => (ctx.values[name] = box.value));
box.addEventListener("input", () => {
ctx.values[name] = box.value;
edited(name, tag);
});
control = box;
}
const label = spec. ? `${name} (${spec.})` : name;
@@ -99,10 +119,11 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
const wide = label.length > 10;
return el("label", {
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
children: [el("span", { text: label }), control],
children: [el("span", { text: label }), ...(tag ? [tag] : []), control],
});
});
const out = el("div", { className: "m01-logic__calc-out" });
const mySeqAtOpen = ctx.onText ? previewSeq + 1 : previewSeq; // 이 그리기가 받을 순번
let manualRun = false; // [계산] 이 이미 답했으면 뒤늦게 오는 미리보기로 안 덮음
if (ctx.onText) {
const mySeq = ++previewSeq;
@@ -118,6 +139,19 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
ctx.onText?.(answer);
});
}
if (ctx.savedKey !== null && !autoAsked.has(ctx.values)) {
autoAsked.add(ctx.values);
void fetchAuto(ctx.savedKey).then((auto) => {
const names = (ctx.row. ?? [])
.map((spec) => spec.)
.filter((n) => auto[n] !== undefined && auto[n] !== null && !(ctx.values[n] ?? "").trim());
if (!names.length || previewSeq !== mySeqAtOpen) return; // 값이 없거나 다른 로직이 이미 열림
for (const n of names) ctx.values[n] = String(auto[n]);
sampled.set(ctx.values, new Set([...marks, ...names]));
buildCalc(host, ctx); // 채운 값·견본 딱지를 새로 그리고 바로 계산(결과 · 텍스트 수식까지)
host.querySelector("button")?.click();
});
}
const run = async (): Promise<void> => {
manualRun = true;
const inputs: Record<string, unknown> = {};
@@ -139,7 +173,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
const answer = await runCalc(body);
if (ctx.onText) ctx.onText(await runText(body));
ctx.onLines(answer.ok ? (answer.lines ?? null) : null);
out.replaceChildren(...answerView(answer, draft));
out.replaceChildren(...answerView(answer, draft, (ctx.row. ?? "").startsWith("원")));
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
@@ -154,7 +188,8 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
);
}
function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] {
/** `money` = 결과가 돈(원) — 그때만 결과 값도 정수 · 수량·비율 결과는 소수 그대로 */
function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] {
const note = draft ? [el("p", { className: "m01-logic__muted", text: tx("Calc_Draft") })] : [];
if (!answer.ok) {
return [
@@ -172,7 +207,7 @@ function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] {
children: [
el("td", { text: line.출처 ? `${line.} (${line.})` : line. }),
el("td", { text: `${formatNumber(line.)} ${line.}` }),
el("td", { className: "m01-logic__money", text: formatNumber(line.) }),
el("td", { className: "m01-logic__money", text: formatMoney(line.) }),
],
}),
);
@@ -200,7 +235,7 @@ function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] {
className: "m01-logic__sums",
children: SUMS.flatMap((k) => [
el("dt", { text: k === "계" ? tx("Calc_Sum") : k }),
el("dd", { className: "m01-logic__money", text: formatNumber(answer.sums?.[k] ?? 0) }),
el("dd", { className: "m01-logic__money", text: formatMoney(answer.sums?.[k] ?? 0) }),
]),
}),
);
@@ -211,7 +246,7 @@ function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] {
className: "m01-logic__sums",
children: [
el("dt", { text: tx("Calc_Result") }),
el("dd", { text: formatNumber(answer.result) }),
el("dd", { text: money ? formatMoney(answer.result) : formatNumber(answer.result) }),
],
}),
);
@@ -0,0 +1,11 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Money.ts
* 돈 모양 한 벌 — 원 단위 정수(반올림). 단가·금액·소계·계 자리만 씀 · 수량·비율은 `formatNumber` 그대로
* ========================================================================== */
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
export const formatMoney = (value: unknown): string =>
typeof value === "number"
? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 })
: formatNumber(value);
@@ -60,6 +60,7 @@ const TEXT = {
Calc_Title: ["시험 계산", "Test calculation"],
Calc_Run: ["계산", "Calculate"],
Calc_NoInputs: ["받을 값 없음", "No inputs"],
Calc_Sample: ["견본", "Sample"],
Calc_Draft: ["고친 줄로 계산(저장 전)", "Using unsaved edits"],
Calc_Stopped: ["멈춤", "Stopped"],
Calc_Result: ["결과", "Result"],