Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts
T

293 lines
10 KiB
TypeScript

/* =============================================================================
* M02_DF_OutputPanel.ts
* 단가산출 다이어그램 — 출력 상자 판. 출력 상자를 고르면 아래에 뜸
* 받은 값(어디서 왔나 · 값 · 단위) · 비용 종류 고르기(노무비 · 재료비 · 경비 · 값(비용 아님)) · 단위 · 이름
* 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 「상자 · 선 약속」 · Model 타입 그대로. 이 폴더째 지워도 됨.
* ========================================================================== */
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 { 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 = {
Title: ["출력", "Output"],
Name: ["이름", "Name"],
Cost: ["비용 종류", "Cost kind"],
Labor: ["노무비", "Labor cost"],
Material: ["재료비", "Material cost"],
Expense: ["경비", "Expense"],
Plain: ["값(비용 아님)", "Value (not a cost)"],
Unit: ["단위", "Unit"],
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"],
Round: ["끝수", "Rounding"],
RoundPlaces: ["자리", "Places"],
RoundMethod: ["방법", "Method"],
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: [
"원가 합계에 안 넣고 값으로만 보여요",
"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: "m02-dfout__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: "m02-dfout__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: "m02-dfout__num",
text: `${showValue(value, cost !== "값")}${unit && unit !== "-" ? ` ${unit}` : ""}`,
}),
],
}),
);
} else {
got.replaceChildren(
el("strong", { text: to("Got") }),
el("p", { className: "m02-dfout__warn", text: to("NoLink") }),
);
}
/* 이름 · 비용 종류 · 단위 */
const nameBox = el("input", {
className: "m02-dfout__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: { ...b.output, unit: b.output?.unit ?? b.unit, cost: v as CfCost },
})),
),
});
const setUnit = (next: string): void =>
emit(() =>
withBox(lg, box.id, (b) => ({
...b,
unit: next,
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",
children: [el("span", { text: label }), control],
});
/* 끝수 — 기본 = 일위대가표 금액란 0.1원 미만 버림(표준 표를 읽음) · 값(비용 아님)은 정하지 않으면 그대로 */
const round = box.output?.round;
const stepName = (n: number): string => (n === 0 ? "1" : String(Number((10 ** -n).toFixed(n))));
let fallback: CfRound = { places: 1, method: "버림" };
const roundNote = el("span", { className: "m02-dfout__muted" });
const noteOf = (r: CfRound | undefined): string =>
r
? `${to("RoundUser")}: ${roundText(r)}`
: cost === "값"
? to("RoundNone")
: `${to("RoundDefault")}: ${to("RoundStd")} ${roundText(fallback)}`;
roundNote.textContent = noteOf(round);
if (cost !== "값")
void defaultItemRound().then((r) => {
fallback = r;
roundNote.textContent = noteOf(round);
});
const setRound = (next: CfRound | undefined): void =>
emit(() =>
withBox(lg, box.id, (b) => {
const output = { ...(b.output ?? { cost, unit: b.unit }) };
if (next) output.round = next;
else delete output.round;
return { ...b, output };
}),
);
const placesField = createSelectField({
options: [
{ value: "", text: cost === "값" ? to("RoundNone") : to("RoundDefault") },
...[0, 1, 2, 3, 4].map((n) => ({ value: String(n), text: `${stepName(n)}원 단위` })),
],
value: round ? String(round.places) : "",
compact: true,
ariaLabel: to("RoundPlaces"),
onChange: (v) =>
setRound(v === "" ? undefined : { places: Number(v), method: round?.method ?? "버림" }),
});
const methodField = createSelectField({
options: (["버림", "올림", "반올림"] as const).map((m) => ({ value: m, text: `미만 ${m}` })),
value: round?.method ?? fallback.method,
compact: true,
disabled: !round,
ariaLabel: to("RoundMethod"),
onChange: (v) => round && setRound({ places: round.places, method: v as CfRound["method"] }),
});
const roundBlock = el("div", {
className: "m02-dfout__round",
children: [
el("strong", { text: to("Round") }),
el("div", {
className: "m02-dfout__fields",
children: [
field(to("RoundPlaces"), placesField.root),
field(to("RoundMethod"), methodField.root),
...(round
? [
createButton({
label: to("RoundReset"),
size: "sm",
variant: "ghost",
onClick: () => setRound(undefined),
}),
]
: []),
],
}),
roundNote,
suggestNote,
],
});
host.replaceChildren(
el("strong", { className: "m02-dfout__title", text: `${to("Title")} · ${box.name}` }),
el("div", {
className: "m02-dfout__body",
children: [
got,
el("div", {
className: "m02-dfout__fields",
children: [field(to("Name"), nameBox), costField.root, field(to("Unit"), unitBox)],
}),
],
}),
roundBlock,
...(cost === "값" ? [el("p", { className: "m02-dfout__muted", text: to("PlainHint") })] : []),
msg,
);
}