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
@@ -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} ?` }),
);
});