Files
Aislo/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts
T

383 lines
15 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 {
fetchMaterials,
searchElements,
type ElementBrief,
type NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney } 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 { tl } from "./M01_MasterData_UI_LogicLab_Text";
/** 재료 고르기 조건(호표 줄 `요소` 가 글 대신 객체일 때) — 서버 `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>;
}
/** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */
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: 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) })]),
...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: formatMoney(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 logicRef = element.startsWith("로직(") ? element : "";
const sum = el("p", { className: "m01lab__sum" });
const setSum = (price: ElementBrief | null | undefined): void => {
const unit = price ? `${tl("Modal_UnitPrice")} ${formatMoney(price.값)} × ` : "";
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 (logicRef) {
rate.append(el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` }));
} 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: 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} ?` }),
);
});
});
}