Files
Aislo/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts
eomsangdonandClaude Sonnet 5.5 c9bdf6d891 feat(799-2): M01 로직 상세 · M02 단가산출 상세 옛 탭 넷(시트 · 인력/재료/경비 · 시험 계산 · 텍스트 수식) 걷음 — 보기는 수식 · 단가산출 상세 · 셈 구동 따로
- 셈 구동 떼기: 새 M01_MasterData_UI_Logic_Drive.ts(driveRun · previewText · autoFill · createDrive) — M01 상세는 숨은 시험 계산 칸 단추 대신 구동 함수 · 시험 계산 칸(Logic_Calc)은 로직 복사 화면이 쓰므로 남기고 같은 구동을 부름 · 재료 고름은 rerun 고리
- M02: 새 _Sangeun_Tabs.ts(수식 · 단가산출 상세 보기 고르기) · Detail 의 시험 계산 · 인력/재료/경비 걷음
- 걷은 파일(git rm): M01 LogicLab_Sheet · Logic_Sheet_Model · LogicLab_Formula(.ts .css) · FormulaTokens · LogicLab_Est / M02 Sangeun_Sheet(.ts .css) · SheetEdit · SheetFormula · logic Sheet_Model · Const_Model · Formula_Model · 부품 시트 줄(createCalcSheetRow) · Cover_Model.sheetGroups · Calc 의 M02 고리(answer · shown · held) · 죽은 CSS · 글
- 서버 길 · 문서 칸(변수줄 · 글줄 · 덮개)은 남김 — 금액 그대로
- 낡은 시험: test_790_1(시트 화면 전부) · test_714_3 · test_748_2(+ helper)는 화면이 없어져 지움 · test_794_1 · test_790_2 는 화면 · TS 거울만 덜고 서버 시험 남김 · test_714_2 · 717_2b · 721 · 722(원래 틀린 한 줄 고침) · 734 · 737 · 750_2 · 793_3b · 796_2 · 796_2b 는 보기 둘 · 새 파일에 맞춤 · 새 test_799_2_old_tabs_gone

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 12:57:30 +09:00

272 lines
11 KiB
TypeScript
Raw Permalink 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_MasterData_UI_Logic_Calc.ts
* 시험 계산 — 받을 값을 넣고 [계산] → 줄별 금액 · 비목 합 · 멈춘 까닭(엔진 글 그대로)
* 고친 로직은 저장 전 줄(`row`)을 같이 보내 메모리에서 셈(`POST /calc`) — 파일에 안 씀
* 셈 구동(입력 → 서버 → 답) = `_Logic_Drive.ts` — 로직 상세는 이 칸 없이 그 구동만 씀(799-2) · 이 칸은 로직 복사 화면이 씀
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
pctView,
searchElements,
type CalcAnswer,
type CalcLine,
type ElementBrief,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import {
isMaterialKeyInput,
machineControl,
materialRows,
showItems,
} from "./M01_MasterData_UI_Logic_CalcPick";
import { autoFill, driveRun } from "./M01_MasterData_UI_Logic_Drive";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatLineMoney, formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
import { createBadge } from "@ui/ui_template_badge";
import { createDivider } from "@ui/ui_template_divider";
/** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */
const KEY_RE = /^[A-Z]{2}\d{6}$/;
const KEY_GROUP: Record<string, string> = { MT: "재료", EQ: "기계", LB: "인력" };
/** 로직마다 다시 그려도 남는 이름 캐시 — 같은 키를 두 번 묻지 않음 */
const nameCache = new Map<string, string | null>();
async function keyName(key: string): Promise<string | null> {
if (nameCache.has(key)) return nameCache.get(key)!;
const group = KEY_GROUP[key.slice(0, 2)];
if (!group) return null;
let name: string | null = null;
try {
const found = await searchElements(group, key); // q = 키 그대로 — 「키」 칸 부분일치라 그 줄만 걸림
const hit = found.items.find((it) => it.ref === key);
name = hit ? `${hit.이름 ?? ""}${hit.규격 ? ` ${hit.규격}` : ""}`.trim() || null : null;
} catch {
name = null;
}
nameCache.set(key, name);
return name;
}
export interface CalcContext {
/** 저장된 키 — 새 로직은 null */
savedKey: string | null;
/** 저장 전 새 초안이면 비움 — 서버가 임시 자리에 얹어 셈 */
file?: string;
row: LogicRow;
/** 잡힌 품목을 보이는 데 씀 — 재료 줄의 단가 요소 · 조건 글 → 요소 */
prices?: Record<string, ElementBrief | null>;
dirty: () => boolean;
/** 로직마다 넣은 값 — 다시 그려도 남음 */
values: Record<string, string>;
onLines?: (lines: CalcLine[] | null) => void;
}
const grid = (children: HTMLElement[]): HTMLElement =>
el("div", { className: "m01-logic__head", children });
const SUMS = ["노무비", "재료비", "경비", "계"];
/** 로직마다 첫 셈을 한 번만(다시 그려도 안 부름) */
const autoAsked = new WeakSet<Record<string, string>>();
export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
// 그릇 폭 280px 고정 칸은 옛 테스트 컨테이너(UI_Test*, 걷힘)의 죽은 CSS 규칙 —
// 지금 쓰는 LogicLab 화면은 그 칸에 안 걸려 폭이 이미 넓음(실측 730px) · 따로 안 풂
const optionText = (o: string | number): string =>
(typeof o === "string" && nameCache.get(o)) || String(o);
const again = (): void => host.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
const fields = (ctx.row.입력 ?? [])
.filter((spec) => !isMaterialKeyInput(spec)) // 재료 키 목록은 표 모달로 바뀌어 걷음
.map((spec) => {
const name = spec.이름;
let control: HTMLElement;
const picks = spec.고르기;
const machine = machineControl(spec, ctx.values, again);
if (machine) {
control = machine;
} else if (picks?.length) {
const pick = createSelectField({
options: ["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
value: ctx.values[name] ?? "",
compact: true,
onChange: (v) => {
ctx.values[name] = v;
},
});
control = pick.root;
const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o));
if (keys.length) {
void Promise.all(keys.map(keyName)).then(() => {
pick.setOptions(
["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
ctx.values[name] ?? "",
);
});
}
} else {
const box = el("input", {
className: "ui-input ui-input--sm m01-logic__input",
attrs: { type: "text", inputmode: "decimal" },
});
if (spec.범위) box.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`;
box.value = ctx.values[name] ?? "";
box.addEventListener("input", () => {
ctx.values[name] = box.value;
});
control = box;
}
const label = spec.단위 ? `${name} (${spec.단위})` : name;
// 긴 이름 줄만 두 칸 차지 — 짧으면 4 열에 맞춰 나란히
const wide = label.length > 10;
return el("label", {
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
children: [el("span", { text: label }), control],
});
});
const materials = materialRows(ctx.row, ctx.prices ?? {}, ctx.values, again);
const out = el("div", { className: "m01-logic__calc-out" });
if (!fields.length && !autoAsked.has(ctx.values)) {
autoAsked.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 계산
queueMicrotask(again);
} else if (ctx.savedKey !== null && !autoAsked.has(ctx.values)) {
autoAsked.add(ctx.values);
void autoFill(ctx).then((filled) => {
if (!filled) return;
buildCalc(host, ctx); // 채운 값을 새로 그리고 바로 계산
again();
});
}
const money = (ctx.row.결과단위 ?? "").startsWith("원");
const run = async (): Promise<void> => {
try {
const { answer, draft } = await driveRun(ctx);
if (answer.ok && answer.lines) showItems(host, ctx.values, answer.lines);
out.replaceChildren(...answerView(answer, draft, money));
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
const runButton = createButton({ label: tx("Calc_Run"), onClick: () => void run() });
runButton.setAttribute("data-calc-run", "");
host.replaceChildren(
el("h3", { text: tx("Calc_Title") }),
// 옵션 고르기 · 자재 고르기 두 격자(기본 4 열) — 사이 수평 구분선(709-1 부품)
...(fields.length || materials.length
? [
...(fields.length ? [grid(fields)] : []),
...(fields.length && materials.length ? [createDivider()] : []),
...(materials.length ? [grid(materials)] : []),
]
: [el("p", { className: "ui-text ui-text--muted", text: tx("Calc_NoInputs") })]),
runButton,
out,
);
}
/** 중간 값 이름이 돈 꼴(경비 · 노무비 · 공구손료 · 금액 · 단가 · 노임) — 1,000 미만이어도 돈 꼴 */
const MONEY_NAME = /(비|료|액|가|임)$/;
/** `money` = 결과가 돈(원) — 그때만 결과 값도 정수 · 수량·비율 결과는 소수 그대로 */
export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] {
const note = draft
? [el("p", { className: "ui-text ui-text--muted", text: tx("Calc_Draft") })]
: [];
if (!answer.ok) {
return [
...note,
el("div", {
className: "ui-banner ui-banner--danger m01-logic__reasons",
children: [
el("strong", { text: tx("Calc_Stopped") }),
el("div", { text: safeReason(answer.reason) }),
],
}),
];
}
const parts: HTMLElement[] = [...note];
if (answer.lines) {
const visible = answer.lines.filter((line) => !line.옵션꺼짐); // 753-2 옵션으로 빠진 줄은 안 그림
const rows = visible.map((line) =>
el("tr", {
children: [
el("td", {
children: [
el("span", {
text: line.출처
? `${pctView(line).name} (${line.출처.replace(/[A-Z]{2}\d{6}\./g, "")})`
: pctView(line).name,
}),
...(line.단위경고
? [createBadge(`${tx("Calc_UnitWarn")} · ${line.단위경고}`, "warning")]
: []),
],
}),
el("td", { text: `${formatNumber(pctView(line).qty)} ${pctView(line).unit}` }),
el("td", { className: "m01-logic__money", text: formatLineMoney(line.금액) }),
],
}),
);
parts.push(
el("table", {
className: "ui-table ui-table--data m01-logic__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [tx("Head_Name"), tx("Ho_Qty"), tx("Ho_Amount")].map((h) =>
el("th", { text: h }),
),
}),
],
}),
el("tbody", { children: rows }),
],
}),
);
}
if (answer.sums) {
parts.push(
el("dl", {
className: "m01-logic__sums",
children: SUMS.flatMap((k) => [
el("dt", { text: k === "계" ? tx("Calc_Sum") : k }),
el("dd", { className: "m01-logic__money", text: formatMoney(answer.sums?.[k] ?? 0) }),
]),
}),
);
}
if (answer.result !== undefined) {
parts.push(
el("dl", {
className: "m01-logic__sums",
children: [
el("dt", { text: tx("Calc_Result") }),
el("dd", { text: money ? formatMoney(answer.result) : formatNumber(answer.result) }),
],
}),
);
}
// 줄 묶음(객체)은 위 상세와 겹쳐 숨김 · 돈 로직의 큰 수는 정수
const middle = Object.entries(answer.middle ?? {}).filter(([, v]) => typeof v !== "object");
if (middle.length) {
parts.push(
el("h4", { text: tx("Middle_Title") }),
el("dl", {
className: "m01-logic__sums",
children: middle.flatMap(([k, v]) => [
el("dt", { text: k }),
el("dd", {
text:
money && typeof v === "number" && (Math.abs(v) >= 1000 || MONEY_NAME.test(k))
? formatLineMoney(v) // 751-3 중간 돈 = 버린 줄 합(0.1 원)
: formatNumber(v),
}),
]),
}),
);
}
return parts;
}