feat(M01): 로직 개선 시험 상세 화면을 컨테이너 넷으로 · 호표를 인력·자재·경비 묶음과 소계로 · 줄을 누르면 쓰는 자료(표·단가) 모달

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
2026-09-21 19:36:07 +09:00
co-authored by Claude Sonnet 5
parent 5b69324207
commit 4a93cfdf8c
6 changed files with 627 additions and 66 deletions
@@ -0,0 +1,161 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Modal.ts
* 호표 줄 자료 모달 — 그 줄이 쓰는 자료를 보임
* 표(소요량·계수)는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처
* 표 읽기 = `/elements` → `/table`(`Logic_Note.loadTable`) · 단가 = 로직 화면이 이미 받은 `prices`
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import {
loadTable,
matchRow,
relatedFinds,
type RelatedFind,
type TableRow,
} from "./M01_MasterData_UI_Logic_Note";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
export interface ModalTarget {
title: string;
/** 수량 식(호표 줄) · 덧줄은 그 식 */
expr: string;
/** 호표 줄의 단가 요소 키 — 덧줄은 없음 */
element?: string;
prices: Record<string, ElementBrief | null>;
middles: NamedFormula[];
/** 시험 계산 칸에 넣은 값 — 걸린 줄을 맞히는 데 씀 */
values: Record<string, string>;
}
function tableView(
find: RelatedFind,
table: TableRow,
values: Record<string, string>,
): HTMLElement {
const hit = matchRow(table, find.find.conds, { values, middle: {} });
const cols = [...Object.keys(table. ?? {}), ...Object.keys(table. ?? {})];
const rows = (table. ?? []).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]) })),
}),
);
const use = table.
? [table.., (table.. ?? []).join("·")].filter(Boolean).join(" · ")
: "";
return el("div", {
className: "m01lab__data",
attrs: { "data-table": table. },
children: [
el("strong", { text: `${table. ?? ""} ${table. ?? table.} · ${table.}` }),
el("p", {
className: "m01-logic__muted",
text: [`${tl("Modal_Source")} ${table. ?? ""}`, use].filter(Boolean).join(" · "),
}),
el("p", {
className: "m01-logic__muted",
text: `${find.source}${find.find.col} · ${hit ? tl("Modal_Hit") : tl("Modal_NoHit")}`,
}),
el("div", {
className: "m01-logic__scroll",
children: [
el("table", {
className: "m01-logic__grid m01lab__table",
children: [
el("thead", {
children: [el("tr", { children: cols.map((c) => el("th", { text: c })) })],
}),
el("tbody", { children: rows }),
],
}),
],
}),
],
});
}
function priceView(ref: string, brief: ElementBrief): HTMLElement {
const cols = Object.entries(brief. ?? {});
return el("div", {
className: "m01lab__data",
attrs: { "data-price": ref },
children: [
el("strong", { text: `${brief. ?? ref} · ${ref}` }),
el("dl", {
className: "m01-logic__sums",
children: [
...(brief.
? [el("dt", { text: tl("Modal_Spec") }), el("dd", { text: brief.규격 })]
: []),
el("dt", { text: tl("Modal_Value") }),
el("dd", { className: "m01-logic__money", text: formatNumber(brief.) }),
...(brief.file
? [el("dt", { text: tl("Modal_Source") }), el("dd", { text: brief.file })]
: []),
...cols.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: String(v) })]),
],
}),
],
});
}
/** 모달을 엶 */
export function openMaterialModal(target: ModalTarget): void {
const body = el("div", { className: "m01lab__modal-body" });
const close = (): void => backdrop.remove();
const dialog = el("div", {
className: "m01-logic__pick m01lab__modal",
attrs: { role: "dialog", "aria-label": target.title },
children: [
el("div", {
className: "m01lab__modal-top",
children: [
el("h3", { text: target.title }),
createButton({ label: tl("Modal_Close"), variant: "ghost", onClick: close }),
],
}),
body,
],
});
const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] });
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
const parts: HTMLElement[] = [
el("p", { className: "m01-logic__muted", text: tl("Modal_Qty") }),
el("pre", { className: "m01lab__expr", text: target.expr }),
];
const finds = relatedFinds(target.expr, target.middles);
const price = target.element ? target.prices[target.element] : undefined;
if (target.element?.startsWith("로직(")) {
parts.push(el("p", { text: `${tl("Modal_Logic")} · ${target.element}` }));
} else if (price) {
parts.push(
el("p", { className: "m01-logic__muted", text: tl("Modal_Price") }),
priceView(target.element as string, price),
);
}
const tables = el("div", { className: "m01lab__tables" });
parts.push(tables);
if (!finds.length && !price && !target.element?.startsWith("로직(")) {
parts.push(el("p", { className: "m01-logic__muted", text: tl("Modal_NoData") }));
}
body.replaceChildren(...parts);
document.body.append(backdrop);
dialog.tabIndex = -1;
dialog.focus();
void Promise.all(finds.map(async (f) => ({ f, table: await loadTable(f.find.table) }))).then(
(got) =>
tables.replaceChildren(
...got.map(({ f, table }) =>
table
? tableView(f, table, target.values)
: el("p", { className: "m01-logic__bad", text: `${f.find.table} ?` }),
),
),
);
}