/* ============================================================================= * M02_CF_InputPanel.ts * 단가산출 다이어그램 — 입력 상자 판: 갈래(그룹 · 변수) 고르기 · M01 그룹 요소 고르기 · 새 요소(사용자 요소) · * 소요량 · 계수 표 고르기(Table.ts) · 변수 정의(Vars.ts) · 상자 단위 * M01 Api(`Api_Fetch`)만 가져다 씀 — 마스터에 쓰지 않음(새 요소는 서버 `/api/m02/cf/user-elements` = 시험 폴더 `_data`) * 글 [한, 영] 짝은 아래 TEXT · 용어는 정함(입력 · 함수 · 출력 · 다이어그램 · 알약 · 변수 · 단위) * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField, el, showToast, } from "@ui/ui_template_elements"; import { fetchFiles, fetchRows, fetchSubs, type SubInfo, } from "../../M01_MasterData/M01_MasterData_Api_Fetch"; import { cfPlain, cfUserElementAdd, cfUserElements, cfValues } from "./M02_CF_Api"; import type { CfBox, CfElementRef, CfGroup, CfLogic } from "./M02_CF_Model"; import { GROUPS, M01_OF, MACHINE_SEP, USER_PREFIX, addElement, elementOf, machineFields, queryOf, reconcileUnit, removeElement, setKind, valueOf, type M01Row, } from "./M02_CF_InputPanel_Build"; import { renderTablePick } from "./M02_CF_InputPanel_Table"; import { renderVariables } from "./M02_CF_InputPanel_Vars"; import "./M02_CF_InputPanel.css"; /** Api 모양 — 서버 Api 가 채워지면(사용자 요소 = Model 모양 · 값 물음 = ref) 그 모양을 그대로 씀 */ interface Answer { id: string; ok: boolean; value?: number | string | null; } interface UserEl { id: string; group: string; name: string; spec: string; unit: string; value: number | null; } const apiValues = cfValues as unknown as (items: object[], users?: object[]) => Promise; const apiUsers = cfUserElements as unknown as () => Promise; const apiAdd = cfUserElementAdd as unknown as (e: Omit) => Promise; const TEXT = { Kind: ["무엇을 넣을까요?", "What goes in?"], Variable: ["변수", "Variable"], 인력: ["인력", "Labor"], 재료: ["재료", "Material"], 유가전력: ["유가·전력", "Fuel · power"], 기계: ["기계", "Machine"], 소요량: ["소요량", "Quantity table"], 계수: ["계수", "Factor table"], 환율: ["환율", "Exchange rate"], 요율: ["요율", "Rate"], Chosen: [ "넣은 요소 — 알약마다 이름 · 값 · 단위", "Chosen elements — name · value · unit per pill", ], NoneChosen: ["아직 고른 요소가 없음", "Nothing chosen yet"], Remove: ["빼기", "Remove"], Put: ["넣기", "Add"], Search: ["이름으로 찾기", "Search by name"], AllSub: ["전체", "All"], SubLabel: ["구분", "Group"], DetailLabel: ["상세구분", "Detail"], Loading: ["찾는 중…", "Searching…"], NoRow: ["찾는 요소 없음", "Nothing found"], Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"], Mine: ["내가 만든 것", "Mine"], NewHead: ["새 요소 — 마스터에는 안 쓰고 내 것으로만 저장", "New element — saved as mine only"], NewName: ["이름", "Name"], NewSpec: ["규격", "Spec"], NewUnit: ["단위", "Unit"], NewValue: ["값(숫자)", "Value (number)"], Make: ["만들어 넣기", "Create and add"], NeedName: ["이름을 적어 주세요", "Enter a name"], NeedValue: ["값은 숫자로", "Value must be a number"], BoxUnit: ["상자 단위", "Box unit"], Field: ["값 칸", "Value"], NoValue: ["—", "—"], Failed: ["불러오지 못함", "Could not load"], Pills: ["알약 {n} 개", "{n} pills"], NotInput: ["입력 상자를 고르면 이 판이 뜸", "Pick an input box"], } 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"); /** 서버 실패 → 쉬운 말(Api 쉬운 말 길이 아직 없으면 그 글 그대로) */ const plain = (e: unknown): string => { try { return cfPlain(e); } catch { return e instanceof Error ? e.message : t("Failed"); } }; const fileCache = new Map(); /** 그룹의 M01 파일 이름 */ async function fileOf(group: CfGroup): Promise { const hit = fileCache.get(group); if (hit) return hit; const { group: g, part } = M01_OF[group]; const files = await fetchFiles(g); const f = files.find((x) => !part || x.file.includes(part)) ?? files[0]; fileCache.set(group, f.file); return f.file; } /** 입력 상자 판 — 고른 입력 상자의 갈래 · 요소 · 표 · 변수를 고침 · 바뀌면 on.change(새 CfLogic) */ export function renderInputPanel( host: HTMLElement, lg: CfLogic, boxId: string, on: { change(lg: CfLogic): void }, ): void { host.className = "m02-cfip"; const box = lg.boxes.find((b) => b.id === boxId); if (!box || box.kind !== "input") { host.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("NotInput") })); return; } // 상자 단위가 고른 요소 · 변수 단위와 다르면(견본 등) 맞춰 다시 그림 — 맞으면 바로 지나감 const fixed = reconcileUnit(lg, boxId); if (fixed !== lg) return on.change(fixed); const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] }; const kind: CfGroup | "변수" = input.mode === "variable" ? "변수" : (input.group ?? "인력"); const body = el("div", { className: "m02-cfip__body" }); // ① 갈래 — 그룹 여덟 + 변수 const kinds = el("div", { className: "m02-cfip__chips", attrs: { role: "tablist", "aria-label": t("Kind") }, children: [...GROUPS, "변수" as const].map((k) => { const b = el("button", { className: `m02-cfip__chip${k === kind && (k !== "변수" ? input.mode === "group" : true) ? " is-on" : ""}`, attrs: { type: "button", role: "tab" }, text: k === "변수" ? t("Variable") : t(k), }); b.addEventListener("click", () => k !== kind && on.change(setKind(lg, boxId, k))); return b; }), }); const unit = el("p", { className: "m02-cfip__unit", text: `${t("BoxUnit")}: ${box.unit || "-"}`, }); host.replaceChildren(el("h4", { text: t("Kind") }), kinds, unit, body); if (kind === "변수") renderVariables(body, lg, boxId, on); else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on); else renderGroup(body, lg, box, kind, on); } /** 그룹 요소 — 고른 것 목록 · 찾기 · 새 요소 */ function renderGroup( host: HTMLElement, lg: CfLogic, box: CfBox, group: CfGroup, on: { change(lg: CfLogic): void }, ): void { const chosen = el("div", { className: "m02-cfip__chosen" }); const find = el("div", { className: "m02-cfip__find" }); host.replaceChildren(chosen, find, newForm(lg, box, group, on)); drawChosen(chosen, lg, box, group, on); void drawFind(find, lg, box, group, on); } /** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */ function drawChosen( host: HTMLElement, lg: CfLogic, box: CfBox, group: CfGroup, on: { change(lg: CfLogic): void }, ): void { const els = box.input?.elements ?? []; const values = new Map(); host.replaceChildren( el("h5", { text: `${t("Chosen")} · ${t("Pills", { n: els.length })}` }), ...(els.length ? els.map((e) => { const val = el("strong", { className: "m02-cfip__value", text: t("NoValue") }); values.set(e.ref, val); return el("div", { className: "m02-cfip__item", children: [ el("span", { className: "m02-cfip__name", text: e.name }), val, el("span", { className: "m02-cfip__muted", text: e.unit || "-" }), createButton({ label: t("Remove"), variant: "ghost", size: "sm", onClick: () => on.change(removeElement(lg, box.id, e.ref)), }), ], }); }) : [el("p", { className: "m02-cfip__muted", text: t("NoneChosen") })]), ); if (!els.length) return; const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) })); void apiValues(items, lg.userElements).then( (answers) => answers.forEach((a) => { const node = values.get(a.id); if (node && a.ok && a.value !== null && a.value !== undefined) node.textContent = typeof a.value === "number" ? fmt(a.value) : String(a.value); }), // Api 가 아직 값을 못 주면 M01 줄에서 바로 읽어 보임(값 칸만 — 요소는 그대로) () => els.forEach( (e) => void m01Value(group, e).then((v) => { const node = values.get(e.ref); if (node && v !== null) node.textContent = fmt(v); }), ), ); } /** 요소 한 개의 값을 M01 줄에서 읽음 — 이름으로 찾아 키가 같은 줄 · 못 찾으면 null */ async function m01Value(group: CfGroup, e: CfElementRef): Promise { if (e.ref.startsWith(USER_PREFIX)) return null; try { const [key, field] = e.ref.split(MACHINE_SEP); const page = await fetchRows( await fileOf(group), 1, 30, e.name.split(" · ")[0].split(" ")[0], false, "", "", ); const row = (page.rows as M01Row[]).find((r) => r["키"] === key); return row ? valueOf(group, row, field || "가격") : null; } catch { return null; } } /** M01 줄 찾기 — 구분 · 상세구분 · 이름 · 내가 만든 것도 함께 */ async function drawFind( host: HTMLElement, lg: CfLogic, box: CfBox, group: CfGroup, on: { change(lg: CfLogic): void }, ): Promise { const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); const list = el("div", { className: "m02-cfip__list" }); const count = el("span", { className: "m02-cfip__muted" }); const bar: HTMLElement[] = []; let file = ""; let sub = ""; let detail = ""; let subs: SubInfo[] = []; const all = { value: "", text: t("AllSub") }; const subBox = createSelectField({ options: [all], value: "", compact: true, ariaLabel: t("SubLabel"), onChange: (v) => ((sub = v), (detail = ""), fillDetail(), void load()), }); const detailBox = createSelectField({ options: [all], value: "", compact: true, ariaLabel: t("DetailLabel"), onChange: (v) => ((detail = v), void load()), }); const fillDetail = (): void => { const d = subs.find((s) => s.name === sub)?.details ?? []; detailBox.root.hidden = !d.length; detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], ""); }; fillDetail(); let seq = 0; const load = async (): Promise => { const mine = ++seq; list.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("Loading") })); try { const word = q.input.value.trim(); const [page, users] = await Promise.all([ fetchRows(file, 1, 40, word, false, sub, detail), apiUsers().catch((): UserEl[] => []), ]); if (mine !== seq) return; const known = new Map(); for (const u of [...lg.userElements, ...users]) if (u.group === group) known.set(u.id, u as UserEl); const mineRows: M01Row[] = [...known.values()] .filter((u) => !word || `${u.name} ${u.spec}`.includes(word)) .map((u) => ({ 키: u.id, 이름: u.name, 규격: u.spec, 단위: u.unit, 값: u.value, __mine: true, })); const rows = [...mineRows, ...(page.rows as M01Row[])]; count.textContent = t("Count", { n: page.total + mineRows.length, m: rows.length }); list.replaceChildren( ...(rows.length ? rows.map((r) => rowOf(r)) : [el("p", { className: "m02-cfip__muted", text: t("NoRow") })]), ); } catch (e) { if (mine === seq) list.replaceChildren(el("p", { className: "m02-cfip__muted", text: plain(e) })); } }; const rowOf = (r: M01Row): HTMLElement => { const fields = group === "기계" ? machineFields(r) : []; const field = createSelectField({ options: fields.map((f) => ({ value: f, text: f })), value: fields.includes("가격") ? "가격" : (fields[0] ?? ""), compact: true, ariaLabel: t("Field"), }); const pick = (): CfLogic => addElement(lg, box.id, elementOf(group, r, field.select.value || "가격")); const v = valueOf(group, r, field.select.value || "가격"); const unitText = group === "기계" ? "" : String(r["단위"] ?? ""); const b = el("button", { className: "m02-cfip__row", attrs: { type: "button" }, children: [ el("span", { className: "m02-cfip__name", children: [ el("strong", { text: String(r["이름"] ?? "") }), el("span", { className: "m02-cfip__muted", text: ` ${r["규격"] ?? ""}` }), ...(r["__mine"] ? [el("span", { className: "m02-cfip__tag", text: t("Mine") })] : []), ], }), el("span", { className: "m02-cfip__muted", text: `${v === null ? "" : fmt(v)} ${unitText}`.trim(), }), ], }); b.addEventListener("click", () => on.change(pick())); return el("div", { className: "m02-cfip__rowbox", children: [b, ...(fields.length ? [field.root] : [])], }); }; try { file = await fileOf(group); subs = await fetchSubs({ file }).catch(() => []); subBox.setOptions([all, ...subs.map((s) => ({ value: s.name, text: s.name }))], ""); subBox.root.hidden = !subs.length; bar.push(subBox.root, detailBox.root); } catch (e) { host.replaceChildren(el("p", { className: "m02-cfip__muted", text: plain(e) })); return; } let timer = 0; q.input.addEventListener( "input", () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), ); host.replaceChildren( el("div", { className: "m02-cfip__bar", children: [...bar, q.root] }), count, list, ); void load(); void USER_PREFIX; } /** 새 요소 — M01 표 「추가」 화면 꼴(이름 · 규격 · 단위 · 값) · 마스터에 안 쓰고 사용자 요소로 */ function newForm( lg: CfLogic, box: CfBox, group: CfGroup, on: { change(lg: CfLogic): void }, ): HTMLElement { const name = createInputField({ type: "text", label: t("NewName"), compact: true }); const spec = createInputField({ type: "text", label: t("NewSpec"), compact: true }); const unit = createInputField({ type: "text", label: t("NewUnit"), compact: true }); const value = createInputField({ type: "text", label: t("NewValue"), compact: true }); const go = createButton({ label: t("Make"), onClick: () => { const n = value.input.value.trim() === "" ? null : Number(value.input.value.replace(/,/g, "")); if (!name.input.value.trim()) return name.setError(t("NeedName")); if (n !== null && !Number.isFinite(n)) return value.setError(t("NeedValue")); name.setError(undefined); value.setError(undefined); apiAdd({ group, name: name.input.value.trim(), spec: spec.input.value.trim(), unit: unit.input.value.trim(), value: n, }).then( (made) => { const el1 = elementOf(group, { 키: made.id, 이름: made.name, 규격: made.spec, 단위: made.unit, 값: made.value, }); const withUser: CfLogic = { ...lg, userElements: [...lg.userElements.filter((u) => u.id !== made.id), { ...made, group }], }; on.change(addElement(withUser, box.id, el1)); }, (e) => showToast(plain(e), "error"), ); }, }); const d = el("details", { className: "m02-cfip__new", children: [ el("summary", { text: t("NewHead") }), name.root, spec.root, unit.root, value.root, go, ], }); return d; }