/* ============================================================================= * M02_DF_Verify.ts * 흐름도 검증 칸(오른쪽 아래) — 끊긴 곳 목록(누르면 그 상자 · 선을 고름) + 「설계 때 정할 것」 전부: * 변수 예시값 · 재료 선택 자리(범위 안 자재 목록에서 고르기 · 찾기 · 쪽) · 재료 조건 자리(지금 잡힌 품목 · 0 · 여럿 까닭) * · 이어지지 않은 다른 로직의 받는 알약. 바꾸면 on.change(고친 다이어그램) · 줄을 누르면 on.select. * 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 4 · 5 · 6 · 8 · 13. 이 폴더째 지워도 됨. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createSelectField, el } from "@ui/ui_template_elements"; import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; import { breaks, type CfEvalOptions, type CfEvalResult, type CfValues } from "./M02_DF_Eval"; import { findMaterials, materialOf, priceOf, type CfMat } from "./M02_DF_Material"; import { designInputs, elementPort, type CfBox, type CfElementRef, type CfLogic, type CfVariable, } from "./M02_DF_Model"; import "./M02_DF_Verify.css"; const TEXT = { Breaks: ["끊긴 곳", "Broken spots"], BreaksHint: ["누르면 그 상자 · 선을 고름", "Click to select the box or line"], Design: ["설계 때 정할 것", "To decide at design time"], DesignHint: ["여기서 바꾸면 그 상자도 바뀜", "Changing here changes the box too"], Vars: ["변수 예시값", "Variable samples"], Slots: ["재료 선택 자리", "Material choice"], Rules: ["재료 조건 자리", "Material by condition"], Logics: ["이어지지 않은 다른 로직 값(예시값)", "Unlinked values of other logics (samples)"], Choose: ["고르기", "Choose"], Close: ["닫기", "Close"], Find: ["찾기", "Find"], Prev: ["이전", "Prev"], Next: ["다음", "Next"], Search: ["재료 이름 · 규격", "Material name or size"], Page: ["쪽 {p} / {n} · 모두 {t}", "Page {p} / {n} · {t} in all"], NoHit: ["찾은 재료 없음", "No material found"], NoDefault: ["아직 고르지 않음", "Nothing chosen yet"], Price: ["단가", "Unit price"], Won: ["원", "KRW"], Loading: ["찾는 중", "Searching"], NeedValue: ["조건 값이 아직 없음", "The condition value is not there yet"], Caught: ["잡힌 품목", "Matched item"], Zero: ["맞는 재료 없음(0)", "No matching material (0)"], Many: ["여럿 맞음({n}) — 첫째를 씀", "{n} match — the first is used"], Cond: ["{field}에 「{v}」 {op}", "{field} {op} “{v}”"], Pick: ["선택", "Choose"], Bad: ["숫자를 넣어야 함", "Enter a number"], Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], Cant: ["지금은 바꿀 수 없음", "Cannot change this now"], OwnSample: ["비우면 그 로직 예시값", "Empty = the logic's own sample"], OtherLogic: [ "이어 주지 않아 그 로직의 예시값을 씀", "Not linked — the logic's own sample is used", ], } as const; type TextKey = keyof typeof TEXT; const tv = (key: TextKey, fill: Record = {}): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace(/\{(\w+)\}/g, (m, k: string) => k in fill ? String(fill[k]) : m, ); /** 고른 상자 · 알약 · 선 — 다이어그램이 이 모양으로 고름 */ export interface CfSel { box: string; port?: string; link?: string; } export interface CfVerifyOn { change(lg: CfLogic): void; select?(sel: CfSel): void; } const PAGE = 10; /** 재료 요소 하나 고치기 — 나머지는 그대로 */ function withElement( lg: CfLogic, boxId: string, name: string, patch: Partial, ): CfLogic { return { ...lg, boxes: lg.boxes.map((b) => b.id === boxId && b.input ? { ...b, input: { ...b.input, elements: b.input.elements.map((e) => (e.name === name ? { ...e, ...patch } : e)), }, } : b, ), }; } const matText = (m: CfMat): string => `${m.name}${m.spec ? ` ${m.spec}` : ""}${m.unit ? ` · ${m.unit}` : ""}`; /** 변수 예시값 칸 */ function sampleField( v: CfVariable, put: (value: number | string) => void, msg: HTMLElement, ): HTMLElement { const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name }); let control: HTMLElement; if (v.type === "choice" && v.choices?.length) { control = createSelectField({ options: [ { value: "", text: tv("Pick") }, ...v.choices.map((c) => ({ value: String(c), text: String(c) })), ], value: v.example === null ? "" : String(v.example), compact: true, ariaLabel: v.name, onChange: (s) => { const hit = v.choices?.find((c) => String(c) === s); if (hit !== undefined) put(hit); }, }).root; } else { const box = el("input", { className: "m02-dfver__input", attrs: { type: "text", inputmode: v.type === "number" ? "decimal" : "text", "aria-label": v.name, }, }); if (v.range) box.placeholder = tv("Range", { a: v.range[0], b: v.range[1] }); box.value = v.example === null ? "" : String(v.example); box.addEventListener("change", () => { const raw = box.value.trim(); if (raw === "") return; if (v.type !== "number") return put(raw); const n = Number(raw); if (Number.isNaN(n)) msg.textContent = tv("Bad"); else put(n); }); control = box; } return el("label", { className: "m02-dfver__field", children: [label, control] }); } /** 재료 선택 자리 한 줄 — 지금 품목 · 단가 · 「고르기」(찾기 · 쪽) */ function slotRow(box: CfBox, e: CfElementRef, on: CfVerifyOn, lg: CfLogic): HTMLElement { const now = el("span", { className: "m02-dfver__now", text: e.default ? "…" : tv("NoDefault") }); const price = el("span", { className: "m02-dfver__price" }); if (e.default) { void materialOf(e.default).then((m) => (now.textContent = m ? matText(m) : (e.default ?? ""))); void priceOf(e.default).then((p) => { price.textContent = p === null ? "" : `${tv("Price")} ${formatMoney(p)} ${tv("Won")}`; }); } const panel = el("div", { className: "m02-dfver__panel" }); panel.hidden = true; const q = el("input", { className: "m02-dfver__input", attrs: { type: "text", placeholder: tv("Search"), "aria-label": tv("Search") }, }); q.value = e.scope?.찾는말 ?? ""; const list = el("ul", { className: "m02-dfver__list" }); const pager = el("div", { className: "m02-dfver__pager" }); let page = 1; const load = async (): Promise => { list.replaceChildren(el("li", { className: "m02-dfver__muted", text: tv("Loading") })); const got = await findMaterials({ q: q.value.trim(), sub: e.scope?.구분, detail: e.scope?.상세구분, page, size: PAGE, }); const pages = Math.max(1, Math.ceil(got.total / PAGE)); list.replaceChildren( ...(got.items.length ? got.items.map((m) => el("li", { children: [ createButton({ label: matText(m), variant: "ghost", size: "sm", onClick: () => on.change(withElement(lg, box.id, e.name, { default: m.ref })), }), ], }), ) : [el("li", { className: "m02-dfver__muted", text: tv("NoHit") })]), ); pager.replaceChildren( createButton({ label: tv("Prev"), size: "sm", variant: "ghost", disabled: page <= 1, onClick: () => { page--; void load(); }, }), el("span", { className: "m02-dfver__muted", text: tv("Page", { p: got.page, n: pages, t: got.total }), }), createButton({ label: tv("Next"), size: "sm", variant: "ghost", disabled: page >= pages, onClick: () => { page++; void load(); }, }), ); }; q.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { page = 1; void load(); } }); const toggle = createButton({ label: tv("Choose"), size: "sm", onClick: () => { panel.hidden = !panel.hidden; toggle.querySelector(".ui-btn__label")!.textContent = panel.hidden ? tv("Choose") : tv("Close"); if (!panel.hidden) void load(); }, }); panel.append( el("div", { className: "m02-dfver__search", children: [ q, createButton({ label: tv("Find"), size: "sm", onClick: () => { page = 1; void load(); }, }), ], }), list, pager, ); return el("div", { className: "m02-dfver__item", children: [ el("div", { className: "m02-dfver__line", children: [el("strong", { text: elementPort(e) }), now, price, toggle], }), panel, ], }); } /** 재료 조건 자리 한 줄 — 지금 잡힌 품목 · 맞는 것 0 · 여럿이면 까닭 */ function ruleRow(e: CfElementRef, lg: CfLogic, result: CfEvalResult): HTMLElement { const rule = e.rule!; const raw = result.ports[rule.from.box]?.[rule.from.name]; const value = Array.isArray(raw) || raw === null || raw === undefined ? "" : String(raw); const state = el("span", { className: "m02-dfver__now", text: value ? tv("Loading") : tv("NeedValue"), }); const note = el("span", { className: "m02-dfver__muted" }); if (value) { void findMaterials({ q: e.scope?.찾는말, sub: e.scope?.구분, detail: e.scope?.상세구분, size: 100, }).then((got) => { const hit = got.items.filter((m) => { const t = rule.field === "규격" ? m.spec : m.name; return rule.op === "같음" ? t === value : t.includes(value); }); const first = hit[0]; state.className = `m02-dfver__now${hit.length === 1 ? "" : " m02-dfver__warn"}`; state.textContent = !first ? tv("Zero") : `${tv("Caught")}: ${matText(first)}`; note.textContent = hit.length > 1 ? tv("Many", { n: hit.length }) : ""; }); } const from = lg.boxes.find((b) => b.id === rule.from.box)?.name ?? ""; return el("div", { className: "m02-dfver__item", children: [ el("div", { className: "m02-dfver__line", children: [el("strong", { text: elementPort(e) }), state, note], }), el("span", { className: "m02-dfver__muted", text: `${from ? `${from} · ` : ""}${tv("Cond", { field: rule.field, v: value || rule.from.name, op: rule.op })}`, }), ], }); } /** 검증 칸 — values = 받은 입력 값(끊긴 곳 검사가 씀) */ export function renderVerify( host: HTMLElement, lg: CfLogic, result: CfEvalResult, on: CfVerifyOn, opts: { values?: CfValues; logics?: CfEvalOptions["logics"] } = {}, ): void { const msg = el("p", { className: "m02-dfver__msg" }); const safe = (make: () => CfLogic): void => { msg.textContent = ""; try { on.change(make()); } catch { msg.textContent = tv("Cant"); } }; /* 끊긴 곳 */ const spots = breaks(lg, opts.values ?? {}, { logics: opts.logics }); const spotBlock = spots.length ? [ el("div", { className: "m02-dfver__block m02-dfver__breaks", children: [ el("strong", { text: tv("Breaks") }), el("ul", { className: "m02-dfver__list", children: spots.map((s) => el("li", { children: [ createButton({ label: s.text, variant: "ghost", size: "sm", disabled: !s.box || !on.select, onClick: () => on.select?.({ box: s.box, port: s.port, link: s.link }), }), ], }), ), }), el("p", { className: "m02-dfver__muted", text: tv("BreaksHint") }), ], }), ] : []; /* 설계 때 정할 것 — Model `designInputs`(변수 · 재료 자리 · 다른 로직) + 재료 조건 자리 */ const designs = designInputs(lg); const boxOf = (id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id); /** 변수 예시값을 그 상자(입력 · 함수)의 변수에 되적음 */ const putExample = (box: string, name: string, value: number | string): CfLogic => ({ ...lg, boxes: lg.boxes.map((x) => { if (x.id !== box) return x; const set = (list: CfVariable[]): CfVariable[] => list.map((y) => (y.name === name ? { ...y, example: value } : y)); return { ...x, ...(x.input ? { input: { ...x.input, variables: set(x.input.variables) } } : {}), ...(x.func ? { func: { ...x.func, variables: set(x.func.variables) } } : {}), }; }), }); const vars = designs.flatMap((d) => d.kind === "변수" && d.variable ? [sampleField(d.variable, (value) => safe(() => putExample(d.box, d.port, value)), msg)] : [], ); const slots = designs.flatMap((d) => { const box = boxOf(d.box); return d.kind === "재료 자리" && d.element && box ? [slotRow(box, d.element, { ...on, change: (n) => safe(() => n) }, lg)] : []; }); const inputs = lg.boxes.filter((b) => b.kind === "input" && b.input); const rules = inputs.flatMap((b) => (b.input?.elements ?? []) .filter((e) => e.mode === "rule" && e.rule) .map((e) => ruleRow(e, lg, result)), ); /** 다른 로직의 받는 알약 — 예시값을 적으면 logic.examples · 비우면 그 로직 자기 예시값 */ const putLogicExample = (box: string, port: string, raw: string): CfLogic => ({ ...lg, boxes: lg.boxes.map((x) => { if (x.id !== box || !x.input?.logic) return x; const examples = { ...x.input.logic.examples }; const text = raw.trim(); if (text === "") delete examples[port]; else examples[port] = Number.isNaN(Number(text)) ? text : Number(text); return { ...x, input: { ...x.input, logic: { ...x.input.logic, examples } } }; }), }); const logics = designs.flatMap((d) => { if (d.kind !== "다른 로직") return []; const input = el("input", { className: "m02-dfver__input", attrs: { type: "text", placeholder: tv("OwnSample"), "aria-label": d.port }, }); input.value = d.example === null || d.example === undefined ? "" : String(d.example); input.addEventListener("change", () => safe(() => putLogicExample(d.box, d.port, input.value))); const unit = d.unit && d.unit !== "-" ? ` (${d.unit})` : ""; return [ el("li", { className: "m02-dfver__line", children: [ createButton({ label: `${boxOf(d.box)?.name ?? ""} · ${d.port}${unit}`, variant: "ghost", size: "sm", disabled: !on.select, onClick: () => on.select?.({ box: d.box, port: d.port }), }), input, ], }), ]; }); const group = (title: TextKey, body: HTMLElement[]): HTMLElement[] => body.length ? [ el("div", { className: "m02-dfver__group", children: [el("strong", { text: tv(title) }), ...body], }), ] : []; const design = [ ...group( "Vars", [el("div", { className: "m02-dfver__fields", children: vars })].filter(() => vars.length), ), ...group("Slots", slots), ...group("Rules", rules), ...group( "Logics", logics.length ? [el("ul", { className: "m02-dfver__list", children: logics })] : [], ), ]; host.replaceChildren( ...spotBlock, ...(design.length ? [ el("div", { className: "m02-dfver__block", children: [ el("strong", { className: "m02-dfver__title", text: tv("Design") }), ...design, el("p", { className: "m02-dfver__muted", text: tv("DesignHint") }), msg, ], }), ] : []), ); }