- 셈 구동 떼기: 새 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>
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|