feat(m02-df): 검증 칸을 Model designInputs · Eval breaks 로 이음 · 출력 단위 고르기(UnitPick) · 단위 표준 소수자리 제안

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
This commit is contained in:
2026-10-01 19:52:25 +09:00
co-authored by Claude Sonnet 5.5
parent f759216242
commit 296255e357
3 changed files with 79 additions and 88 deletions
@@ -11,6 +11,7 @@ import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"
import { evalLogic, type CfEvalResult, type CfValue } from "./M02_DF_Eval";
import { portsOut, type CfBox, type CfCost, type CfLogic, type CfRound } from "./M02_DF_Model";
import { defaultItemRound, roundText } from "./M02_DF_Round";
import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick";
import "./M02_DF_OutputPanel.css";
const TEXT = {
@@ -22,7 +23,6 @@ const TEXT = {
Expense: ["경비", "Expense"],
Plain: ["값(비용 아님)", "Value (not a cost)"],
Unit: ["단위", "Unit"],
UnitHint: ["예: 원 · ㎥ · 시간 · -", "e.g. KRW · ㎥ · h · -"],
Got: ["받은 값", "Received value"],
From: ["어디서", "From"],
Value: ["값", "Value"],
@@ -39,6 +39,8 @@ const TEXT = {
RoundDefault: ["끝수 기본", "Default rounding"],
RoundStd: ["일위대가표 금액란", "unit-price sheet amount column"],
RoundNone: ["끝수 없음(값 그대로)", "No rounding (value as is)"],
Suggest: ["단위 표준 제안", "Unit standard suggests"],
Apply: ["적용", "Apply"],
RoundReset: ["기본으로", "Back to default"],
RoundUser: ["고른 끝수", "Chosen rounding"],
PlainHint: [
@@ -165,13 +167,7 @@ export function renderOutputPanel(
})),
),
});
const unitBox = el("input", {
className: "m02-dfout__input",
attrs: { type: "text", placeholder: to("UnitHint"), "aria-label": to("Unit") },
});
unitBox.value = box.output?.unit ?? box.unit;
unitBox.addEventListener("change", () => {
const next = unitBox.value.trim() || "-";
const setUnit = (next: string): void =>
emit(() =>
withBox(lg, box.id, (b) => ({
...b,
@@ -179,7 +175,27 @@ export function renderOutputPanel(
output: { ...b.output, cost: b.output?.cost ?? "노무비", unit: next },
})),
);
const unitBox = createUnitPick({
value: box.output?.unit ?? box.unit,
ariaLabel: to("Unit"),
extra: lg.resultUnit.includes("/") ? [lg.resultUnit.split("/")[0]] : [],
onPick: setUnit,
});
/** 단위 표준의 소수자리 제안(주 ① 반올림) — 값(비용 아님)이고 끝수를 안 정했을 때 */
const suggestNote = el("span", { className: "m02-dfout__muted" });
if (cost === "값" && !box.output?.round)
void suggestPlaces(box.output?.unit ?? box.unit).then((p) => {
if (p === null) return;
suggestNote.textContent = `${to("Suggest")}: ${to("RoundPlaces")} ${p} · 반올림`;
suggestNote.append(
createButton({
label: to("Apply"),
size: "sm",
variant: "ghost",
onClick: () => setRound({ places: p, method: "반올림" }),
}),
);
});
const field = (label: string, control: HTMLElement): HTMLElement =>
el("label", {
className: "m02-dfout__field",
@@ -253,6 +269,7 @@ export function renderOutputPanel(
],
}),
roundNote,
suggestNote,
],
});
@@ -9,7 +9,7 @@
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type { CfEvalResult, CfValues } from "./M02_DF_Eval";
import type { CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval";
import type { CfBox, CfLogic, CfRound } from "./M02_DF_Model";
import { showValue } from "./M02_DF_OutputPanel";
import { defaultTotalRound, roundBy, roundText } from "./M02_DF_Round";
@@ -55,7 +55,7 @@ export function renderSummary(
lg: CfLogic,
result: CfEvalResult,
on?: { change(lg: CfLogic): void; select?(sel: CfSel): void },
opts?: { values?: CfValues },
opts?: { values?: CfValues; logics?: CfEvalOptions["logics"] },
): void {
const money = !lg.resultUnit || lg.resultUnit.startsWith("원");
const perUnit = lg.resultUnit.split("/").slice(1).join("/");
@@ -131,7 +131,7 @@ export function renderSummary(
lg,
result,
{ change: (next) => on?.change(next), select: on?.select },
{ values: opts?.values },
{ values: opts?.values, logics: opts?.logics },
);
const plainValues = Object.entries(cost.값들);
host.replaceChildren(
+51 -77
View File
@@ -9,10 +9,11 @@
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 { breaks, type CfEvalOptions, type CfEvalResult, type CfValues } from "./M02_DF_Eval";
import { findMaterials, materialOf, priceOf, type CfMat } from "./M02_DF_Material";
import {
portsIn,
designInputs,
elementPort,
type CfBox,
type CfElementRef,
type CfLogic,
@@ -54,8 +55,6 @@ const TEXT = {
"이어 주지 않아서 그 로직의 예시값을 써요",
"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;
@@ -78,28 +77,6 @@ export interface CfVerifyOn {
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,
@@ -285,7 +262,7 @@ function slotRow(box: CfBox, e: CfElementRef, on: CfVerifyOn, lg: CfLogic): HTML
children: [
el("div", {
className: "m02-dfver__line",
children: [el("strong", { text: e.slot ?? e.name }), now, price, toggle],
children: [el("strong", { text: elementPort(e) }), now, price, toggle],
}),
panel,
],
@@ -325,7 +302,7 @@ function ruleRow(e: CfElementRef, lg: CfLogic, result: CfEvalResult): HTMLElemen
children: [
el("div", {
className: "m02-dfver__line",
children: [el("strong", { text: e.slot ?? e.name }), state, note],
children: [el("strong", { text: elementPort(e) }), state, note],
}),
el("span", {
className: "m02-dfver__muted",
@@ -341,7 +318,7 @@ export function renderVerify(
lg: CfLogic,
result: CfEvalResult,
on: CfVerifyOn,
opts: { values?: CfValues } = {},
opts: { values?: CfValues; logics?: CfEvalOptions["logics"] } = {},
): void {
const msg = el("p", { className: "m02-dfver__msg" });
const safe = (make: () => CfLogic): void => {
@@ -354,7 +331,7 @@ export function renderVerify(
};
/* 끊긴 곳 */
const spots = brokenSpots(lg, result, opts.values ?? {});
const spots = breaks(lg, opts.values ?? {}, { logics: opts.logics });
const spotBlock = spots.length
? [
el("div", {
@@ -383,59 +360,56 @@ export function renderVerify(
]
: [];
/* 설계 때 정할 것 */
/* 설계 때 정할 것 — 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 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 logics = designs.flatMap((d) =>
d.kind === "다른 로직"
? [
el("li", {
children: [
createButton({
label: `${boxOf(d.box)?.name ?? ""} · ${d.port}${d.unit && d.unit !== "-" ? ` (${d.unit})` : ""} — ${tv("OtherLogic")}`,
variant: "ghost",
size: "sm",
disabled: !on.select,
onClick: () => on.select?.({ box: d.box, port: d.port }),
}),
],
}),
]
: [],
);
const group = (title: TextKey, body: HTMLElement[]): HTMLElement[] =>
body.length