Files
Aislo/M01_MasterData/SheetLogic/M01_SL_Result.ts
T
eomsangdonandClaude Sonnet 5.5 e5955b705d fix(m01-sl): 비율 바탕 이음은 아는 상자만 · 손선 보존, 결과 칸 값/단위, 다른 로직 손잡이
- refreshBases 를 Model connect/disconnect 로 바꿔 모르는 항목 상자와 손으로 이은 선을 안 건드림, applyItemLayout 내보냄
- 결과 칸: 돈 아닌 결과는 값과 단위, 「100㎡ 당」 표기
- 새 LogicTools: 다른 로직 찾기 · 고르기 · 넘길 값 · 결과 알약

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 13:55:04 +09:00

267 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M01_SL_Result.ts
* 시트 로직 — 오른쪽 결과 칸: 큰 합계 · 노무비 · 재료비 · 경비 · 일위대가표(EasyCalc Sheet 에 값을 맞춰 넣음)
* · 예시값 띠(입력값 상자의 예시값을 여기서도 바꿈) · 오류 · 빈 입력 한 줄
* 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌.
* 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 8. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createSelectField, el } from "@ui/ui_template_elements";
import type {
Cell,
DataValidationRule,
Sheet,
} from "../../A00_Common/spreadsheet/spreadsheet_types";
import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet";
import type { SlEvalResult } from "./M01_SL_Eval";
import { PILL_AMOUNT, PILL_PRICE, USED, itemRefOf } from "./M01_SL_Item";
import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model";
import "./M01_SL_Result.css";
const TEXT = {
Per: ["{per} 당", "per {per}"],
Total: ["합계", "Total"],
Value: ["결과값", "Result value"],
Won: ["원", "KRW"],
Labor: ["노무비", "Labor cost"],
Material: ["재료비", "Material cost"],
Expense: ["경비", "Expense"],
Sample: ["예시값", "Sample values"],
SampleHint: [
"여기서 바꾸면 입력값 상자의 예시값도 바뀌어요",
"Changing here changes the input box too",
],
Problems: ["고칠 것", "To fix"],
Empty: ["「{box}」 상자의 「{pill}」 칸이 비었어요", "“{pill}” in box “{box}” is empty"],
Bad: ["숫자를 넣어 주세요", "Enter a number"],
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
Pick: ["고르세요", "Choose"],
RateOf: ["{base}의 %", "% of {base}"],
} as const;
type TextKey = keyof typeof TEXT;
const tr = (key: TextKey, fill: Record<string, string> = {}): string =>
TEXT[key][currentLanguageIndex].replace(/\{(\w+)\}/g, (m, k: string) => fill[k] ?? m);
const COSTS = ["노무비", "재료비", "경비"] as const;
const num = (v: number): string => v.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
const n0 = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined =>
sl.workbook.시트.find((s) => s.id === node.sheetId);
const colOf = (cell: string): { r: number; c: number } => ({
r: Number(cell.replace(/\D/g, "")) - 1,
c: cell.charCodeAt(0) - 65,
});
/** 일위대가표 줄 — 항목 상자 하나 = 줄 하나(출력 알약을 읽음) */
function linesOf(sl: SheetLogic, values: SlEvalResult): CalcLine[] {
return sl.nodes
.filter((n) => n.kind === "item")
.map((n) => {
const ref = itemRefOf(n);
const p = values.pills[n.id] ?? {};
const unit = n.pillsOut.find((x) => x.name === USED)?.unit ?? "";
const pickName = (name: string): number => n0(p[name]);
return {
이름: n.name,
단위: unit,
수량: pickName(USED),
단가: pickName(PILL_PRICE),
금액: pickName(PILL_AMOUNT),
비목: Object.fromEntries(COSTS.map((c) => [c, pickName(c)])),
...(ref?.what === "비율"
? { 품목: { 키: "", 규격: tr("RateOf", { base: ref.base ?? "노무비" }) } }
: ref?.spec
? { 품목: { 키: "", 규격: ref.spec } }
: {}),
};
});
}
/** 빈 입력 — 입력 알약 칸에 값도 참조도 없는 것 */
function emptyInputs(sl: SheetLogic): string[] {
return sl.nodes.flatMap((n) => {
const cells = sheetOf(sl, n)?.칸 ?? {};
return n.pillsIn
.filter(
(p) => !p.outside && !(cells[p.cell] && ("값" in cells[p.cell] || "식" in cells[p.cell])),
)
.map((p) => tr("Empty", { box: n.name, pill: p.name }));
});
}
/** 예시값 칸 — 선택 목록(유효성)이 걸린 칸은 고르기 · 수범위는 범위 안내 */
function ruleAt(sheet: Sheet, cell: string): DataValidationRule["규칙"] | null {
const { r, c } = colOf(cell);
const hit = (sheet.유효성 ?? []).find((v) =>
v.범위.some((g) => r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1),
);
return hit?.규칙 ?? null;
}
function sampleField(
sl: SheetLogic,
node: SlNode,
pill: SlPill,
put: (cell: string, v: number | string) => void,
msg: HTMLElement,
): HTMLElement {
const sheet = sheetOf(sl, node);
const cur: Cell | undefined = sheet?.칸[pill.cell];
const rule = sheet ? ruleAt(sheet, pill.cell) : null;
const label = el("span", { text: pill.unit ? `${pill.name} (${pill.unit})` : pill.name });
let control: HTMLElement;
if (rule?.종류 === "목록") {
const field = createSelectField({
options: [{ value: "", text: tr("Pick") }, ...rule.값들.map((v) => ({ value: v, text: v }))],
value: cur?.값 === undefined ? "" : String(cur.값),
compact: true,
ariaLabel: pill.name,
onChange: (v) => {
if (v !== "") put(pill.cell, v);
},
});
control = field.root;
} else {
const box = el("input", {
className: "m01-slresult__input",
attrs: { type: "text", inputmode: "decimal", "aria-label": pill.name },
});
if (rule?.종류 === "수범위") {
box.placeholder = tr("Range", { a: String(rule.아래 ?? ""), b: String(rule.위 ?? "") });
}
const linked = cur?.식 !== undefined;
box.value = linked || cur?.값 === undefined ? "" : String(cur.값);
box.readOnly = linked;
box.addEventListener("change", () => {
const raw = box.value.trim();
const n = Number(raw);
if (raw === "" || Number.isNaN(n)) {
msg.textContent = tr("Bad");
return;
}
put(pill.cell, n);
});
control = box;
}
return el("label", { className: "m01-slresult__field", children: [label, control] });
}
/** 결과 칸 — values = 화면 계산 결과 · 예시값을 바꾸면 on.change(새 시트 로직) */
export function renderResult(
host: HTMLElement,
sl: SheetLogic,
values: SlEvalResult,
on: { change(sl: SheetLogic): void },
): void {
const money = !sl.resultUnit || sl.resultUnit.startsWith("원");
const { 계 } = values.result;
/** 돈이 아닌 결과(㎥/시간 …) — 결과 상자의 「계」 · 없으면 이어진 첫 숫자 */
const plainValue = (): number | null => {
const box = sl.nodes.find((n) => n.kind === "result");
const ins = box ? Object.values(values.pillsIn[box.id] ?? {}) : [];
const first = [계, ...COSTS.map((c) => values.result[c]), ...ins].find(
(v) => typeof v === "number" && Number.isFinite(v),
);
return typeof first === "number" ? first : null;
};
const perUnit = sl.resultUnit.split("/").slice(1).join("/");
const perText = !perUnit
? tr("Total")
: tr("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` });
const shown = money ? 계 : plainValue();
const big = el("p", {
className: "m01-slresult__big",
children: [
el("span", { text: money ? perText : tr("Value") }),
el("strong", {
text:
shown === null
? "—"
: money
? `${formatMoney(shown)} ${tr("Won")}`
: `${num(shown)} ${sl.resultUnit}`.trim(),
}),
],
});
const costs = el("dl", {
className: "m01-slresult__costs",
children: COSTS.flatMap((c, i) => [
el("dt", { text: tr((["Labor", "Material", "Expense"] as const)[i]) }),
el("dd", { text: values.result[c] === null ? "—" : formatMoney(values.result[c]) }),
]),
});
const head = { name: sl.name, resultUnit: sl.resultUnit };
const problems = [...values.errors, ...emptyInputs(sl)];
const ans: CalcAnswer =
values.errors.length || 계 === null
? { ok: false, reason: values.errors[0] ?? emptyInputs(sl)[0] ?? "" }
: {
ok: true,
lines: linesOf(sl, values),
sums: {
노무비: n0(values.result.노무비),
재료비: n0(values.result.재료비),
경비: n0(values.result.경비),
계: n0(계),
},
middle: {},
};
const sheetHost = el("div", { className: "m01-slresult__sheet" });
if (money) renderSheet(sheetHost, head, ans);
const msg = el("p", { className: "m01-slresult__msg" });
const put =
(node: SlNode) =>
(cell: string, v: number | string): void => {
msg.textContent = "";
try {
const 시트 = sl.workbook.시트.map((s) =>
s.id === node.sheetId ? { ...s, 칸: { ...s.칸, [cell]: { 값: v } } } : s,
);
on.change({ ...sl, workbook: { ...sl.workbook, 시트 } });
} catch {
msg.textContent = tr("Cant");
}
};
const fields = sl.nodes
.filter((n) => n.kind === "input")
.flatMap((n) => n.pillsOut.map((p) => sampleField(sl, n, p, put(n), msg)));
const strip = fields.length
? [
el("div", {
className: "m01-slresult__strip",
children: [
el("strong", { text: tr("Sample") }),
el("div", { className: "m01-slresult__fields", children: fields }),
el("p", { className: "m01-slresult__muted", text: tr("SampleHint") }),
msg,
],
}),
]
: [];
host.replaceChildren(
big,
...(money ? [costs] : []),
...strip,
...(problems.length
? [
el("div", {
className: "m01-slresult__problems",
children: [
el("strong", { text: tr("Problems") }),
el("ul", { children: problems.map((t) => el("li", { text: t })) }),
],
}),
]
: []),
sheetHost,
);
}