feat(m02-df): 단가산출 흐름도 0걸음 — CostFlow 를 DataFlow 로 옮겨 이름 바꿈
- 파일 M02_CF_* → M02_DF_* · css m02-cf/m01-cf → m02-df · 서버 길 /api/m02/cf → /api/m02/df · 키 CF- → DF- · 사용자 요소 UE- 그대로 - _sample · .gitignore(_data git 밖) 옮김 · DataFlow 는 CostFlow 를 import 안 함 - 시험 test_m02_df_model · router · sample · inputpanel_build(옛 cf 시험은 그대로) - 서버 등록은 sub1 몫 — M02_MasterTemplete.DataFlow.M02_DF_Router 의 router Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Summary.ts
|
||||
* 단가산출 다이어그램 — 원가 합계 칸: 큰 합계 「1㎡ 당 ○원」 · 노무비 · 재료비 · 경비 · 출력 상자별 줄
|
||||
* · 값(비용 아님) 출력 · 빈 받는 자리 · 오류 · 예시값(변수 상자의 예시값을 여기서도 바꿈)
|
||||
* 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌.
|
||||
* 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 점검 5 · 6. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { checkLogic, type CfEvalResult, type CfValues } from "./M02_DF_Eval";
|
||||
import type { CfBox, CfLogic, CfVariable } from "./M02_DF_Model";
|
||||
import { showValue } from "./M02_DF_OutputPanel";
|
||||
import "./M02_DF_Summary.css";
|
||||
|
||||
const TEXT = {
|
||||
Per: ["{per} 당", "per {per}"],
|
||||
Total: ["원가 합계", "Cost total"],
|
||||
Won: ["원", "KRW"],
|
||||
Labor: ["노무비", "Labor cost"],
|
||||
Material: ["재료비", "Material cost"],
|
||||
Expense: ["경비", "Expense"],
|
||||
Lines: ["출력 상자별", "By output box"],
|
||||
HName: ["이름", "Name"],
|
||||
HCost: ["비용 종류", "Cost kind"],
|
||||
HValue: ["값", "Value"],
|
||||
HUnit: ["단위", "Unit"],
|
||||
Plain: ["값(비용 아님)", "Values (not costs)"],
|
||||
Sample: ["예시값", "Sample values"],
|
||||
SampleHint: [
|
||||
"여기서 바꾸면 변수 상자의 예시값도 바뀌어요",
|
||||
"Changing here changes the variable box too",
|
||||
],
|
||||
Problems: ["고칠 것", "To fix"],
|
||||
NoLink: ["「{box}」 출력 상자에 이어진 선이 없어요", "Output box “{box}” has no line yet"],
|
||||
NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
|
||||
Pick: ["고르세요", "Choose"],
|
||||
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
|
||||
Plain3: ["값", "Value"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const ts = (key: TextKey, fill: Record<string, string> = {}): string =>
|
||||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace(
|
||||
/\{(\w+)\}/g,
|
||||
(m, k: string) => fill[k] ?? m,
|
||||
);
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
const COST_KEY = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const;
|
||||
|
||||
const costText = (c: string): string => (c === "값" ? ts("Plain3") : c);
|
||||
|
||||
/** 빈 받는 자리 · 출력 상자 없음 — 쉬운 말 */
|
||||
function emptyPlaces(lg: CfLogic): string[] {
|
||||
const outs = lg.boxes.filter((b) => b.kind === "output");
|
||||
if (!outs.length) return [ts("NoOutput")];
|
||||
return outs
|
||||
.filter((o) => !lg.links.some((l) => l.to.box === o.id))
|
||||
.map((o) => ts("NoLink", { box: o.name }));
|
||||
}
|
||||
|
||||
/** 변수 상자의 예시값 칸 하나 */
|
||||
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: ts("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-dfsum__input",
|
||||
attrs: {
|
||||
type: "text",
|
||||
inputmode: v.type === "number" ? "decimal" : "text",
|
||||
"aria-label": v.name,
|
||||
},
|
||||
});
|
||||
if (v.range) box.placeholder = ts("Range", { a: String(v.range[0]), b: String(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 = ts("Bad");
|
||||
else put(n);
|
||||
});
|
||||
control = box;
|
||||
}
|
||||
return el("label", { className: "m02-dfsum__field", children: [label, control] });
|
||||
}
|
||||
|
||||
/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 · opts.values = 받은 입력 값(검사가 씀) */
|
||||
export function renderSummary(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
result: CfEvalResult,
|
||||
on?: { change(lg: CfLogic): void },
|
||||
opts?: { values?: CfValues },
|
||||
): void {
|
||||
const money = !lg.resultUnit || lg.resultUnit.startsWith("원");
|
||||
const perUnit = lg.resultUnit.split("/").slice(1).join("/");
|
||||
const perText = !perUnit
|
||||
? ts("Total")
|
||||
: ts("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` });
|
||||
const { cost } = result;
|
||||
const big = el("p", {
|
||||
className: "m02-dfsum__big",
|
||||
children: [
|
||||
el("span", { text: money ? perText : ts("Total") }),
|
||||
el("strong", { text: `${formatMoney(cost.계)} ${ts("Won")}` }),
|
||||
],
|
||||
});
|
||||
const costs = el("dl", {
|
||||
className: "m02-dfsum__costs",
|
||||
children: COSTS.flatMap((c) => [
|
||||
el("dt", { text: ts(COST_KEY[c]) }),
|
||||
el("dd", { text: formatMoney(cost[c]) }),
|
||||
]),
|
||||
});
|
||||
|
||||
/* 출력 상자별 줄 — 비용 줄 · 값(비용 아님)은 따로 */
|
||||
const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output");
|
||||
const costed = outs.filter((b) => b.output?.cost !== "값");
|
||||
const plain = outs.filter((b) => b.output?.cost === "값");
|
||||
const line = (b: CfBox, isCost: boolean): HTMLElement =>
|
||||
el("tr", {
|
||||
children: [
|
||||
el("td", { text: b.name }),
|
||||
...(isCost ? [el("td", { text: costText(b.output?.cost ?? "") })] : []),
|
||||
el("td", {
|
||||
className: "m02-dfsum__num",
|
||||
text: showValue(result.outputs[b.id], isCost),
|
||||
}),
|
||||
el("td", { text: b.output?.unit ?? b.unit }),
|
||||
],
|
||||
});
|
||||
const table = (list: CfBox[], isCost: boolean, title: string): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-dfsum__block",
|
||||
children: [
|
||||
el("strong", { text: title }),
|
||||
el("div", {
|
||||
className: "m02-dfsum__scroll",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "m02-dfsum__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
el("th", { text: ts("HName") }),
|
||||
...(isCost ? [el("th", { text: ts("HCost") })] : []),
|
||||
el("th", { text: ts("HValue") }),
|
||||
el("th", { text: ts("HUnit") }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: list.map((b) => line(b, isCost)) }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
/* 고칠 것 — 빈 받는 자리 · 검사 · 계산 오류 */
|
||||
let checked: string[] = [];
|
||||
try {
|
||||
checked = checkLogic(lg, opts?.values ?? {});
|
||||
} catch {
|
||||
checked = [];
|
||||
}
|
||||
const problems = [...new Set([...emptyPlaces(lg), ...checked, ...result.errors])];
|
||||
|
||||
/* 예시값 — 변수 상자의 변수마다 */
|
||||
const msg = el("p", { className: "m02-dfsum__msg" });
|
||||
const fields = lg.boxes
|
||||
.filter((b) => b.kind === "input" && b.input?.mode === "variable")
|
||||
.flatMap((b) =>
|
||||
(b.input?.variables ?? []).map((v, i) =>
|
||||
sampleField(
|
||||
v,
|
||||
(value) => {
|
||||
msg.textContent = "";
|
||||
if (!on) return;
|
||||
try {
|
||||
on.change({
|
||||
...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,
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
msg.textContent = ts("Cant");
|
||||
}
|
||||
},
|
||||
msg,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const plainValues = Object.entries(cost.값들);
|
||||
host.replaceChildren(
|
||||
big,
|
||||
costs,
|
||||
...(costed.length ? [table(costed, true, ts("Lines"))] : []),
|
||||
...(plain.length
|
||||
? [table(plain, false, ts("Plain"))]
|
||||
: plainValues.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-dfsum__block",
|
||||
children: [
|
||||
el("strong", { text: ts("Plain") }),
|
||||
...plainValues.map(([k, v]) =>
|
||||
el("p", { className: "m02-dfsum__muted", text: `${k} · ${showValue(v)}` }),
|
||||
),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(fields.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-dfsum__strip",
|
||||
children: [
|
||||
el("strong", { text: ts("Sample") }),
|
||||
el("div", { className: "m02-dfsum__fields", children: fields }),
|
||||
el("p", { className: "m02-dfsum__muted", text: ts("SampleHint") }),
|
||||
msg,
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(problems.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-dfsum__problems",
|
||||
children: [
|
||||
el("strong", { text: ts("Problems") }),
|
||||
el("ul", { children: problems.map((t) => el("li", { text: t })) }),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user