/* ============================================================================= * M01_MasterData_UI_LogicLab_Modal.ts * 자료 모달 둘 — * ① 줄 모달(`openMaterialModal`) — 줄을 누르면 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 * 바로 보임(알약 목록 없이) · 그 아래 한 줄 풀이 + 원문 수량 식 * ② 표 모달(`openTableModal`) — 수량 칸 안 알약(찾기(...) 표 참조)을 누르면 그 표만 보임 * 표는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처 * 표 읽기 = `/elements` → `/table`(`Logic_Note.loadTable`) · 단가 = 로직 화면이 이미 받은 `prices` * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { fetchMaterials, searchElements, type ElementBrief, type NamedFormula, } from "./M01_MasterData_UI_Logic_Api"; 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"; /** 로직 알약 → 그 로직 열기 — 로직 화면이 자기 열기를 등록 · 열려 있는 모달은 다 닫음 */ 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("|"); export interface ModalTarget { title: string; /** 수량 식(호표 줄) · 덧줄은 그 식 */ expr: string; /** 호표 줄의 단가 요소 키 — 덧줄은 없음 */ element?: string; /** 재료 고르기 줄 — `element` 대신 조건 객체 · 잡힌 품목 단가 + 후보 목록을 보임 */ cond?: PickCond; /** 호표 줄 단위 — 후보를 같은 단위 줄로만 거름 */ unit?: string; prices: Record; middles: NamedFormula[]; /** 시험 계산 칸에 넣은 값 — 걸린 줄을 맞히는 데 씀 */ values: Record; /** 서버 `요소조각` — 단가 자리(요소 · 로직 부르기)를 조각으로 · 없으면 로직 키만 알약으로 */ unitFrag?: Fragment[]; /** 서버가 입력으로 푼 단가 요소 키 · 글 — 자리 표시(`{…}`) 든 줄이 씀 */ unitRef?: string; unitText?: string; /** 서버 수량 조각 — 한 줄 풀이를 조각으로(알약 포함) · 없으면 식 풀이 글 */ qtyFrag?: Fragment[]; } /** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */ export interface TableTarget { title: string; table: string; /** 찾은 줄 — 찾기 조건(예 {구분: 합판}) · 줄의 키·원문번호 글 · 줄 차례(0부터) */ row?: Record | 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: "m01-logic__muted", text: [`${tl("Modal_Source")} ${table.출처 ?? ""}`, use].filter(Boolean).join(" · "), }), el("p", { className: "m01-logic__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: "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.값칸 ?? {}); 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) => 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(); } 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: formatPrice(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, 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 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)); } 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(muted("…")); // 후보 목록이 오면 그 줄로 채움 — 100건 밖이면 키로 따로 찾아 옴 } 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 ? [ 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: [ 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; }): 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: "m01-logic__muted", text: "…" })); void loadTable(target.table).then((table) => { body.replaceChildren( table ? tableView(table, target) : el("p", { className: "m01-logic__bad", text: `${target.table} ?` }), ); }); }); }