feat(m02-cf): 단가산출 다이어그램 출력 상자 판(OutputPanel) · 원가 합계 칸(Summary)

출력 상자의 받은 값 · 비용 종류 · 단위 · 이름 판, 원가 합계(노무비 · 재료비 · 경비 · 계) · 출력 상자별 줄 · 값 출력 · 빈 받는 자리 · 오류 · 예시값.

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 16:05:15 +09:00
co-authored by Claude Sonnet 5.5
parent ac5a0eb685
commit 11d377d9e0
4 changed files with 690 additions and 0 deletions
@@ -0,0 +1,85 @@
.m01-cfout__title {
color: var(--color-text-body);
font-size: var(--text-body);
}
.m01-cfout__body {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--spacing-16);
margin: var(--spacing-8) 0;
}
.m01-cfout__got {
min-width: 220px;
color: var(--color-text-body);
}
.m01-cfout__got dl {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-4) var(--spacing-8);
margin: var(--spacing-4) 0 0;
font-size: var(--text-body-sm);
}
.m01-cfout__got dt {
color: var(--color-text-secondary);
}
.m01-cfout__got dd {
margin: 0;
}
.m01-cfout__num {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.m01-cfout__fields {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-8) var(--spacing-16);
}
.m01-cfout__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m01-cfout__input {
box-sizing: border-box;
width: 160px;
max-width: 100%;
height: var(--control-height-sm);
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
}
.m01-cfout__warn {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-body-sm);
}
.m01-cfout__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m01-cfout__msg {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-caption);
}
@@ -0,0 +1,195 @@
/* =============================================================================
* M02_CF_OutputPanel.ts
* 단가산출 다이어그램 — 출력 상자 판. 출력 상자를 고르면 아래에 뜸
* 받은 값(어디서 왔나 · 값 · 단위) · 비용 종류 고르기(노무비 · 재료비 · 경비 · 값(비용 아님)) · 단위 · 이름
* 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 「상자 · 선 약속」 · Model 타입 그대로. 이 폴더째 지워도 됨.
* ========================================================================== */
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 { evalLogic, type CfEvalResult, type CfValue } from "./M02_CF_Eval";
import { portsOut, type CfBox, type CfCost, type CfLogic } from "./M02_CF_Model";
import "./M02_CF_OutputPanel.css";
const TEXT = {
Title: ["출력", "Output"],
Name: ["이름", "Name"],
Cost: ["비용 종류", "Cost kind"],
Labor: ["노무비", "Labor cost"],
Material: ["재료비", "Material cost"],
Expense: ["경비", "Expense"],
Plain: ["값(비용 아님)", "Value (not a cost)"],
Unit: ["단위", "Unit"],
UnitHint: ["예: 원 · ㎥ · 시간 · -", "e.g. KRW · ㎥ · h · -"],
Got: ["받은 값", "Received value"],
From: ["어디서", "From"],
Value: ["값", "Value"],
NoLink: [
"아직 이어진 선이 없어요 — 입력이나 함수 상자에서 이 상자로 선을 이어 주세요",
"Nothing is linked yet — draw a line from an input or function box",
],
Table: ["표", "Table"],
Calc: ["계산 중이 아니에요", "Not calculated"],
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
PlainHint: [
"원가 합계에 안 넣고 값으로만 보여요",
"Shown as a value, not added to the cost total",
],
} as const;
type TextKey = keyof typeof TEXT;
const to = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
const COSTS: [CfCost, TextKey][] = [
["노무비", "Labor"],
["재료비", "Material"],
["경비", "Expense"],
["값", "Plain"],
];
/** 값 한 칸의 글 — 수 · 글 · 참거짓 · 표 */
export function showValue(v: CfValue | undefined, money = false): string {
if (v === undefined || v === null) return "—";
if (Array.isArray(v)) return to("Table");
if (typeof v === "number")
return money ? formatMoney(v) : v.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
return String(v);
}
const withBox = (lg: CfLogic, id: string, edit: (b: CfBox) => CfBox): CfLogic => ({
...lg,
boxes: lg.boxes.map((b) => (b.id === id ? edit(b) : b)),
});
/** 출력 상자 판 — `opts.result` 가 있으면 그 값으로 「받은 값」을 보임(없으면 지금 계산을 한 번 해 봄) */
export function renderOutputPanel(
host: HTMLElement,
lg: CfLogic,
boxId: string,
on: { change(lg: CfLogic): void },
opts: { result?: CfEvalResult } = {},
): void {
const box = lg.boxes.find((b) => b.id === boxId);
if (!box || box.kind !== "output") {
host.replaceChildren();
return;
}
const msg = el("p", { className: "m01-cfout__msg" });
const emit = (make: () => CfLogic): void => {
msg.textContent = "";
try {
on.change(make());
} catch {
msg.textContent = to("Cant");
}
};
const cost = box.output?.cost ?? "노무비";
/* 받은 값 — 이 상자로 들어오는 선 하나(받는 자리는 하나) */
const link = lg.links.find((l) => l.to.box === box.id);
const from = link && lg.boxes.find((b) => b.id === link.from.box);
let result = opts.result;
if (!result && link) {
try {
result = evalLogic(lg);
} catch {
result = undefined;
}
}
let unit = "";
if (from && link) {
try {
unit = portsOut(from).find((p) => p.name === link.from.name)?.unit ?? from.unit;
} catch {
unit = from.unit;
}
}
const got = el("div", { className: "m01-cfout__got" });
if (link && from) {
const value = result?.ports[from.id]?.[link.from.name] ?? result?.outputs[box.id];
got.replaceChildren(
el("strong", { text: to("Got") }),
el("dl", {
children: [
el("dt", { text: to("From") }),
el("dd", { text: `${from.name} · ${link.from.name}` }),
el("dt", { text: to("Value") }),
el("dd", {
className: "m01-cfout__num",
text: `${showValue(value, cost !== "값")}${unit && unit !== "-" ? ` ${unit}` : ""}`,
}),
],
}),
);
} else {
got.replaceChildren(
el("strong", { text: to("Got") }),
el("p", { className: "m01-cfout__warn", text: to("NoLink") }),
);
}
/* 이름 · 비용 종류 · 단위 */
const nameBox = el("input", {
className: "m01-cfout__input",
attrs: { type: "text", "aria-label": to("Name") },
});
nameBox.value = box.name;
nameBox.addEventListener("change", () => {
const next = nameBox.value.trim();
if (!next || next === box.name) {
nameBox.value = box.name;
return;
}
emit(() => withBox(lg, box.id, (b) => ({ ...b, name: next })));
});
const costField = createSelectField({
label: to("Cost"),
options: COSTS.map(([value, key]) => ({ value, text: to(key) })),
value: cost,
compact: true,
onChange: (v) =>
emit(() =>
withBox(lg, box.id, (b) => ({
...b,
output: { unit: b.output?.unit ?? b.unit, cost: v as CfCost },
})),
),
});
const unitBox = el("input", {
className: "m01-cfout__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() || "-";
emit(() =>
withBox(lg, box.id, (b) => ({
...b,
unit: next,
output: { cost: b.output?.cost ?? "노무비", unit: next },
})),
);
});
const field = (label: string, control: HTMLElement): HTMLElement =>
el("label", {
className: "m01-cfout__field",
children: [el("span", { text: label }), control],
});
host.replaceChildren(
el("strong", { className: "m01-cfout__title", text: `${to("Title")} · ${box.name}` }),
el("div", {
className: "m01-cfout__body",
children: [
got,
el("div", {
className: "m01-cfout__fields",
children: [field(to("Name"), nameBox), costField.root, field(to("Unit"), unitBox)],
}),
],
}),
...(cost === "값" ? [el("p", { className: "m01-cfout__muted", text: to("PlainHint") })] : []),
msg,
);
}
@@ -0,0 +1,132 @@
.m01-cfsum__big {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-8);
margin: 0 0 var(--spacing-8);
color: var(--color-text-secondary);
}
.m01-cfsum__big strong {
color: var(--color-text-body);
font-size: 28px;
font-variant-numeric: tabular-nums;
}
.m01-cfsum__costs {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4) var(--spacing-16);
margin: 0 0 var(--spacing-12);
font-size: var(--text-body-sm);
}
.m01-cfsum__costs dt {
display: inline;
color: var(--color-text-secondary);
}
.m01-cfsum__costs dd {
display: inline;
margin: 0 0 0 var(--spacing-4);
color: var(--color-text-body);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.m01-cfsum__block {
margin: 0 0 var(--spacing-12);
color: var(--color-text-body);
}
.m01-cfsum__scroll {
max-width: 100%;
overflow-x: auto;
}
.m01-cfsum__table {
width: 100%;
min-width: 360px;
margin-top: var(--spacing-4);
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.m01-cfsum__table th,
.m01-cfsum__table td {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
text-align: left;
vertical-align: middle;
}
.m01-cfsum__table th {
background: var(--color-surface);
color: var(--color-text-secondary);
font-weight: 600;
white-space: nowrap;
}
.m01-cfsum__table td.m01-cfsum__num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.m01-cfsum__strip {
margin: 0 0 var(--spacing-12);
color: var(--color-text-body);
}
.m01-cfsum__fields {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8) var(--spacing-16);
margin: var(--spacing-8) 0;
}
.m01-cfsum__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m01-cfsum__input {
box-sizing: border-box;
width: 140px;
height: var(--control-height-sm);
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
}
.m01-cfsum__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m01-cfsum__msg {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-caption);
}
.m01-cfsum__problems {
margin: 0 0 var(--spacing-12);
padding: var(--spacing-8) var(--spacing-12);
border-left: 4px solid var(--color-danger);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.m01-cfsum__problems ul {
margin: var(--spacing-4) 0 0;
padding-left: var(--spacing-16);
}
@@ -0,0 +1,278 @@
/* =============================================================================
* M02_CF_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 } from "./M02_CF_Eval";
import type { CfBox, CfLogic, CfVariable } from "./M02_CF_Model";
import { showValue } from "./M02_CF_OutputPanel";
import "./M02_CF_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: "m01-cfsum__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: "m01-cfsum__field", children: [label, control] });
}
/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 */
export function renderSummary(
host: HTMLElement,
lg: CfLogic,
result: CfEvalResult,
on?: { change(lg: CfLogic): void },
): 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: "m01-cfsum__big",
children: [
el("span", { text: money ? perText : ts("Total") }),
el("strong", { text: `${formatMoney(cost.계)} ${ts("Won")}` }),
],
});
const costs = el("dl", {
className: "m01-cfsum__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: "m01-cfsum__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: "m01-cfsum__block",
children: [
el("strong", { text: title }),
el("div", {
className: "m01-cfsum__scroll",
children: [
el("table", {
className: "m01-cfsum__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);
} catch {
checked = [];
}
const problems = [...new Set([...emptyPlaces(lg), ...checked, ...result.errors])];
/* 예시값 — 변수 상자의 변수마다 */
const msg = el("p", { className: "m01-cfsum__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: "m01-cfsum__block",
children: [
el("strong", { text: ts("Plain") }),
...plainValues.map(([k, v]) =>
el("p", { className: "m01-cfsum__muted", text: `${k} · ${showValue(v)}` }),
),
],
}),
]
: []),
...(fields.length
? [
el("div", {
className: "m01-cfsum__strip",
children: [
el("strong", { text: ts("Sample") }),
el("div", { className: "m01-cfsum__fields", children: fields }),
el("p", { className: "m01-cfsum__muted", text: ts("SampleHint") }),
msg,
],
}),
]
: []),
...(problems.length
? [
el("div", {
className: "m01-cfsum__problems",
children: [
el("strong", { text: ts("Problems") }),
el("ul", { children: problems.map((t) => el("li", { text: t })) }),
],
}),
]
: []),
);
}