From 4c8ee6828f3a33353d2a457f9690e678a72898e4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 19:14:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20=EC=9E=90=EC=9E=AC=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=ED=99=94=EB=A9=B4=20=E2=80=94=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=C2=B7=20=EC=83=81=EC=84=B8=20=C2=B7=20=EC=9E=90=EC=9E=AC=20?= =?UTF-8?q?=EB=8D=94=ED=95=98=EA=B8=B0=20=C2=B7=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 「자재 조합」 컨테이너(찾기) + 우측 목록(키 · 이름 · 자재 수 · 본뜬키 · 소유) · 상세(이름 · 비고 · 본뜬키 · 담은 자재 표 · 빼기) - [자재 더하기] = 자재DB 낱말 찾기 → 하나씩 고름(이미 담은 자재는 잠김) · 저장 · 지우기는 M01 길 /api/m01/matset… - 쓰는 구조물 열이 있어 지울 수 없으면 서버가 준 구조물 이름들을 알림으로 보임 - 알약 키 「이름|단위」 주석 갱신 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp --- M01_MasterData/M01_MasterData_Api_Fetch.ts | 2 +- M01_MasterData/M01_MasterData_UI_Matset.ts | 367 ++++++++++++++++++ .../M01_MasterData_UI_Matset_Api.ts | 60 +++ .../M01_MasterData_UI_Matset_Text.ts | 59 +++ M01_MasterData/M01_MasterData_UI_Page.ts | 26 +- M01_MasterData/M01_MasterData_UI_Side.ts | 17 +- 6 files changed, 525 insertions(+), 6 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_Matset.ts create mode 100644 M01_MasterData/M01_MasterData_UI_Matset_Api.ts create mode 100644 M01_MasterData/M01_MasterData_UI_Matset_Text.ts diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index d7b4eb05..f9105aff 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -176,7 +176,7 @@ export const fetchPick = ( q: string, ): Promise<{ total: number; items: PickItem[] }> => get("/pick", { kind, q, limit: 50 }); -/** 조합 알약 물량 하나 — 담은 로직 결과단위 분모(같은 이름 · 단위는 합침) · `배` = 원/10㎡ 의 10 */ +/** 조합 알약 물량 하나 — 키 = 「이름|단위」 · 담은 로직 결과단위 분모(같은 이름 · 단위는 합침) · `배` = 원/10㎡ 의 10 */ export interface ComboQuantity { 키: string; 이름: string; diff --git a/M01_MasterData/M01_MasterData_UI_Matset.ts b/M01_MasterData/M01_MasterData_UI_Matset.ts new file mode 100644 index 00000000..d7bded9e --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Matset.ts @@ -0,0 +1,367 @@ +/* ============================================================================= + * M01_MasterData_UI_Matset.ts + * 자재 조합 화면 — 왼쪽 컨테이너(찾기) / 오른쪽 목록 · 상세 (일위대가 조합 화면과 같은 틀) + * 상세 = 이름 · 비고 + 본뜬키 줄 + 담은 자재 표(코드 · 이름 · 규격 · 단위 · 빼기) + [자재 더하기] + * 저장 · 지우기는 서버 자재 조합 마스터(`자재조합.json` · 키 UM) — 길은 `Matset_Api` 한 곳 + * ========================================================================== */ + +import { + createButton, + createInputField, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { searchMaterials } from "./M01_MasterData_Api_Fetch"; +import { ApiError } from "./M01_MasterData_UI_Logic_Api"; +import { + deleteMatset, + editMatset, + fetchMatset, + fetchMatsets, + newMatset, + type MatItem, + type Matset, + type MatsetSummary, +} from "./M01_MasterData_UI_Matset_Api"; +import { tm } from "./M01_MasterData_UI_Matset_Text"; +import type { SideHandle } from "./M01_MasterData_UI_Side"; +import "./M01_MasterData_UI_Logic_Style.css"; +import "./M01_MasterData_UI_LogicLab_Style.css"; + +const blank = (): Matset => ({ + 키: "", + 이름: "", + 담은자재: [], + 본뜬키: null, + 소유: "현장", + 비고: null, +}); + +/** 서버가 준 까닭 — 422 · 409(지우기 막음) 는 글 목록 · 409 낡은 판본은 stale */ +function reason(error: unknown): string { + if (error instanceof ApiError && typeof error.detail === "object" && error.detail) { + const d = error.detail as { errors?: string[]; stale?: string[] }; + if (d.errors) return d.errors.join(" / "); + if (d.stale) return tm("Stale"); + } + return error instanceof Error ? error.message : tm("Load_Failed"); +} + +const fail = (error: unknown): void => + showToast(error instanceof Error ? error.message : tm("Load_Failed"), "error"); + +/** [자재 더하기] 창 — 낱말로 자재DB 를 찾아 하나씩 고름 */ +function openMaterialPicker(have: Set, done: (item: MatItem) => void): void { + const search = createInputField({ type: "search", placeholder: tm("Pick_Search") }); + const list = el("div", { className: "m01-logic__pick-list" }); + const hint = el("p", { className: "m01-logic__muted", text: tm("Pick_Hint") }); + const close = (): void => backdrop.remove(); + const find = async (): Promise => { + try { + const found = await searchMaterials(search.input.value.trim()); + hint.textContent = found.length ? String(found.length) : tm("Pick_None"); + list.replaceChildren( + ...found.map((x) => { + const taken = have.has(x.코드); + const row = el("button", { + className: "m01-logic__pick-row m01-logic__pick-row--button", + attrs: { type: "button", "data-pick": x.코드, ...(taken ? { disabled: "" } : {}) }, + children: [ + el("div", { + className: "m01-logic__stack", + children: [ + el("strong", { text: x.이름 }), + el("span", { className: "m01-logic__muted", text: `${x.코드} · ${x.규격}` }), + ], + }), + el("span", { text: taken ? tm("Pick_Have") : x.단위 }), + ], + }); + row.addEventListener("click", () => { + close(); + done(x); + }); + return row; + }), + ); + } catch (error) { + fail(error); + } + }; + search.input.addEventListener("keydown", (ev) => ev.key === "Enter" && void find()); + const dialog = el("div", { + className: "m01-logic__pick", + attrs: { role: "dialog", "aria-label": tm("Pick_Title") }, + children: [ + el("div", { + className: "m01-logic__section-head", + children: [ + el("h3", { text: tm("Pick_Title") }), + createButton({ label: tm("Pick_Close"), variant: "ghost", onClick: close }), + ], + }), + el("div", { + className: "m01-logic__filters", + children: [ + search.root, + createButton({ label: tm("Pick_Find"), variant: "filled", onClick: () => void find() }), + ], + }), + hint, + 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()); + document.body.append(backdrop); + search.input.focus(); +} + +export interface MatsetHandle { + /** 컨테이너를 펼칠 때 — 목록을 새로 받아 그림 */ + show: () => Promise; +} + +export function mountM01Matset(host: HTMLElement, side: SideHandle): MatsetHandle { + let items: MatsetSummary[] = []; + let q = ""; + /** 화면을 바꾼 횟수 — 늦게 온 답은 이 값이 달라졌으면 버림 */ + let nav = 0; + /** 열어 둔 상세 — 다른 화면에 갔다 와도 그대로 돌아오게 기억 */ + let current: { matset: Matset; version: string } | null = null; + const view = el("div", { className: "m01-logic__editor" }); + host.replaceChildren(view); + + const find = createInputField({ type: "search", placeholder: tm("Search") }); + find.input.addEventListener("input", () => { + q = find.input.value.trim().toLowerCase(); + if (!current) drawList(); + }); + side.matsetHost.replaceChildren(find.root); + + const load = async (redraw = true): Promise => { + const my = nav; + try { + items = await fetchMatsets(); + } catch (error) { + fail(error); + } + if (redraw && my === nav) drawList(); + }; + + const show = async (): Promise => { + if (!items.length && !current) { + view.replaceChildren(el("p", { className: "m01-logic__muted", text: tm("Loading") })); + } + await load(false); + if (current) draw(current.matset, current.version); + else drawList(); + }; + + /* --- 목록 --- */ + const drawList = (): void => { + nav++; + current = null; + const shown = items.filter((x) => !q || `${x.키} ${x.이름}`.toLowerCase().includes(q)); + const heads = [ + tm("Head_Key"), + tm("Head_Name"), + tm("Head_Count"), + tm("Head_Origin"), + tm("Head_Owner"), + ]; + const rows = shown.map((x) => { + const tr = el("tr", { + className: "m01-master__table-row", + attrs: { "data-matset-key": x.키 }, + children: [x.키, x.이름, String(x.count), x.본뜬키 ?? tm("Origin_Self"), x.소유 ?? ""].map( + (v) => el("td", { text: v }), + ), + }); + tr.addEventListener("click", () => void open(x.키)); + return tr; + }); + view.replaceChildren( + el("div", { + className: "m01-logic__section-head", + children: [ + createButton({ label: tm("New"), variant: "filled", onClick: () => draw(blank(), "") }), + ], + }), + shown.length + ? el("div", { + className: "m01-master__grid-wrap", + children: [ + el("table", { + className: "m01-master__grid", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: rows }), + ], + }), + ], + }) + : el("p", { className: "m01-logic__muted", text: tm("Empty") }), + ); + }; + + const open = async (key: string): Promise => { + try { + const my = ++nav; + const one = await fetchMatset(key); + if (my !== nav) return; + draw(one.matset, one.version); + } catch (error) { + fail(error); + } + }; + + /* --- 상세 --- */ + const draw = (matset: Matset, version: string): void => { + nav++; + current = { matset, version }; + const field = (label: string, name: "이름" | "비고") => { + const f = createInputField({ type: "text", label }); + f.input.value = matset[name] ?? ""; + f.input.setAttribute("data-matset-field", name); + f.input.addEventListener("input", () => { + if (name === "이름") matset.이름 = f.input.value; + else matset.비고 = f.input.value || null; + }); + return f.root; + }; + const table = el("div", { className: "m01-master__grid-wrap" }); + const drawTable = (): void => { + const trs = matset.담은자재.map((m, i) => + el("tr", { + attrs: { "data-matset-item": m.코드 }, + children: [ + el("td", { text: m.코드 }), + el("td", { text: m.이름 }), + el("td", { text: m.규격 }), + el("td", { text: m.단위 }), + el("td", { + children: [ + createButton({ + label: tm("Remove"), + variant: "ghost", + size: "sm", + onClick: () => { + matset.담은자재.splice(i, 1); + drawTable(); + }, + }), + ], + }), + ], + }), + ); + const heads = [ + tm("Col_Code"), + tm("Col_Name"), + tm("Col_Spec"), + tm("Col_Unit"), + tm("Col_Move"), + ]; + table.replaceChildren( + el("table", { + className: "m01-master__grid", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: trs }), + ], + }), + ...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tm("NoItem") })]), + ); + }; + drawTable(); + + const add = createButton({ + label: tm("AddItem"), + variant: "filled", + onClick: () => + openMaterialPicker(new Set(matset.담은자재.map((m) => m.코드)), (x) => { + matset.담은자재.push({ 코드: x.코드, 이름: x.이름, 규격: x.규격, 단위: x.단위 }); + drawTable(); + }), + }); + const save = createButton({ + label: tm("Save"), + variant: "filled", + onClick: async () => { + if (!matset.이름.trim()) return showToast(tm("NeedName"), "error"); + if (!matset.담은자재.length) return showToast(tm("NeedItem"), "error"); + const my = nav; + try { + const key = matset.키 ? matset.키 : (await newMatset(matset)).key; + if (matset.키) await editMatset(matset.키, version, matset); + showToast(tm("Saved"), "success"); + await load(false); + if (my !== nav) { + if (current?.matset === matset) current = null; // 저장 사이에 나감 — 옛 판본을 붙들지 않음 + return; + } + await open(key); + } catch (error) { + showToast(reason(error), "error"); + } + }, + }); + const remove = createButton({ + label: tm("Delete"), + variant: "ghost", + onClick: async () => { + if (!matset.키 || !(await showConfirmDialog(tm("DeleteAsk")))) return; + try { + await deleteMatset(matset.키, version); + showToast(tm("Deleted"), "success"); + await load(); + } catch (error) { + showToast(reason(error), "error"); + } + }, + }); + const back = createButton({ label: "←", variant: "ghost", onClick: () => drawList() }); + view.replaceChildren( + el("div", { + className: "m01-logic__section-head", + children: [back, el("h3", { text: matset.키 || tm("New") }), remove, save], + }), + el("section", { + className: "m01lab__box m01-logic__section", + children: [ + el("div", { + className: "m01lab__info", + children: [ + field(tm("Head_Name"), "이름"), + field(tm("Head_Note"), "비고"), + el("p", { + className: "m01-logic__muted", + text: `${tm("Head_Origin")}: ${matset.본뜬키 ?? tm("Origin_Self")}`, + attrs: { "data-matset-origin": matset.본뜬키 ?? "" }, + }), + ], + }), + ], + }), + el("section", { + className: "m01lab__box m01-logic__section", + children: [ + el("div", { + className: "m01-logic__section-head", + children: [el("h3", { text: tm("Items_Title") }), add], + }), + table, + ], + }), + ); + }; + + return { show }; +} diff --git a/M01_MasterData/M01_MasterData_UI_Matset_Api.ts b/M01_MasterData/M01_MasterData_UI_Matset_Api.ts new file mode 100644 index 00000000..251b6ea0 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Matset_Api.ts @@ -0,0 +1,60 @@ +/* ============================================================================= + * M01_MasterData_UI_Matset_Api.ts + * 자재 조합(`자재조합.json` · 키 UM) 서버 길 — 목록 · 하나 · 새로 · 고치기 · 지우기 + * 계약: `resources/master_data/_화면_계약.md` 9장 「자재 조합」 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { ApiError } from "./M01_MasterData_UI_Logic_Api"; + +/** 담은 자재 한 줄 — 고를 때의 사본(수량 · 단가는 안 둠) */ +export interface MatItem { + 코드: string; + 이름: string; + 규격: string; + 단위: string; +} +export interface Matset { + 키: string; + 이름: string; + 담은자재: MatItem[]; + /** 본뜬 원본 자재 조합 키 — 비면 원본 */ + 본뜬키: string | null; + 소유: string | null; + 비고: string | null; +} +export interface MatsetSummary extends Omit { + count: number; +} +export interface MatsetOne { + file: string; + version: string; + matset: Matset; +} + +async function request(path: string, body?: unknown): Promise { + const response = await fetch(`${API_BASE_URL}/m01${path}`, { + method: body === undefined ? "GET" : "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); + return data; +} + +export const fetchMatsets = (): Promise => + request<{ matsets: MatsetSummary[] }>("/matsets").then((d) => d.matsets); + +export const fetchMatset = (key: string): Promise => + request(`/matset?key=${encodeURIComponent(key)}`); + +export const newMatset = (matset: Matset): Promise<{ key: string; version: string }> => + request("/matset/new", { matset }); + +export const editMatset = (key: string, version: string, matset: Matset): Promise => + request("/matset/edit", { key, version, matset }); + +export const deleteMatset = (key: string, version: string): Promise => + request("/matset/delete", { key, version }); diff --git a/M01_MasterData/M01_MasterData_UI_Matset_Text.ts b/M01_MasterData/M01_MasterData_UI_Matset_Text.ts new file mode 100644 index 00000000..734bb890 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Matset_Text.ts @@ -0,0 +1,59 @@ +/* ============================================================================= + * M01_MasterData_UI_Matset_Text.ts + * 자재 조합 화면 글자 — [한국어, 영어] + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +const TEXT = { + Title: ["자재 조합", "Material set"], + Search: ["이름 · 키로 찾기", "Search by name or key"], + Empty: [ + "자재 조합이 없음 — [새 자재 조합]으로 만듦", + "No material sets — press [New material set]", + ], + New: ["새 자재 조합", "New material set"], + Delete: ["지우기", "Delete"], + Save: ["저장", "Save"], + DeleteAsk: ["이 자재 조합을 지울까요?", "Delete this material set?"], + Saved: ["저장함", "Saved"], + Deleted: ["지움", "Deleted"], + Stale: ["그 사이 자재 조합 파일이 바뀜 — 다시 열어 주세요", "The file changed — reopen it"], + Loading: ["불러오는 중…", "Loading…"], + NeedName: ["이름을 적어야 저장됨", "A name is required"], + NeedItem: ["담은 자재가 하나는 있어야 저장됨", "Add at least one material"], + Load_Failed: ["불러오지 못함", "Load failed"], + Head_Key: ["키", "Key"], + Head_Name: ["이름", "Name"], + Head_Count: ["자재 수", "Materials"], + Head_Origin: ["본뜬키", "Copied from"], + Head_Owner: ["소유", "Owner"], + Head_Note: ["비고", "Note"], + Origin_Self: ["원본", "Original"], + Items_Title: ["담은 자재", "Materials in it"], + Col_Code: ["자재 코드", "Code"], + Col_Name: ["이름", "Name"], + Col_Spec: ["규격", "Spec"], + Col_Unit: ["단위", "Unit"], + Col_Move: ["빼기", "Remove"], + Remove: ["빼기", "Remove"], + AddItem: ["자재 더하기", "Add material"], + NoItem: ["담은 자재 없음", "No materials yet"], + Pick_Title: ["자재 고르기", "Pick a material"], + Pick_Search: ["이름 · 규격으로 찾기", "Search by name or spec"], + Pick_Find: ["찾기", "Find"], + Pick_Hint: [ + "낱말을 적고 [찾기]를 누름 — 하나씩 직접 고름", + "Type words, press Find — pick one by one", + ], + Pick_None: ["찾은 자재 없음", "No materials found"], + Pick_Have: ["이미 담음", "Already in"], + Pick_Close: ["닫기", "Close"], +} as const satisfies Record; + +export type MatsetTextKey = keyof typeof TEXT; + +export function tm(key: MatsetTextKey): string { + const entry = TEXT[key]; + return entry[currentLanguageIndex as 0 | 1] ?? entry[0]; +} diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 211b16ff..e4437db8 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -39,6 +39,7 @@ import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import { renderTables } from "./M01_MasterData_UI_Tables"; import type { LogicHandle } from "./M01_MasterData_UI_LogicLab"; import type { ComboHandle } from "./M01_MasterData_UI_Combo"; +import type { MatsetHandle } from "./M01_MasterData_UI_Matset"; import "./M01_MasterData_UI_Style.css"; const TABLE_GROUPS = ["소요량", "계수"]; @@ -60,6 +61,7 @@ function buildPage(): HTMLElement { let dispose = (): void => {}; let logicMounted = false; let comboHandle: ComboHandle | null = null; + let matsetHandle: MatsetHandle | null = null; /* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */ const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") }); @@ -76,6 +78,7 @@ function buildPage(): HTMLElement { }); const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); const comboHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); + const matsetHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); const elementView = el("div", { className: "m01-master__elements", children: [ @@ -98,9 +101,10 @@ function buildPage(): HTMLElement { onDraftChange(refreshBar); refreshBar(); - const showMode = (mode: "elements" | "logic" | "combo"): void => { + const showMode = (mode: "elements" | "logic" | "combo" | "matset"): void => { logicHost.hidden = mode !== "logic"; comboHost.hidden = mode !== "combo"; + matsetHost.hidden = mode !== "matset"; elementView.hidden = mode !== "elements"; }; @@ -222,6 +226,22 @@ function buildPage(): HTMLElement { }); }; + /** 「자재 조합」 — 목록을 새로 받아 그림 */ + const openMatsetTab = (): void => { + dispose(); + dispose = (): void => {}; + current = null; + showMode("matset"); + if (matsetHandle) { + void matsetHandle.show(); + return; + } + void import("./M01_MasterData_UI_Matset").then((m) => { + matsetHandle = m.mountM01Matset(matsetHost, side); + void matsetHandle.show(); + }); + }; + /** 알약 → 마스터 화면 — 찾기 글을 그 키로 두고 열고, 알림줄에 「단가산출 로직으로 돌아가기」를 둠 */ const jump = async (target: MasterJump): Promise => { let group = target.kind === "element" ? target.group : ""; @@ -262,7 +282,7 @@ function buildPage(): HTMLElement { setMasterJump((target) => void jump(target)); /* --- 좌측: 컨테이너 --- */ - const side = buildSide(openFile, () => openLogicTab(), openComboTab); + const side = buildSide(openFile, () => openLogicTab(), openComboTab, openMatsetTab); const layout = el("div", { className: "ui-workflow-layout m01-master" }); const main = el("main", { @@ -270,7 +290,7 @@ function buildPage(): HTMLElement { children: [ el("div", { className: "m01-master__panel", - children: [elementView, logicHost, comboHost], + children: [elementView, logicHost, comboHost, matsetHost], }), ], }); diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 2e31508d..187d84f5 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -11,6 +11,7 @@ import { t as L } from "@ui/ui_template_locale"; import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { tc } from "./M01_MasterData_UI_Combo_Text"; +import { tm } from "./M01_MasterData_UI_Matset_Text"; import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree"; /** 컨테이너마다 기억하는 화면 상태 — 찾기 글 · 쪽 · 소요량·계수 표의 용도 대상 거름 (거름은 `m01.filter.*`) */ @@ -88,6 +89,8 @@ export interface SideHandle { logicHost: HTMLElement; /** 일위대가 조합 컨테이너 안(구분 · 상세구분 거름이 들어갈 자리) */ comboHost: HTMLElement; + /** 자재 조합 컨테이너 안(찾기 칸이 들어갈 자리) */ + matsetHost: HTMLElement; /** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */ refresh: (group: string) => Promise; /** 그룹의 파일 목록(이미 받은 것) */ @@ -146,11 +149,13 @@ const LEAF: Group[] = ["환율", "요율"]; const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"]; const LOGIC_ID = "로직|"; const COMBO_ID = "조합|"; +const MATSET_ID = "자재조합|"; export function buildSide( onOpen: (pick: Pick) => void, onLogic: () => void, onCombo: () => void = () => {}, + onMatset: () => void = () => {}, ): SideHandle { const lists = new Map(); const bodies = new Map(); @@ -460,9 +465,17 @@ ${first.file}` onCombo(); }); + const matsetHost = el("div", { className: "m01-side__items ui-sidebar-list" }); + const matset = section(tm("Title"), matsetHost); + matset.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { + if (!matset.classList.contains("is-collapsed")) return; + setActive(buttons.has(MATSET_ID) ? MATSET_ID : null); + onMatset(); + }); + const root = el("div", { className: "m01-side ui-sidebar-panel", - children: [...groups, logic, combo], + children: [...groups, logic, combo, matset], }); attachCollapsible(root); void Promise.all(GROUPS.map(refresh)).catch((error) => @@ -472,5 +485,5 @@ ${first.file}` onPicked = fn; }; const files = (group: string): FileInfo[] => lists.get(group as Group) ?? []; - return { root, logicHost, comboHost, refresh, files, setActive, setOnPicked, filter }; + return { root, logicHost, comboHost, matsetHost, refresh, files, setActive, setOnPicked, filter }; }