/* ============================================================================= * M02_Material_Pick.ts * M01 자재 하나 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(일위대가 자재목록 · 자재 추가가 씀). * · 일위대가 [자재 추가] 단추(`addMaterialButton` · 148 에서 `Labor_Detail` 이 덜어 냄) * · 개인 층(148-3) = 개인 자재(「개인」 표 · 앞) + M01 · 머리 [신규 자재] → 품명 · 단위(필수) · 규격 · 단가 → * 서버가 품번(`PM` + 6자리)을 주고 그 줄로 고름(`M02_Material_Own`) * 모양(`m02-pick*`)은 `M02_MasterTemplete_Structure.css`. * ========================================================================== */ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; import { call } from "./M02_MasterTemplete_Api_Fetch"; import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; /** 고른 자재 한 줄 — M01 자재(키 `MT`) · 개인 자재(키 `PM`) */ export interface MaterialItem { 코드: string; 이름: string | null; 규격: string | null; 단위: string | null; } /** 개인 자재 한 줄(서버 `개인자재.json` 꼴) */ interface OwnRow { 키: string; 이름: string; 규격: string; 단위: string; 값: number | null; } /** 개인 층 주인(148-3 · 지금 층이면 null) — 넘기면 개인 자재 · [신규 자재] */ export interface OwnLayer { owner: string | null; } const TEXT = { pickTitle: "자재 추가", pickClose: "닫기", search: "자재 이름 · 규격 · 코드", searching: "찾는 중…", noMatch: "맞는 자재 없음", searchFailed: "자재 목록을 못 받음", newMat: "신규 자재", own: "개인", noPrice: "단가 없음", name: "품명", spec: "규격", unit: "단위", price: "단가(원 · 비우면 단가 없음)", key: "품번 — 저장 때 자동", save: "저장", back: "목록으로", needName: "품명 · 단위는 꼭 적음", }; const ownPath = (own: OwnLayer): string => `/layers/personal/materials${own.owner ? `?owner=${encodeURIComponent(own.owner)}` : ""}`; const toItem = (r: OwnRow): MaterialItem => ({ 코드: r.키, 이름: r.이름, 규격: r.규격, 단위: r.단위, }); /** 찾기 낱말이 모두 이름 · 규격 · 키에 듦(M01 찾기와 같은 규칙) */ const matches = (r: OwnRow, words: string[]): boolean => { const hay = `${r.이름} ${r.규격} ${r.키}`.toLowerCase(); return words.every((w) => hay.includes(w)); }; /** 자재 고르기 창 — 고르면 `done` 부르고 닫음 · `own` = 개인 층(개인 자재 · [신규 자재]) */ export function pickMaterial(done: (item: MaterialItem) => void, own?: OwnLayer): void { let mine: OwnRow[] = []; const loaded = own ? call<{ 줄: OwnRow[] }>(ownPath(own)) .then((d) => void (mine = d.줄)) .catch(() => undefined) : Promise.resolve(); let showNew = (): void => undefined; const newButton = createButton({ label: TEXT.newMat, variant: "ghost", onClick: () => showNew(), }); newButton.dataset.materialNew = ""; openModal({ title: TEXT.pickTitle, closeLabel: TEXT.pickClose, dialogClass: "m02-modal m02-pick", ...(own ? { actions: [newButton] } : {}), mount: (body, close) => { const search = createInputField({ placeholder: TEXT.search, type: "search" }); const list = el("div", { className: "m02-pick__list" }); const item = (m: MaterialItem, tags: string[] = []): HTMLElement => { const row = el("button", { className: "m02-pick__item", attrs: { type: "button", "data-material": m.코드 }, children: [ el("span", { className: "m02-pick__key", text: m.코드 }), `${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "), ...tags.map((t) => el("span", { className: "m02-pick__tag", text: t })), ], }); row.addEventListener("click", () => { done(m); close(); }); return row; }; let seq = 0; const paint = async (): Promise => { const ticket = ++seq; list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching })); try { const q = search.input.value.trim(); const [hits] = await Promise.all([searchMaterials(q), loaded]); if (ticket !== seq) return; const words = q.toLowerCase().split(/\s+/).filter(Boolean); const ownHits = mine .filter((r) => matches(r, words)) .map((r) => item(toItem(r), r.값 === null ? [TEXT.own, TEXT.noPrice] : [TEXT.own])); const all = [...ownHits, ...hits.map((m) => item(m))]; list.replaceChildren( ...(all.length ? all : [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]), ); } catch { if (ticket === seq) showToast(TEXT.searchFailed, "error"); } }; let timer = 0; search.input.addEventListener("input", () => { window.clearTimeout(timer); timer = window.setTimeout(() => void paint(), 250); }); const picking = [search.root, list]; if (own) showNew = () => { newButton.hidden = true; body.replaceChildren( newForm( own, search.input.value.trim(), (row) => { done(toItem(row)); close(); }, () => { newButton.hidden = false; body.replaceChildren(...picking); }, ), ); }; body.append(...picking); void paint(); search.input.focus(); }, }); } /** 신규 자재 입력(148-3) — [저장] = 서버 추가 → 그 줄로 `saved` · 실패는 서버 까닭 그대로 */ function newForm( own: OwnLayer, seed: string, saved: (row: OwnRow) => void, back: () => void, ): HTMLElement { const name = createInputField({ label: TEXT.name, value: seed, required: true }); const spec = createInputField({ label: TEXT.spec }); const price = createInputField({ label: TEXT.price, type: "number", min: 0 }); let unit = ""; const unitHost = el("span"); const drawUnit = (): void => unitHost.replaceChildren( createUnitPick({ value: unit || NO_UNIT, ariaLabel: TEXT.unit, onPick: (u) => { unit = u === NO_UNIT ? "" : u; drawUnit(); }, }), ); drawUnit(); const save = createButton({ label: TEXT.save, variant: "filled", onClick: async () => { const 이름 = name.input.value.trim(); if (!이름 || !unit) return showToast(TEXT.needName, "error"); const raw = price.input.value.trim(); try { saved( await call(ownPath(own), { method: "POST", body: JSON.stringify({ 이름, 규격: spec.input.value.trim(), 단위: unit, 값: raw === "" ? null : Number(raw), }), }), ); } catch (e) { showToast(e instanceof Error ? e.message : String(e), "error"); } }, }); return el("div", { className: "m02-pick__new", children: [ name.root, spec.root, el("div", { className: "ui-field", children: [el("label", { className: "ui-field__label", text: TEXT.unit }), unitHost], }), price.root, el("p", { className: "m02-struct__empty", text: TEXT.key }), el("div", { className: "ui-btn-group", children: [createButton({ label: TEXT.back, variant: "ghost", onClick: back }), save], }), ], }); } /** 일위대가 [자재 추가] 단추 — 누르면 고르기 창 · 고른 줄로 `done`(막을지는 부르는 쪽) */ export const addMaterialButton = ( label: string, done: (item: MaterialItem) => void, own?: OwnLayer, ): HTMLElement => createButton({ label, variant: "filled", onClick: () => pickMaterial(done, own) });