Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
417 lines
16 KiB
TypeScript
417 lines
16 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 {
|
||
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<string, ElementBrief | null>;
|
||
middles: NamedFormula[];
|
||
/** 시험 계산 칸에 넣은 값 — 걸린 줄을 맞히는 데 씀 */
|
||
values: Record<string, string>;
|
||
/** 서버 `요소조각` — 단가 자리(요소 · 로직 부르기)를 조각으로 · 없으면 로직 키만 알약으로 */
|
||
unitFrag?: Fragment[];
|
||
/** 서버 수량 조각 — 한 줄 풀이를 조각으로(알약 포함) · 없으면 식 풀이 글 */
|
||
qtyFrag?: Fragment[];
|
||
}
|
||
|
||
/** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */
|
||
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: "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<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) => {
|
||
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) {
|
||
rate.append(
|
||
muted(`${element} — ${tl(element.includes("{") ? "Pill_NoPick" : "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<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: "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} ?` }),
|
||
);
|
||
});
|
||
});
|
||
}
|