/* ============================================================================= * 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 CfBreak, type CfEvalResult, type CfValues } from "./M02_DF_Eval"; import { findMaterials, materialOf, priceOf, type CfMat } from "./M02_DF_Material"; import { portsIn, 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"], 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"], OtherLogic: [ "이어 주지 않아서 그 로직의 예시값을 써요", "Not linked — the logic's own sample is used", ], NoLine: ["「{box}」 의 받는 자리 「{pill}」 에 선이 없어요", "“{pill}” of “{box}” has no line"], NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"], } 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; /** 끊긴 곳 — Model 쪽 `breaks` 가 차면 그것 · 아직이면 빈 받는 자리와 계산 오류만 */ function brokenSpots(lg: CfLogic, result: CfEvalResult, values: CfValues): CfBreak[] { try { return breaks(lg, values); } catch { const out: CfBreak[] = []; if (!lg.boxes.some((b) => b.kind === "output")) out.push({ kind: "오류", box: "", text: tv("NoOutput") }); for (const b of lg.boxes) for (const p of portsIn(b)) if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name)) out.push({ kind: "빈 받는 자리", box: b.id, port: p.name, text: tv("NoLine", { box: b.name, pill: p.name }), }); for (const e of result.errors) out.push({ kind: "오류", box: "", text: e }); return out; } } /** 재료 요소 하나 고치기 — 나머지는 그대로 */ 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: e.slot ?? e.name }), 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: e.slot ?? e.name }), 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 } = {}, ): 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 = brokenSpots(lg, result, opts.values ?? {}); 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") }), ], }), ] : []; /* 설계 때 정할 것 */ const inputs = lg.boxes.filter((b) => b.kind === "input" && b.input); const vars = inputs.flatMap((b) => (b.input?.variables ?? []).map((v, i) => sampleField( v, (value) => safe(() => ({ ...lg, boxes: lg.boxes.map((x) => x.id === b.id && x.input ? { ...x, input: { ...x.input, variables: x.input.variables.map((y, j) => j === i ? { ...y, example: value } : y, ), }, } : x, ), })), msg, ), ), ); const slots = inputs.flatMap((b) => (b.input?.elements ?? []) .filter((e) => e.mode === "choice") .map((e) => slotRow(b, e, { ...on, change: (n) => safe(() => n) }, lg)), ); const rules = inputs.flatMap((b) => (b.input?.elements ?? []) .filter((e) => e.mode === "rule" && e.rule) .map((e) => ruleRow(e, lg, result)), ); const logics = inputs.flatMap((b) => (b.input?.logic?.designs ?? []) .filter((d) => !lg.links.some((l) => l.to.box === b.id && l.to.name === d.name)) .map((d) => el("li", { children: [ createButton({ label: `${b.name} · ${d.name}${d.unit && d.unit !== "-" ? ` (${d.unit})` : ""} — ${tv("OtherLogic")}`, variant: "ghost", size: "sm", disabled: !on.select, onClick: () => on.select?.({ box: b.id, port: d.name }), }), ], }), ), ); 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, ], }), ] : []), ); }