수량 칸을 「알약 + 사칙연산 글자」로 그림 — 찾기(...) 표 호출만 알약(조각 계약 `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
218 lines
8.2 KiB
TypeScript
218 lines
8.2 KiB
TypeScript
/* =============================================================================
|
||
* 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} ?` }),
|
||
);
|
||
});
|
||
});
|
||
}
|