- 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
267 lines
9.9 KiB
TypeScript
267 lines
9.9 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
);
|
||
}
|