/* ============================================================================= * M02_DF_InputPanel_Material.ts * 입력 판 — 재료 요소 방식 셋(고정 · 선택 자리 · 조건 찾기) · 자재 찾기(띄어쓰기 AND · 규격까지 · 쪽 넘기기 · 전체 수) * 서버 `/api/m02/df/materials` 를 먼저 부르고 아직 없으면 M01 `/pick?kind=price` 로 같은 모양을 만듦 * 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField, el, showToast, } from "@ui/ui_template_elements"; import { fetchFiles, fetchSubs, type SubInfo } from "../../M01_MasterData/M01_MasterData_Api_Fetch"; import { cfValues } from "./M02_DF_Api"; import type { CfElementRef, CfLogic, CfMaterialMode, CfMaterialRule } from "./M02_DF_Model"; import { catchNote, checkMaterial, elementOf, matches, materialElement, pageRange, removeElementNamed, variablePills, type MatItem, } from "./M02_DF_InputPanel_Build"; const TEXT = { Chosen: ["넣은 재료 — 알약마다 이름 · 값 · 단위", "Chosen materials"], None: ["아직 고른 재료가 없음", "Nothing chosen yet"], Remove: ["빼기", "Remove"], Fixed: ["고정", "Fixed"], Choice: ["선택 자리", "Choice slot"], Rule: ["조건 찾기", "Find by rule"], FixedHelp: ["품목 하나를 못 박음", "One fixed item"], ChoiceHelp: [ "자리만 두고 프로젝트에서 품목을 고름(기본 품목 하나)", "A slot — pick the item per project", ], RuleHelp: [ "변수 알약 값으로 재료를 잡음 — 예: 관경 알약 값이 규격에 들어간 흄관", "Find by a variable pill", ], Search: ["찾는 말 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"], AllSub: ["전체", "All"], SubLabel: ["구분", "Group"], DetailLabel: ["상세구분", "Detail"], Loading: ["찾는 중…", "Searching…"], NoRow: ["찾는 재료 없음", "Nothing found"], Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"], Prev: ["이전", "Prev"], Next: ["다음", "Next"], SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"], Scope: ["범위", "Scope"], Word: ["찾는 말(범위 안에서)", "Word in scope"], Default: ["기본 품목", "Default item"], DefaultNone: ["아직 안 고름 — 아래 목록에서 눌러 고름", "Not chosen — pick one below"], DefaultClear: ["기본 품목 비움", "Clear"], RuleField: ["찾는 칸", "Field"], FieldSpec: ["규격", "Spec"], FieldName: ["이름", "Name"], RuleOp: ["조건", "Condition"], OpIn: ["에 알약 값이 들어 있음", "contains the pill value"], OpEq: ["이 알약 값과 같음", "equals the pill value"], RulePill: ["변수 알약", "Variable pill"], NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"], Put: ["넣기", "Add"], Pick: ["넣기", "Add"], Preview: ["지금 잡히는 재료", "Items caught now"], Price: ["가장 싼 값 {p} {u}", "cheapest {p} {u}"], RuleLine: ["{f}{o} · 알약 「{n}」 = {v}", "{f} {o} · pill “{n}” = {v}"], ScopeLine: ["범위 {s}", "scope {s}"], Failed: ["불러오지 못함", "Could not load"], } as const; type TKey = keyof typeof TEXT; const t = (key: TKey, v: Record = {}): string => Object.entries(v).reduce( (s, [k, x]) => s.replace(`{${k}}`, String(x)), TEXT[key][currentLanguageIndex] as string, ); const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR"); /* ── 자재 찾기 ── */ export interface MatQuery { q: string; sub?: string; detail?: string; page: number; size: number; } export interface MatPage { total: number; page: number; size: number; items: MatItem[]; } async function getJson(path: string): Promise { const res = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); if (!res.ok) throw new Error(String(res.status)); return (await res.json()) as T; } /** 서버가 없으면 M01 값 줄에서 같은 모양으로 — 띄어쓰기 AND(이름 + 규격) · 쪽은 앞에서 잘라 줌 */ async function fallback(q: MatQuery): Promise { const r = await getJson<{ items: Record[] }>( `/m01/pick?${new URLSearchParams({ kind: "price", q: q.q, limit: "200" })}`, ); const rows = r.items.filter((i) => !q.sub || i["구분"] === q.sub); const items = rows.map((i): MatItem => ({ ref: String(i["ref"]), name: String(i["이름"] ?? ""), spec: String(i["규격"] ?? ""), unit: String(i["단위"] ?? ""), price: typeof i["값"] === "number" ? i["값"] : null, 구분: String(i["구분"] ?? ""), })); const from = (q.page - 1) * q.size; return { total: items.length, page: q.page, size: q.size, items: items.slice(from, from + q.size), }; } export async function searchMaterials(q: MatQuery): Promise { const params = new URLSearchParams({ q: q.q, page: String(q.page), size: String(q.size), sub: q.sub ?? "", detail: q.detail ?? "", }); try { return await getJson(`/m02/df/materials?${params}`); } catch { return fallback(q); } } let subsCache: SubInfo[] | null = null; async function materialSubs(): Promise { if (subsCache) return subsCache; const files = await fetchFiles("재료"); const f = files.find((x) => x.file.includes("자재품목")) ?? files[0]; subsCache = await fetchSubs({ file: f.file }); return subsCache; } /** 구분 · 상세구분 고르기 칸 — 바뀌면 onChange */ function scopeBoxes( init: { sub: string; detail: string }, onChange: () => void, ): { root: HTMLElement; get(): { sub: string; detail: string } } { const state = { ...init }; const all = { value: "", text: t("AllSub") }; const subBox = createSelectField({ options: [all], value: "", compact: true, ariaLabel: t("SubLabel"), onChange: (v) => ((state.sub = v), (state.detail = ""), fill(), onChange()), }); const detailBox = createSelectField({ options: [all], value: "", compact: true, ariaLabel: t("DetailLabel"), onChange: (v) => ((state.detail = v), onChange()), }); let subs: SubInfo[] = []; const fill = (): void => { const d = subs.find((s) => s.name === state.sub)?.details ?? []; detailBox.root.hidden = !d.length; detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], state.detail); }; fill(); void materialSubs().then( (s) => { subs = s; subBox.setOptions([all, ...s.map((x) => ({ value: x.name, text: x.name }))], state.sub); fill(); }, () => undefined, ); return { root: el("div", { className: "m02-dfip__bar", children: [subBox.root, detailBox.root] }), get: () => ({ ...state }), }; } /** 품목 찾기 목록 — 찾는 말 · 구분 · 쪽 넘기기 · 눌러 고르기 */ function itemList( onPick: (item: MatItem) => void, base?: () => { sub: string; detail: string }, ): HTMLElement { const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); const list = el("div", { className: "m02-dfip__list" }); const info = el("span", { className: "m02-dfip__muted" }); let page = 1; let seq = 0; const own = base ? null : scopeBoxes({ sub: "", detail: "" }, () => ((page = 1), void load())); const scope = (): { sub: string; detail: string } => (base ? base() : own!.get()); const prev = createButton({ label: t("Prev"), variant: "ghost", size: "sm", onClick: () => ((page -= 1), void load()), }); const next = createButton({ label: t("Next"), variant: "ghost", size: "sm", onClick: () => ((page += 1), void load()), }); const load = async (): Promise => { const mine = ++seq; list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") })); try { const r = await searchMaterials({ q: q.input.value.trim(), ...scope(), page, size: 12 }); if (mine !== seq) return; const range = pageRange(r.total, page, 12); info.textContent = t("Total", { n: r.total, a: range.from, b: range.to, p: Math.min(page, range.pages), m: range.pages, }); prev.disabled = page <= 1; next.disabled = page >= range.pages; list.replaceChildren( ...(r.items.length ? r.items.map((it) => { const b = el("button", { className: "m02-dfip__row", attrs: { type: "button" }, children: [ el("span", { className: "m02-dfip__name", children: [ el("strong", { text: it.name }), el("span", { className: "m02-dfip__muted", text: ` ${it.spec}` }), ], }), el("span", { className: "m02-dfip__muted", text: `${it.price === null ? "" : fmt(it.price)} ${it.unit}`.trim(), }), ], }); b.addEventListener("click", () => onPick(it)); return b; }) : [el("p", { className: "m02-dfip__muted", text: t("NoRow") })]), ); } catch { if (mine === seq) list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Failed") })); } }; let timer = 0; q.input.addEventListener( "input", () => ( window.clearTimeout(timer), (timer = window.setTimeout(() => ((page = 1), void load()), 250)) ), ); void load(); return el("div", { className: "m02-dfip__find", children: [ ...(own ? [own.root] : []), q.root, info, list, el("div", { className: "m02-dfip__chips", children: [prev, next] }), ], }); } /* ── 잡히는 재료 미리 보기 ── */ export async function previewOf( el1: CfElementRef, lg: CfLogic, ): Promise<{ found: number; names: string[]; cheapest: MatItem | null; unit: string }> { const scope = el1.scope ?? {}; const word = scope.찾는말 ?? ""; if (el1.mode === "rule" && el1.rule) { const pill = variablePills(lg).find( (p) => p.port.box === el1.rule!.from.box && p.port.name === el1.rule!.from.name, ); const value = pill?.example; if (value === null || value === undefined || value === "") return { found: 0, names: [], cheapest: null, unit: "" }; const r = await searchMaterials({ q: `${word} ${value}`.trim(), sub: scope.구분, detail: scope.상세구분, page: 1, size: 200, }); const hit = r.items.filter((i) => matches(i, el1.rule!, value)); return { found: hit.length, names: hit.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()), cheapest: cheapestOf(hit), unit: commonUnit(hit), }; } const r = await searchMaterials({ q: word, sub: scope.구분, detail: scope.상세구분, page: 1, size: 200, }); return { found: r.total, names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()), cheapest: cheapestOf(r.items), unit: commonUnit(r.items), }; } /** 잡힌 품목 단위가 모두 같으면 그 단위 · 아니면 빈 글 */ const commonUnit = (items: MatItem[]): string => items.length && items.every((i) => i.unit === items[0].unit) ? items[0].unit : ""; const cheapestOf = (items: MatItem[]): MatItem | null => items .filter((i) => i.price !== null) .sort((a, b) => (a.price as number) - (b.price as number))[0] ?? null; /** 요소 한 줄 요약 — 방식 · 범위 · 규칙 */ function summaryOf(e: CfElementRef, lg: CfLogic): string { const parts: string[] = []; const scope = Object.entries(e.scope ?? {}) .map(([, v]) => v) .join(" / "); if (scope) parts.push(t("ScopeLine", { s: scope })); if (e.rule) { const p = variablePills(lg).find( (x) => x.port.box === e.rule!.from.box && x.port.name === e.rule!.from.name, ); parts.push( t("RuleLine", { f: e.rule.field, o: e.rule.op === "포함" ? t("OpIn") : t("OpEq"), n: e.rule.from.name, v: p?.example ?? "—", }), ); } return parts.join(" · "); } /** 재료 판 — 넣은 재료 목록 + 방식 셋 */ export function renderMaterial( host: HTMLElement, lg: CfLogic, boxId: string, on: { change(lg: CfLogic): void }, addEl: (lg: CfLogic, el: CfElementRef) => CfLogic, ): void { const box = lg.boxes.find((b) => b.id === boxId); const els = (box?.input?.elements ?? []) as CfElementRef[]; const chosen = el("div", { className: "m02-dfip__chosen" }); const body = el("div", { className: "m02-dfip__stack" }); let mode: CfMaterialMode = "fixed"; const drawChosen = (): void => { chosen.replaceChildren( el("h5", { text: `${t("Chosen")} · ${els.length}` }), ...(els.length ? els.map((e) => { const val = el("strong", { className: "m02-dfip__value", text: "—" }); const note = el("span", { className: "m02-dfip__muted" }); const kind = e.mode === "choice" ? t("Choice") : e.mode === "rule" ? t("Rule") : t("Fixed"); const row = el("div", { className: "m02-dfip__item", children: [ el("span", { className: "m02-dfip__name", children: [ el("span", { className: "m02-dfip__tag", text: kind }), el("strong", { text: ` ${e.name}` }), el("div", { className: "m02-dfip__muted", text: summaryOf(e, lg) }), note, ], }), val, el("span", { className: "m02-dfip__muted", text: e.unit || "-" }), createButton({ label: t("Remove"), variant: "ghost", size: "sm", onClick: () => on.change(removeElementNamed(lg, boxId, e.name)), }), ], }); const ref = e.mode === "choice" ? e.default : e.mode === "rule" ? "" : e.ref; if (ref) void cfValues([{ id: ref, group: "재료", ref }] as never) .then((a) => a.forEach( (x) => x.ok && typeof x.value === "number" && (val.textContent = fmt(x.value)), ), ) .catch(() => undefined); if (e.mode && e.mode !== "fixed") void previewOf(e, lg).then( (p) => { const n = catchNote(p.found, p.names[0]); note.textContent = `${t("Preview")}: ${n.text}`; note.classList.toggle("is-bad", n.kind !== "one"); if (p.cheapest?.price != null && !ref) val.textContent = fmt(p.cheapest.price); }, () => undefined, ); return row; }) : [el("p", { className: "m02-dfip__muted", text: t("None") })]), ); }; const chips = el("div", { className: "m02-dfip__chips" }); const drawChips = (): void => chips.replaceChildren( ...(["fixed", "choice", "rule"] as CfMaterialMode[]).map((m) => createButton({ label: m === "fixed" ? t("Fixed") : m === "choice" ? t("Choice") : t("Rule"), variant: mode === m ? "filled" : "pill", size: "sm", onClick: () => ((mode = m), drawChips(), drawBody()), }), ), ); const drawBody = (): void => { if (mode === "fixed") { body.replaceChildren( el("p", { className: "m02-dfip__muted", text: t("FixedHelp") }), itemList((it) => on.change( addEl(lg, { ...elementOf("재료", { 키: it.ref, 이름: it.name, 규격: it.spec, 단위: it.unit }), mode: "fixed", }), ), ), ); return; } // 선택 자리 · 조건 찾기 — 이름 · 범위 · (기본 품목 | 규칙) · 미리 보기 const name = createInputField({ type: "text", label: t("SlotName"), compact: true, placeholder: "예: 흄관", }); const word = createInputField({ type: "text", label: t("Word"), compact: true }); let def: MatItem | null = null; const scope = scopeBoxes({ sub: "", detail: "" }, () => void refresh()); const note = el("p", { className: "m02-dfip__note" }); const pills = variablePills(lg); const fieldBox = createSelectField({ label: t("RuleField"), options: [ { value: "규격", text: t("FieldSpec") }, { value: "이름", text: t("FieldName") }, ], value: "규격", compact: true, onChange: () => void refresh(), }); const opBox = createSelectField({ label: t("RuleOp"), options: [ { value: "포함", text: t("OpIn") }, { value: "같음", text: t("OpEq") }, ], value: "포함", compact: true, onChange: () => void refresh(), }); const pillBox = createSelectField({ label: t("RulePill"), options: pills.map((p, i) => ({ value: String(i), text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`, })), value: "0", compact: true, onChange: () => void refresh(), }); const defNote = el("p", { className: "m02-dfip__muted" }); const build = (): CfElementRef => { const sc = scope.get(); const rule: CfMaterialRule | undefined = mode === "rule" && pills[Number(pillBox.select.value)] ? { field: fieldBox.select.value as CfMaterialRule["field"], op: opBox.select.value as CfMaterialRule["op"], from: pills[Number(pillBox.select.value)].port, } : undefined; return materialElement( mode, name.input.value, { 구분: sc.sub, 상세구분: sc.detail, 찾는말: word.input.value.trim() }, { rule, item: def ?? undefined }, ); }; let seq = 0; const refresh = async (): Promise => { const mine = ++seq; defNote.textContent = def ? `${t("Default")}: ${def.name} ${def.spec}` : t("DefaultNone"); const cand = build(); note.textContent = `${t("Preview")}: …`; try { const p = await previewOf(cand, lg); if (mine !== seq) return; const n = catchNote(p.found, p.names[0]); note.textContent = `${t("Preview")}: ${n.text}${p.cheapest?.price != null ? ` · ${t("Price", { p: fmt(p.cheapest.price), u: p.cheapest.unit })}` : ""}`; note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`; } catch { if (mine === seq) note.textContent = t("Failed"); } }; name.input.addEventListener("input", () => name.setError(undefined)); word.input.addEventListener("input", () => void refresh()); const add = createButton({ label: t("Put"), size: "sm", onClick: () => { const cand = build(); const why = checkMaterial(cand, els); name.setError(why ?? undefined); if (why) return void showToast(why, "error"); // 잡히는 재료의 단위가 하나로 같으면 그 단위를 알약 단위로 void previewOf(cand, lg).then( (p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)), () => on.change(addEl(lg, cand)), ); }, }); const clear = createButton({ label: t("DefaultClear"), variant: "ghost", size: "sm", onClick: () => ((def = null), void refresh()), }); body.replaceChildren( el("p", { className: "m02-dfip__muted", text: mode === "choice" ? t("ChoiceHelp") : t("RuleHelp"), }), name.root, el("h5", { text: t("Scope") }), scope.root, word.root, ...(mode === "rule" ? pills.length ? [fieldBox.root, opBox.root, pillBox.root] : [el("p", { className: "m02-dfip__muted is-bad", text: t("NoPill") })] : [ el("h5", { text: t("Default") }), defNote, clear, itemList((it) => ((def = it), void refresh())), ]), note, add, ); void refresh(); }; host.replaceChildren(chosen, chips, body); drawChosen(); drawChips(); drawBody(); }