Files
Aislo/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts
T
eomsangdonandClaude Sonnet 5 f7dd866876 feat(m01): 단가산출 상세 수량 칸 알약화 — 표 찾기만 알약 · 나머지는 글자
수량 칸을 「알약 + 사칙연산 글자」로 그림 — 찾기(...) 표 호출만 알약(조각 계약
`ref/_분석_알약_상세줄.md` ㉮ 표대로) · 나머지(숫자·연산기호·중간값·설계입력
이름)는 글자. 서버 조각 API 는 아직이라 화면에서 임시로 쪼갬(Logic_Note.ts
찾기() 규칙과 같음) — 서버가 붙으면 걷어냄. 단가·금액 칸은 안 건드림.

알약을 누르면 그 표만 바로 모달로(모달 안 알약 목록은 없앰) · 줄을 누르면 그
줄의 단가·다른 로직을 바로 보임(알약 없이 한 가지만). 인력·재료·경비 세 표를
table-layout: fixed + 같은 칸 폭으로 맞춤 · 알약은 칸 폭 기준 줄바꿈.

원 단위 소수점 0 — 돈 자리(단가·금액·소계·계)만 정수 · 수량·비율은 그대로 ·
조합 미리 보기(Combo_Preview)도 같이 고침.

검증: typecheck 통과 · ORCA 로 13-4-1·12-4·12-17-1 열어 확인(알약 클릭·행
클릭·칸 폭 동일 729.57px·정수 표시 확인, data-sum 은 원래 값 그대로 유지).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
2026-09-22 19:59:48 +09:00

218 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M01_MasterData_UI_LogicLab_Modal.ts
* 자료 모달 둘 —
* ① 줄 모달(`openMaterialModal`) — 줄을 누르면 그 줄의 단가(재료·인력·기계 또는 다른 로직)를
* 바로 보임(알약 목록 없이) · 그 아래 한 줄 풀이 + 원문 수량 식
* ② 표 모달(`openTableModal`) — 수량 칸 안 알약(찾기(...) 표 참조)을 누르면 그 표만 보임
* 표는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처
* 표 읽기 = `/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 { explain, loadTable, matchRow, 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);
export interface ModalTarget {
title: string;
/** 수량 식(호표 줄) · 덧줄은 그 식 */
expr: string;
/** 호표 줄의 단가 요소 키 — 덧줄은 없음 */
element?: 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>;
}
function tableView(
table: TableRow,
conds: [string, string][],
values: Record<string, string>,
): HTMLElement {
// 표 줄의 빈 칸은 「무엇이든」 — 서버 찾기()와 같게 그 칸은 조건에서 뺌(줄마다 따로 봄)
const hit =
(table. ?? []).find((line) =>
matchRow(
{ ...table, : [line] },
conds.filter(([col]) => line[col] !== undefined && line[col] !== null && line[col] !== ""),
{ values, middle: {} },
),
) ?? null;
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: hit ? tl("Modal_Hit") : tl("Modal_NoHit"),
}),
goButton({ kind: "table", key: table.키 }),
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 }),
],
}),
],
}),
],
});
}
const groupOf = (ref: string): string =>
ref.startsWith("LB") ? "인력" : ref.startsWith("M") ? "재료" : ref.startsWith("EQ") ? "기계" : "";
function goButton(target: MasterJump): HTMLElement {
return createButton({
label: tl("Pill_Go"),
variant: "ghost",
onClick: () => jumpToMaster(target),
});
}
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: formatMoney(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) })]),
],
}),
goButton({ kind: "element", group: groupOf(ref), ref: brief.ref ?? ref, file: brief.file }),
],
});
}
/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움 */
function openDialog(title: string, mount: (body: HTMLElement) => void): 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": title },
children: [
el("div", {
className: "m01lab__modal-top",
children: [
el("h3", { text: 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());
mount(body);
document.body.append(backdrop);
dialog.tabIndex = -1;
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 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)}`,
}),
el("details", {
className: "m01lab__raw",
children: [
el("summary", { text: tl("Modal_Qty") }),
el("pre", { className: "m01lab__expr", text: target.expr }),
],
}),
);
});
}
/** 수량 칸 표 알약을 눌렀을 때 — 그 표만 보임(걸린 줄 강조) */
export function openTableModal(target: TableTarget): void {
openDialog(target.title, (body) => {
body.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" }));
void loadTable(target.table).then((table) => {
body.replaceChildren(
table
? tableView(table, target.conds, target.values)
: el("p", { className: "m01-logic__bad", text: `${target.table} ?` }),
);
});
});
}