방식 C 를 테스트 컨테이너에서 꺼내 로직 화면 기본 보기로 올림(옛 호표 표는 「고급」). 상자를 누르면 값·출처에 더해 쉬운 말 풀이와 원문 표 미리보기(걸린 줄 강조)가 펼쳐짐. 자체 로직은 [고치기] 로 상자 안에서 고치고 줄을 더하거나 지움 — 정본은 「본떠 만들기」 뒤. - UI_Test_C·_Model·_Style → UI_Logic_Flow·_Model·_Style - UI_Test_Pick 의 재료·기계 바꿔 고르기를 UI_Logic_Pick 에 합침 - 방식 B 의 설명 카드 로직을 UI_Logic_Note 로 옮겨 흐름 그림과 같이 씀 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_Logic_Pick.ts
|
|
* 고르기 두 가지
|
|
* ① 요소 찾기 창 — 그룹 전체에서 이름·키·원문번호로 찾아 호표 줄에 넣음(`GET /elements`)
|
|
* 표 고르기(소요량·계수)는 값칸 단추를 눌러 `찾기(…).칸` 으로 수량에 넣음
|
|
* ② 재료·기계 바꿔 고르기 — 구분 → 상세구분 → 후보 순으로 좁힘
|
|
* 바꾼 것은 시험 계산용 복사본에만 · 정본 로직은 그대로
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
createButton,
|
|
createInputField,
|
|
createSelectField,
|
|
el,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import {
|
|
fetchMaterials,
|
|
searchElements,
|
|
searchPrice,
|
|
type ElementBrief,
|
|
type HoLine,
|
|
type LogicOne,
|
|
type LogicRow,
|
|
} from "./M01_MasterData_UI_Logic_Api";
|
|
import { formatNumber, type PickDone, type PickMode } from "./M01_MasterData_UI_Logic_Edit";
|
|
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
|
|
|
const GROUPS: Record<PickMode, string[]> = {
|
|
element: ["인력", "재료", "기계", "로직"],
|
|
table: ["소요량", "계수"],
|
|
};
|
|
|
|
export function openPicker(mode: PickMode, group: string, done: PickDone): void {
|
|
const groups = GROUPS[mode];
|
|
const groupField = createSelectField({
|
|
options: groups.map((g) => ({ value: g, text: g })),
|
|
value: groups.includes(group) ? group : groups[0],
|
|
compact: true,
|
|
});
|
|
const select = groupField.select;
|
|
const searchField = createInputField({ type: "search", placeholder: tx("Pick_Search") });
|
|
const search = searchField.input;
|
|
const count = el("span", { className: "m01-logic__muted" });
|
|
const list = el("div", { className: "m01-logic__pick-list" });
|
|
const close = (): void => backdrop.remove();
|
|
const dialog = el("div", {
|
|
className: "m01-logic__pick",
|
|
attrs: { role: "dialog", "aria-label": tx("Pick_Title") },
|
|
children: [
|
|
el("div", {
|
|
className: "m01-logic__section-head",
|
|
children: [
|
|
el("h3", { text: tx("Pick_Title") }),
|
|
createButton({ label: tx("Pick_Close"), variant: "ghost", onClick: close }),
|
|
],
|
|
}),
|
|
el("div", {
|
|
className: "m01-logic__pick-bar",
|
|
children: [groupField.root, searchField.root, count],
|
|
}),
|
|
list,
|
|
],
|
|
});
|
|
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());
|
|
|
|
const choose = (item: ElementBrief, column?: string): void => {
|
|
close();
|
|
done(item, column);
|
|
};
|
|
let seq = 0;
|
|
const load = async (): Promise<void> => {
|
|
const mine = ++seq;
|
|
try {
|
|
const found = await searchElements(select.value, search.value.trim());
|
|
if (mine !== seq) return; // 최신 응답만
|
|
count.textContent = tx("Pick_Total", { n: found.total });
|
|
list.replaceChildren(...found.items.map((item) => itemRow(item, mode, choose)));
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
|
}
|
|
};
|
|
let timer = 0;
|
|
search.addEventListener("input", () => {
|
|
window.clearTimeout(timer);
|
|
timer = window.setTimeout(load, 250);
|
|
});
|
|
select.addEventListener("change", load);
|
|
document.body.append(backdrop);
|
|
search.focus();
|
|
void load();
|
|
}
|
|
|
|
function itemRow(
|
|
item: ElementBrief,
|
|
mode: PickMode,
|
|
choose: (item: ElementBrief, column?: string) => void,
|
|
): HTMLElement {
|
|
const text = el("div", {
|
|
className: "m01-logic__stack",
|
|
children: [
|
|
el("strong", { text: `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() }),
|
|
el("span", { className: "m01-logic__muted", text: item.ref }),
|
|
],
|
|
});
|
|
if (mode === "table") {
|
|
const columns = Object.keys(item.값칸 ?? {});
|
|
const chips = columns.map((column) =>
|
|
createButton({ label: column, variant: "pill", onClick: () => choose(item, column) }),
|
|
);
|
|
return el("div", {
|
|
className: "m01-logic__pick-row",
|
|
children: [text, el("div", { className: "m01-logic__chips", children: chips })],
|
|
});
|
|
}
|
|
const unit = typeof item.단위 === "string" ? item.단위 : "";
|
|
const value = typeof item.값 === "object" && item.값 !== null ? "{…}" : formatNumber(item.값);
|
|
const row = el("button", {
|
|
className: "m01-logic__pick-row m01-logic__pick-row--button",
|
|
attrs: { type: "button" },
|
|
children: [
|
|
text,
|
|
el("span", { className: "m01-logic__money", text: `${value} ${unit}`.trim() }),
|
|
],
|
|
});
|
|
row.addEventListener("click", () => choose(item));
|
|
return row;
|
|
}
|
|
|
|
/* ── ② 재료·기계 바꿔 고르기 ─────────────────────────────────────────────── */
|
|
|
|
export type Swaps = Map<number, ElementBrief>;
|
|
|
|
const PICKABLE = ["재료", "기계"];
|
|
|
|
/** 고를 수 있는 줄이 있는지 */
|
|
export const hasPickable = (logic: LogicRow): boolean =>
|
|
(logic.호표 ?? []).some((l) => PICKABLE.includes(l.종류));
|
|
|
|
/** 바꿔 본 줄이 있으면 고친 복사본 — 없으면 undefined(정본 그대로 셈) */
|
|
export function swappedRow(logic: LogicRow, swaps: Swaps): LogicRow | undefined {
|
|
if (!swaps.size) return undefined;
|
|
return {
|
|
...logic,
|
|
호표: (logic.호표 ?? []).map((l, i) => {
|
|
const s = swaps.get(i);
|
|
return s ? { ...l, 요소: s.ref, 이름: s.이름 ?? l.이름, 규격: s.규격 ?? l.규격 } : l;
|
|
}),
|
|
};
|
|
}
|
|
|
|
type Brief = ElementBrief & {
|
|
구분?: string;
|
|
상세구분?: string;
|
|
값들?: Record<string, unknown>;
|
|
};
|
|
|
|
const distinct = (items: Brief[], key: "구분" | "상세구분"): string[] => [
|
|
...new Set(items.map((i) => i[key]).filter((v): v is string => !!v)),
|
|
];
|
|
|
|
/** 후보 글 — 값 열이 여럿이면 있는 열을 다 보임(계산은 그 가운데 낮은 값) */
|
|
const label = (it: Brief): string => {
|
|
const cols = Object.entries(it.값들 ?? {})
|
|
.filter(([, v]) => v !== null && v !== undefined)
|
|
.map(([k, v]) => `${k} ${formatNumber(v)}`);
|
|
const prices = cols.length ? cols : [formatNumber(it.값) || tx("Mat_NoPrice")];
|
|
return [`${it.이름 ?? ""} ${it.규격 ?? ""}`.trim(), ...prices].join(" · ");
|
|
};
|
|
|
|
const oldNames = (one: LogicOne, line: HoLine): string[] => {
|
|
const brief = one.prices[line.요소 as string] as { 이름?: string; 원문번호?: string } | null;
|
|
const names = [brief?.이름, brief?.원문번호, (line.이름 ?? "").split(/[\s(·]/)[0]];
|
|
return [...new Set(names.filter((n): n is string => !!n))];
|
|
};
|
|
|
|
/** 이름을 차례로 찾아 후보가 나오는 첫 결과 — 다 비면 마지막 결과 */
|
|
async function firstFound(names: string[]): Promise<Awaited<ReturnType<typeof searchPrice>>> {
|
|
let last = await searchPrice(names[0] ?? "");
|
|
for (const name of names.slice(1)) {
|
|
if (last.items.length) break;
|
|
last = await searchPrice(name);
|
|
}
|
|
return last;
|
|
}
|
|
|
|
function swapRow(one: LogicOne, line: HoLine, i: number, swaps: Swaps, onChange: () => void) {
|
|
const cond =
|
|
typeof line.요소 === "object" ? (line.요소 as unknown as Record<string, unknown>) : null;
|
|
const price = cond ? undefined : one.prices[line.요소]?.값;
|
|
let all: Brief[] = [];
|
|
let sub = "";
|
|
let detail = String(cond?.["상세구분"] ?? "");
|
|
|
|
const subBox = createSelectField({
|
|
options: [],
|
|
value: "",
|
|
compact: true,
|
|
onChange: (v) => ((sub = v), (detail = ""), fill()),
|
|
});
|
|
const detailBox = createSelectField({
|
|
options: [],
|
|
value: "",
|
|
compact: true,
|
|
onChange: (v) => ((detail = v), fill()),
|
|
});
|
|
const itemBox = createSelectField({
|
|
options: [{ value: "", text: tx("Mat_Default") }],
|
|
value: "",
|
|
onChange: (ref) => {
|
|
const item = all.find((it) => it.ref === ref);
|
|
if (item) swaps.set(i, item);
|
|
else swaps.delete(i);
|
|
onChange();
|
|
},
|
|
});
|
|
const subRow = el("div", {
|
|
className: "m01-test__filters",
|
|
children: [subBox.root, detailBox.root],
|
|
});
|
|
|
|
// 구분 → 상세구분 → 후보 — 앞 단을 고르면 뒷 단 목록이 좁혀짐(구분 칸이 없는 후보면 그 단은 숨김)
|
|
const fill = (): void => {
|
|
const subs = distinct(all, "구분");
|
|
const inSub = all.filter((it) => !sub || it.구분 === sub);
|
|
const details = distinct(inSub, "상세구분");
|
|
const inDetail = inSub.filter((it) => !detail || it.상세구분 === detail);
|
|
const all_ = { value: "", text: tx("Pick_All") };
|
|
subBox.root.hidden = subs.length < 1;
|
|
detailBox.root.hidden = details.length < 1;
|
|
subBox.setOptions([all_, ...subs.map((v) => ({ value: v, text: v }))], sub);
|
|
detailBox.setOptions([all_, ...details.map((v) => ({ value: v, text: v }))], detail);
|
|
itemBox.setOptions(
|
|
[
|
|
{ value: "", text: tx("Mat_Default") },
|
|
...inDetail.map((it) => ({ value: it.ref, text: label(it) })),
|
|
],
|
|
swaps.get(i)?.ref ?? "",
|
|
);
|
|
};
|
|
|
|
// 고르기 조건({구분, 상세구분, 규격}) 줄 = 조건 안 후보 · 옛 줄 = 이름으로 찾은 후보
|
|
const found = cond
|
|
? fetchMaterials({
|
|
sub: String(cond["구분"] ?? ""),
|
|
spec: String(cond["규격"] ?? ""),
|
|
region: "울진",
|
|
})
|
|
: line.종류 === "재료"
|
|
? firstFound(oldNames(one, line)) // 옛 줄(고르기 조건 없음) — 품셈재료 이름 → 원문번호 → 이름 첫 낱말 순으로 찾아 나온 첫 것
|
|
: searchElements(line.종류, line.이름 ?? "");
|
|
void found
|
|
.then((r) => {
|
|
all = r.items as Brief[];
|
|
if (cond) sub = String(cond["구분"] ?? "");
|
|
fill();
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
const spec = line.규격 ? ` ${line.규격}` : "";
|
|
const cost =
|
|
price !== undefined && price !== null ? ` · ${tx("Mat_Price")} ${formatNumber(price)}` : "";
|
|
return el("div", {
|
|
className: "m01-test__pick",
|
|
children: [
|
|
el("strong", { text: `${line.종류} · ${line.이름 ?? String(line.요소)}${spec}` }),
|
|
el("span", { className: "m01c__muted", text: `${line.단위 ?? ""}${cost}` }),
|
|
subRow,
|
|
itemBox.root,
|
|
],
|
|
});
|
|
}
|
|
|
|
/** 재료·기계 줄마다 한 칸씩 — 한 번 만들어 두고 다시 그릴 때 같은 노드를 씀(고른 것이 안 날아감) */
|
|
export function pickPanel(one: LogicOne, swaps: Swaps, onChange: () => void): HTMLElement {
|
|
const rows = (one.logic.호표 ?? [])
|
|
.map((line, i) => ({ line, i }))
|
|
.filter((x) => PICKABLE.includes(x.line.종류))
|
|
.map((x) => swapRow(one, x.line, x.i, swaps, onChange));
|
|
return el("div", {
|
|
className: "m01-test__picks",
|
|
children: rows.length ? rows : [el("p", { text: tx("Mat_None") })],
|
|
});
|
|
}
|