Files
Aislo/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts
T
eomsangdonandClaude Opus 5.5 0c956a6c61 feat(711-2): M01 재료 모달 하나 — 상세 표 줄 · 시험 계산 자재 칸 같은 모달 · 물가 넷 가로 · 찾기 · 고르기
- 상세 쪽 openMaterialModal 기준 — 위 물가자료 · 유통물가 · 물가정보 · 거래가격 가로 넷(빈 칸 「-」 · 시험 계산이 쓴 값 굵게)
- 후보 = /materials(조건 · 단위 거름) · 조건 없으면 이름 찾기 · 찾기 칸(검색어로 먼저 거름 · 없으면 재료 전체 찾기) · 목록 값 열 뺌
- 줄 누르면 고름(시험 입력 · 저장 안 함) → 다시 셈 · 상세 표 재료 줄에서 골라도 시험 계산 자재 칸 따라감
- 기계 고르기 · M02 일위대가 [바꾸기]는 PickTable 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:26:22 +09:00

561 lines
23 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`) — 줄을 누르면 그 줄의 단가(재료·인력·기계 또는 다른 로직)를
* 바로 보임(알약 목록 없이) · 그 아래 한 줄 풀이 + 원문 수량 식
* 재료 줄 = 위 물가 넷 가로(빈 칸 「-」 · 시험 계산이 쓴 값 굵게) + 후보 목록(찾기 · `onPick` 이면 누르면 고름)
* — 상세 표 줄 · 시험 계산 자재 칸이 같은 모달(711-2) · 기계 고르기는 `Logic_PickTable` 그대로
* ② 표 모달(`openTableModal`) — 수량 칸 안 알약(찾기(...) 표 참조)을 누르면 그 표만 보임
* 표는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처
* 표 읽기 = `/elements` → `/table`(`Logic_Note.loadTable`) · 단가 = 로직 화면이 이미 받은 `prices`
* ========================================================================== */
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import {
fetchMaterials,
searchElements,
type ElementBrief,
type NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch";
import { createStat, statGrid } from "@ui/ui_template_stat";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney, formatPrice } 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 { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import { createText } from "@ui/ui_template_text";
/** 로직 알약 → 그 로직 열기 — 로직 화면이 자기 열기를 등록 · 열려 있는 모달은 다 닫음 */
let logicOpener: (key: string) => void = () => {};
export const setLogicOpener = (fn: (key: string) => void): void => {
logicOpener = fn;
};
export function openLogic(key: string): void {
document.querySelectorAll(".m01-logic__backdrop").forEach((b) => b.remove());
logicOpener(key);
}
/** 재료 고르기 조건(호표 줄 `요소` 가 글 대신 객체일 때) — 서버 `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("|") + (cond["검색어"] ? `|${cond["검색어"]}` : "");
/** 재료 모달 검색 칸 글 — 검색어 첫 낱말의 괄호 앞(「합판(내수) 12t」 → 「합판」) */
export const headWord = (text: unknown): string =>
String(text ?? "")
.trim()
.split(/\s+/)[0]
.replace(/[((].*$/, "");
/** 고르기 줄 후보 찾기 글 — 검색어 → 이름 → 상세구분 → 구분 차례 첫 낱말 */
export const seedOf = (cond: PickCond): string =>
[cond["검색어"], cond.이름, cond.상세구분, cond.구분].map(headWord).find(Boolean) ?? "";
export interface ModalTarget {
title: string;
/** 수량 식(호표 줄) · 덧줄은 그 식 */
expr: string;
/** 호표 줄의 단가 요소 키 — 덧줄은 없음 */
element?: string;
/** 재료 고르기 줄 — `element` 대신 조건 객체 · 잡힌 품목 단가 + 후보 목록을 보임 */
cond?: PickCond;
/** 호표 줄 단위 — 후보를 같은 단위 줄로만 거름 */
unit?: string;
prices: Record<string, ElementBrief | null>;
middles: NamedFormula[];
/** 시험 계산 칸에 넣은 값 — 걸린 줄을 맞히는 데 씀 */
values: Record<string, string>;
/** 서버 `요소조각` — 단가 자리(요소 · 로직 부르기)를 조각으로 · 없으면 로직 키만 알약으로 */
unitFrag?: Fragment[];
/** 서버가 입력으로 푼 단가 요소 키 · 글 — 자리 표시(`{…}`) 든 줄이 씀 */
unitRef?: string;
unitText?: string;
/** 서버 수량 조각 — 한 줄 풀이를 조각으로(알약 포함) · 없으면 식 풀이 글 */
qtyFrag?: Fragment[];
/** 호표 줄 종류 — 「재료」면 후보 목록(고르기 조건 줄이 아니어도) */
kind?: string;
/** 시험 계산이 쓴 값 칸(계산 답 `품목.값칸`) — 위 물가 넷 가운데 굵게 */
usedSlot?: string;
/** 있으면 후보 줄을 눌러 고름(시험 입력 · 저장 안 함) — 고르면 모달 닫힘 */
onPick?: (item: { ref: string; label: string }) => void;
}
/** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */
export interface TableTarget {
title: string;
table: string;
/** 찾은 줄 — 찾기 조건(예 {구분: 합판}) · 줄의 키·원문번호 글 · 줄 차례(0부터) */
row?: Record<string, unknown> | string | number | null;
/** 찾은 값 칸 이름 */
col?: string | null;
}
const sameCond = (have: unknown, want: unknown, range: boolean): boolean => {
if (range && Array.isArray(have)) {
const n = Number(want);
const [low, high] = have as (number | null)[];
return (low == null || n >= low) && (high == null || n <= high);
}
return String(have) === String(want);
};
function tableView(table: TableRow, target: TableTarget): HTMLElement {
const lines = table.줄 ?? [];
const at = target.row;
const kinds = table.조건 ?? {};
const hit =
at === undefined || at === null
? null
: typeof at === "number"
? (lines[at] ?? null)
: typeof at === "string"
? (lines.find((line) => line["키"] === at || line["원문번호"] === at) ?? null)
: (lines.find((line) =>
Object.entries(at).every(([c, want]) => sameCond(line[c], want, kinds[c] === "범위")),
) ?? null);
const cols = [...Object.keys(table.조건 ?? {}), ...Object.keys(table.값칸 ?? {})];
const rows = lines.map((line) =>
el("tr", {
className: hit === line ? "m01lab__hit" : "",
attrs: hit === line ? { "data-hit": "1" } : {},
children: cols.map((c) =>
el("td", {
// 찾은 줄을 알면 그 줄의 칸만 · 줄을 못 받았으면(표 줄에 키가 없는 표) 값 칸 세로줄만 색
className: c === target.col && (hit === line || !hit) ? "m01lab__hit-cell" : "",
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: "ui-text ui-text--muted",
text: [`${tl("Modal_Source")} ${table.출처 ?? ""}`, use].filter(Boolean).join(" · "),
}),
el("p", {
className: "ui-text ui-text--muted",
text: hit ? tl("Modal_Hit") : target.col ? tl("Modal_ColOnly") : tl("Modal_NoHit"),
}),
goButton({ kind: "table", key: table.키 }),
el("div", {
className: "m01-logic__scroll",
children: [
el("table", {
className: "ui-table ui-table--data 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),
});
}
/** 자재품목 값 칸 넷 — 위 가로 줄(관급은 뺌) */
const SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격"];
const MARKET_FILE = "재료_자재품목.json";
const marketRows = new Map<string, Promise<Row | null>>();
/** 자재품목 줄 하나(값 칸 넷) — 키로 찾음 · 한 번만 물음 */
const marketRow = (ref: string): Promise<Row | null> => {
if (!marketRows.has(ref))
marketRows.set(
ref,
fetchRows(MARKET_FILE, 1, 5, ref)
.then((got) => got.rows.find((r) => r["키"] === ref) ?? null)
.catch(() => null),
);
return marketRows.get(ref)!;
};
/** 자재품목 줄 단가 — 이름 · 키 · 규격 + 물가 넷 가로(빈 칸 「-」 · 시험 계산이 쓴 값 굵게 · 모르면 낮은 값) */
function marketView(
ref: string,
brief: ElementBrief | null,
usedSlot?: string,
onValue?: (value: unknown) => void,
): HTMLElement {
const head = el("strong");
const spec = el("p", { className: "ui-text ui-text--muted" });
const slots = el("div");
const paint = (name: unknown, size: unknown, values: Record<string, unknown>): void => {
head.textContent = `${String(name ?? ref)} · ${ref}`;
spec.textContent = size ? `${tl("Modal_Spec")} ${String(size)}` : "";
spec.hidden = !size;
const nums = SLOTS.map((s) => values[s]).filter((v): v is number => typeof v === "number");
const used = SLOTS.includes(usedSlot ?? "")
? usedSlot
: SLOTS.find((s) => nums.length && values[s] === Math.min(...nums));
slots.replaceChildren(
statGrid(
SLOTS.map((s) => {
const card = createStat({
label: s,
value: values[s] === null || values[s] === undefined ? "-" : formatPrice(values[s]),
compact: true,
});
card.classList.toggle("is-used", s === used);
return card;
}),
),
);
if (used) onValue?.(values[used]);
};
paint(brief?.이름, brief?.규격, brief?.값들 ?? {});
if (!brief?.값들) void marketRow(ref).then((row) => row && paint(row["이름"], row["규격"], row));
return el("div", {
className: "m01lab__data m01lab__slots",
attrs: { "data-price": ref },
children: [
head,
spec,
slots,
goButton({ kind: "element", group: "재료", ref, file: brief?.file ?? MARKET_FILE }),
],
});
}
function priceView(ref: string, brief: ElementBrief, usedSlot?: string): HTMLElement {
if (ref.startsWith("MT")) return marketView(ref, brief, usedSlot);
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 },
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: formatPrice(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) })]),
...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 }),
],
});
}
/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움(닫기 함수도 받음) */
function openDialog(title: string, mount: (body: HTMLElement, close: () => void) => void): void {
openModal({
title,
closeLabel: tl("Modal_Close"),
mount,
dialogClass: "m01-logic__pick m01lab__modal",
backdropClass: "m01-logic__backdrop",
});
}
const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text });
const free = (v: unknown): string => {
const text = String(v ?? "");
return text.includes("{") ? "" : text; // `{입력}` 자리는 계산 때 정해짐 — 거름에 안 씀
};
const squash = (v: unknown): string =>
String(v ?? "")
.replace(/\s+/g, "")
.toLowerCase();
/** 재료 줄 후보 목록 — 고르기 조건 안 후보(`/materials` · 단위 거름) · 조건이 없으면 이름 찾기 ·
* 찾기 칸 = 받은 후보 안에서 거름(없으면 재료 전체 이름 찾기) · 잡힌 품목(`pickedRef`) 노란 줄 맨 위 ·
* `onPick` 이면 줄을 누르면 고름 · 값은 위 가로 칸이 보여 목록엔 값 열 없음 */
function candidateView(target: ModalTarget, pickedRef: string, close: () => void): HTMLElement {
const cond = target.cond;
const sub = free(cond?.구분);
const list = el("div");
const count = el("strong");
const field = createInputField({ type: "search", placeholder: tl("Modal_Find") });
const box = el("div", {
className: "m01lab__data",
attrs: { "data-candidates": "1" },
children: [count, field.root, list],
});
list.append(muted("…"));
let base: ElementBrief[] = [];
const draw = (items: ElementBrief[], total: number): void => {
const sorted = [
...items.filter((it) => it.ref === pickedRef),
...items.filter((it) => it.ref !== pickedRef),
];
const more = total > items.length ? ` (${items.length})` : "";
count.textContent = `${tl(target.onPick ? "Modal_PickList" : "Modal_Candidates")} ${total}${more}`;
const rows = sorted.map((it) => {
const hit = it.ref === pickedRef;
const tr = el("tr", {
className: `${hit ? "m01lab__hit" : ""}${target.onPick ? " m01lab__pickrow" : ""}`,
attrs: { ...(hit ? { "data-hit": "1" } : {}), "data-ref": it.ref },
children: [
el("td", { text: it.이름 ?? it.ref }),
el("td", { text: it.규격 ?? "" }),
el("td", { text: String(it.단위 ?? "") }),
],
});
const onPick = target.onPick;
if (onPick)
tr.addEventListener("click", () => {
close();
onPick({ ref: it.ref, label: `${it.이름 ?? ""} ${it.규격 ?? ""}`.trim() || it.ref });
});
return tr;
});
list.replaceChildren(
rows.length
? el("div", {
className: "m01-logic__scroll m01lab__cands",
children: [
el("table", {
className: "ui-table ui-table--data m01-logic__grid m01lab__table",
children: [
el("thead", {
children: [
el("tr", {
children: [tl("Col_Name"), tl("Modal_Spec"), tl("Col_Unit")].map((h) =>
el("th", { text: h }),
),
}),
],
}),
el("tbody", { children: rows }),
],
}),
],
})
: muted(tl("Pill_NoPick")),
);
};
// 조건 없는 재료 줄 = 그 요소 이름 첫 낱말로 찾음(「모르타르, 서울 1:1」 → 「모르타르」)
const known = target.element ? target.prices[target.element] : null;
const seed = cond ? seedOf(cond) : headWord(known?.이름 ?? target.title).replace(/[,,].*$/, "");
const first = sub
? fetchMaterials({
sub,
detail: free(cond?.상세구분),
spec: free(cond?.규격),
unit: target.unit ?? "",
})
: searchElements("재료", seed);
let total = 0;
let timer = 0;
let seq = 0;
/** 찾기 칸 글로 거름 — 받은 후보 안에서 · 하나도 없으면 재료 전체 이름 찾기 */
const apply = (): void => {
const mine = ++seq;
const q = field.input.value.trim();
const words = q.split(/\s+/).map(squash).filter(Boolean);
if (!words.length) return draw(base, total);
const hits = base.filter((it) =>
words.every((w) => squash(`${it.이름 ?? ""} ${it.규격 ?? ""} ${it.ref}`).includes(w)),
);
if (hits.length) return draw(hits, hits.length);
void searchElements("재료", q)
.then((got) => mine === seq && draw(got.items, got.total))
.catch(() => undefined);
};
void first
.then((got) => {
base = got.items;
total = got.total;
field.input.value = sub ? free(cond?.["검색어"]) : ""; // 엔진이 고르는 검색어로 먼저 거름 · 지우면 조건 안 전부
apply();
})
.catch(() => list.replaceChildren(muted(tl("Pill_NoPick"))));
field.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(apply, 300);
});
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, close) => {
const cond = target.cond;
const element = target.element ?? "";
const base = element.split(".")[0];
// 고르기 줄 = 서버가 조건 글을 열쇠로 준 줄(대표가 안 정해졌으면 없음)
const priced = cond ? target.prices[condText(cond)] : undefined;
const known = cond
? target.unitRef && priced?.ref !== target.unitRef
? undefined // 서버가 잡은 품목이 다르면 그 품목으로
: priced
: element
? (target.prices[element] ?? target.prices[base])
: undefined;
const priceRef = cond
? target.unitRef || pickedOf(cond, target.values, known?.ref ?? "")
: element;
const logicKey = /^로직\((\w+)/.exec(element)?.[1] ?? "";
const sum = el("p", { className: "m01lab__sum" });
const setSum = (price: ElementBrief | null | undefined): void => {
const unit = price ? `${tl("Modal_UnitPrice")} ${formatPrice(price.값)} × ` : "";
if (target.qtyFrag) {
sum.replaceChildren(`${target.title} = ${unit}`, fragmentRow(target.qtyFrag));
} else 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, target.usedSlot));
} else if (logicKey) {
rate.append(
el("p", {
children: [
`${tl("Modal_Logic")} · `,
fragmentRow(
target.unitFrag ?? [
{ pill: { label: logicKey, onClick: () => openLogic(logicKey) } },
],
),
],
}),
);
} else if (cond && priceRef) {
// 서버가 잡은 품목 · 고른 품목 — 자재품목 줄을 키로 읽어 물가 넷
rate.append(
marketView(priceRef, null, target.usedSlot, (value) =>
setSum({ ref: priceRef, 값: value } as ElementBrief),
),
);
} else if (cond) {
rate.append(muted(tl("Pill_NoPick")));
} else if (element.includes("{")) {
// 자리 표시 든 요소 — 날글 대신 서버가 푼 키로 단가 · 못 풀면 입력 이름만
const ref = target.unitRef;
if (!ref) {
const names = [...element.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]).join(" · ");
rate.append(muted(`${target.unitText || names} — ${tl("Pill_Unresolved")}`));
} else {
const show = (brief: ElementBrief | null): void => {
rate.replaceChildren(
brief
? priceView(ref, brief)
: muted(`${target.unitText ?? ref} — ${tl("Pill_NoValue")}`),
);
setSum(brief);
};
rate.append(muted("…"));
const cached = target.prices[ref];
if (cached) show(cached);
else
void searchElements(groupOf(ref), ref)
.then((got) => show(got.items.find((it) => it.ref === ref) ?? null))
.catch(() => show(null));
}
} else if (element) {
rate.append(muted(`${element} — ${tl("Pill_NoValue")}`));
}
body.replaceChildren(
rate,
...(cond || target.kind === "재료" ? [candidateView(target, priceRef, close)] : []),
muted(tl("Modal_Sum")),
sum,
el("details", {
className: "m01lab__raw",
children: [
el("summary", { text: tl("Modal_Qty") }),
el("pre", {
className: "m01lab__expr",
text: logicKey
? `${element}
${target.expr}`
: target.expr,
}),
],
}),
);
});
}
/** 수량 칸 요소 알약을 눌렀을 때 — 그 요소의 단가 자료(이미 받은 `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) => {
body.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: "…" }));
void loadTable(target.table).then((table) => {
body.replaceChildren(
table ? tableView(table, target) : createText(`${target.table} ?`, "error"),
);
});
});
}