Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1

This commit is contained in:
2026-11-01 15:30:40 +09:00
5 changed files with 257 additions and 89 deletions
@@ -22,6 +22,7 @@ import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Ed
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick";
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
import {
condText,
@@ -281,7 +282,7 @@ function entries(ctx: DetailContext): Entry[] {
open: (self) =>
openMaterialModal({
unitFrag: self.unitFrag,
unitRef: self.unitRef,
unitRef: line?.품목?.키 ?? self.unitRef,
unitText: self.unitText,
qtyFrag: self.piecesFrag,
title: item.이름 ?? (cond ? condLabel(cond) : item.요소),
@@ -290,6 +291,12 @@ function entries(ctx: DetailContext): Entry[] {
prices: ctx.prices,
middles,
values: ctx.values,
// 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜)
kind: item.종류,
usedSlot: line?.품목?.값칸,
...(item.종류 === "재료"
? { onPick: (it) => pickMaterial(ctx.calcHost, ctx.values, i, it) }
: {}),
}),
};
return spread(entry, line);
@@ -3,18 +3,23 @@
* 자료 모달 둘 —
* ① 줄 모달(`openMaterialModal`) — 줄을 누르면 그 줄의 단가(재료·인력·기계 또는 다른 로직)를
* 바로 보임(알약 목록 없이) · 그 아래 한 줄 풀이 + 원문 수량 식
* 재료 줄 = 위 물가 넷 가로(빈 칸 「-」 · 시험 계산이 쓴 값 굵게) + 후보 목록(찾기 · `onPick` 이면 누르면 고름)
* — 상세 표 줄 · 시험 계산 자재 칸이 같은 모달(711-2) · 기계 고르기는 `Logic_PickTable` 그대로
* ② 표 모달(`openTableModal`) — 수량 칸 안 알약(찾기(...) 표 참조)을 누르면 그 표만 보임
* 표는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처
* 표 읽기 = `/elements` → `/table`(`Logic_Note.loadTable`) · 단가 = 로직 화면이 이미 받은 `prices`
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
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";
@@ -81,6 +86,12 @@ export interface ModalTarget {
unitText?: string;
/** 서버 수량 조각 — 한 줄 풀이를 조각으로(알약 포함) · 없으면 식 풀이 글 */
qtyFrag?: Fragment[];
/** 호표 줄 종류 — 「재료」면 후보 목록(고르기 조건 줄이 아니어도) */
kind?: string;
/** 시험 계산이 쓴 값 칸(계산 답 `품목.값칸`) — 위 물가 넷 가운데 굵게 */
usedSlot?: string;
/** 있으면 후보 줄을 눌러 고름(시험 입력 · 저장 안 함) — 고르면 모달 닫힘 */
onPick?: (item: { ref: string; label: string }) => void;
}
/** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */
@@ -176,7 +187,71 @@ function goButton(target: MasterJump): HTMLElement {
});
}
function priceView(ref: string, brief: ElementBrief): HTMLElement {
/** 자재품목 값 칸 넷 — 위 가로 줄(관급은 뺌) */
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", {
@@ -207,8 +282,8 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement {
});
}
/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움 */
function openDialog(title: string, mount: (body: HTMLElement) => void): void {
/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움(닫기 함수도 받음) */
function openDialog(title: string, mount: (body: HTMLElement, close: () => void) => void): void {
openModal({
title,
closeLabel: tl("Modal_Close"),
@@ -220,58 +295,122 @@ function openDialog(title: string, mount: (body: HTMLElement) => void): void {
const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text });
/** 재료 고르기 줄 후보 목록 — 잡힌 품목(`pickedRef`)을 노란 줄로 · 후보가 100건을 넘으면 앞 100건만 옴 */
function candidateView(
cond: PickCond,
pickedRef: string,
onPicked: (item: ElementBrief | null) => void,
): HTMLElement {
const box = el("div", { className: "m01lab__data", attrs: { "data-candidates": "1" } });
box.append(muted("…"));
void searchElements("재료", seedOf(cond))
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) => {
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: "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"),
tl("Modal_Value"),
].map((h) => el("th", { text: h })),
}),
],
}),
el("tbody", { children: rows }),
],
}),
],
}),
);
base = got.items;
total = got.total;
field.input.value = sub ? free(cond?.["검색어"]) : ""; // 엔진이 고르는 검색어로 먼저 거름 · 지우면 조건 안 전부
apply();
})
.catch(() => box.replaceChildren(muted(tl("Pill_NoPick"))));
.catch(() => list.replaceChildren(muted(tl("Pill_NoPick"))));
field.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(apply, 300);
});
return box;
}
@@ -285,7 +424,7 @@ function pickedOf(cond: PickCond, values: Record<string, string>, fallback: stri
/** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) ·
* 재료 고르기 줄(`cond`)은 잡힌 품목 단가 + 후보 목록 */
export function openMaterialModal(target: ModalTarget): void {
openDialog(target.title, (body) => {
openDialog(target.title, (body, close) => {
const cond = target.cond;
const element = target.element ?? "";
const base = element.split(".")[0];
@@ -312,7 +451,7 @@ export function openMaterialModal(target: ModalTarget): void {
setSum(known);
const rate = el("div", { className: "m01lab__modal-body" });
if (known && priceRef) {
rate.append(priceView(priceRef, known));
rate.append(priceView(priceRef, known, target.usedSlot));
} else if (logicKey) {
rate.append(
el("p", {
@@ -327,7 +466,12 @@ export function openMaterialModal(target: ModalTarget): void {
}),
);
} else if (cond && priceRef) {
rate.append(muted("…")); // 후보 목록이 오면 그 줄로 채움 — 100건 밖이면 키로 따로 찾아 옴
// 서버가 잡은 품목 · 고른 품목 — 자재품목 줄을 키로 읽어 물가 넷
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("{")) {
@@ -358,23 +502,7 @@ export function openMaterialModal(target: ModalTarget): void {
}
body.replaceChildren(
rate,
...(cond
? [
candidateView(cond, 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));
}),
]
: []),
...(cond || target.kind === "재료" ? [candidateView(target, priceRef, close)] : []),
muted(tl("Modal_Sum")),
sum,
el("details", {
@@ -215,3 +215,13 @@
.m01lab__now {
margin: 0;
}
/* 재료 모달(711-2) — 위 물가 넷 가로 · 시험 계산이 쓴 값만 굵게 · 후보 목록은 모달 안 스크롤 */
.m01lab__slots .ui-stat:not(.is-used) .ui-stat__value {
font-weight: 400;
}
.m01lab__cands {
max-height: 40vh;
overflow: auto;
}
@@ -83,6 +83,11 @@ const TEXT = {
"후보 목록 (노란 줄 = 시험 계산이 쓰는 품목)",
"Candidates (yellow = used in the trial)",
],
Modal_PickList: [
"후보 목록 — 줄을 누르면 그 품목으로 시험 계산 (노란 줄 = 지금 쓰는 품목)",
"Candidates — click a row to use it in the trial (yellow = in use)",
],
Modal_Find: ["이름 · 규격으로 찾기", "Search by name or spec"],
Modal_Logic: ["다른 로직을 부름", "Calls another logic"],
Modal_NoData: ["표나 단가 자료가 없는 줄 — 수량 식만 있음", "No table or price data"],
Modal_Hit: ["노란 줄 = 지금 넣은 값으로 걸린 줄", "Highlighted row matches the trial values"],
@@ -1,7 +1,8 @@
/* =============================================================================
* M01_MasterData_UI_Logic_CalcPick.ts
* 시험 계산의 재료 · 기계 고르기 — 재료 칸마다 잡힌 품목 이름(누르면 고르기) · 기계 입력은 단추 하나
* 누르면 마스터 표 모달(`PickTable`) · 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함
* 재료 = 상세 표 줄과 같은 재료 모달(`openMaterialModal` · 711-2) · 기계 = 마스터 표 모달(`PickTable`)
* 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
@@ -9,7 +10,7 @@ import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch";
import type { CalcLine, ElementBrief, LogicInput, LogicRow } from "./M01_MasterData_UI_Logic_Api";
import { openPickTable } from "./M01_MasterData_UI_Logic_PickTable";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { condText, headWord, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal";
import { condText, openMaterialModal, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal";
interface Chosen {
ref: string;
@@ -24,6 +25,19 @@ const picksOf = (values: Record<string, string>): Map<number, Chosen> => {
return mine;
};
/** 재료 고름(시험 입력 · 저장 안 함) — 시험 계산 칸(`host`)의 자재 글을 바꾸고 다시 셈 · 상세 표 줄 모달이 씀 */
export function pickMaterial(
host: HTMLElement,
values: Record<string, string>,
i: number,
it: Chosen,
): void {
picksOf(values).set(i, it);
const link = host.querySelector<HTMLElement>(`[data-mat-idx="${i}"]`);
if (link) link.textContent = it.label;
host.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
}
/** 계산 요청 몸의 `고름` — 고른 것이 없으면 안 실음 */
export function pickBody(values: Record<string, string>): { 고름?: Record<string, string> } {
const mine = picksOf(values);
@@ -78,9 +92,6 @@ export function materialRows(
const held = mine.get(i);
const nowName =
held?.label ?? (brief ? `${brief.이름 ?? ""} ${brief.규격 ?? ""}`.trim() : "") ?? "";
const seeds = [cond?.["검색어"], cond?.이름, cond?.상세구분, cond?.구분, label]
.map(headWord)
.filter((w, k, all) => w && all.indexOf(w) === k);
// 잡힌 품목 이름 = 링크 단추 — 누르면 고르기 모달([품목 고르기] 단추 걷음)
const current = createButton({
label: nowName || tx("Mat_NoPrice"),
@@ -90,20 +101,26 @@ export function materialRows(
current.dataset.matIdx = String(i);
current.dataset.ref = brief?.ref ?? "";
current.title = tx("Pick_Item");
const title = `${label}${item.규격 ? ` ${item.규격}` : ""}`;
const pick = (): void =>
openPickTable({
openMaterialModal({
title,
expr: item.수량,
...(cond ? { cond } : { element: item.요소 }),
unit: item.단위,
kind: "재료",
title: `${tx("Pick_MatTitle")} · ${label}`,
seed: seeds[0] ?? "",
alt: seeds.slice(1),
isCurrent: (r) => String(r["키"]) === (mine.get(i)?.ref ?? current.dataset.ref),
onPick: (r) => {
mine.set(i, { ref: String(r["키"]), label: nameOf(r) });
current.textContent = nameOf(r);
prices,
middles: row.중간 ?? [],
values,
unitRef: mine.get(i)?.ref || current.dataset.ref || undefined,
usedSlot: current.dataset.slot || undefined,
onPick: (it) => {
mine.set(i, it);
current.textContent = it.label;
again();
},
});
return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current);
return line(title, current);
});
}
@@ -117,6 +134,7 @@ export function showItems(
host.querySelectorAll<HTMLElement>("[data-mat-idx]").forEach((span) => {
const i = Number(span.dataset.matIdx);
const item = lines[i]?.품목;
span.dataset.slot = item?.값칸 ?? ""; // 시험 계산이 쓴 값 칸 — 모달 위 가로 칸 굵게
if (!item || mine.has(i)) return;
span.dataset.ref = item.키;
span.textContent = `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키;