로직을 열 때 /text 를 값 없이 한 번 불러 왼쪽 이름 식 칸만 미리 채움 오른쪽 값 식 칸은 [계산] 전까지 안내 글만 표시 · [계산] 뒤엔 지금과 같음 onText 콜백이 redraw 를 안 불러 화면에 안 닿던 것을 찾아 LogicLab.ts 한 줄도 같이 고침(onLines 와 같은 자리)
234 lines
8.8 KiB
TypeScript
234 lines
8.8 KiB
TypeScript
/* =============================================================================
|
||
* M01_MasterData_UI_Logic_Calc.ts
|
||
* 시험 계산 — 받을 값을 넣고 [계산] → 줄별 금액 · 비목 합 · 멈춘 까닭(엔진 글 그대로)
|
||
* 고친 로직은 저장 전 줄(`row`)을 같이 보내 메모리에서 셈(`POST /calc`) — 파일에 안 씀
|
||
* ========================================================================== */
|
||
|
||
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
|
||
import {
|
||
runCalc,
|
||
runText,
|
||
searchElements,
|
||
type CalcAnswer,
|
||
type CalcLine,
|
||
type TextAnswer,
|
||
type LogicRow,
|
||
} from "./M01_MasterData_UI_Logic_Api";
|
||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||
|
||
/** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */
|
||
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;
|
||
dirty: () => boolean;
|
||
/** 로직마다 넣은 값 — 다시 그려도 남음 */
|
||
values: Record<string, string>;
|
||
onLines: (lines: CalcLine[] | null) => void;
|
||
/** 읽는 식 줄(`/text`)도 같이 받고 싶을 때 — 호출이 `onLines` 보다 먼저 */
|
||
onText?: (answer: TextAnswer) => void;
|
||
}
|
||
|
||
const SUMS = ["노무비", "재료비", "경비", "계"];
|
||
|
||
/** 로직을 열 때마다 새로 부르는 미리보기 — 늦게 온 응답이 새 로직을 덮지 않게 순번으로 거름 */
|
||
let previewSeq = 0;
|
||
|
||
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 fields = (ctx.row.입력 ?? []).map((spec) => {
|
||
const name = spec.이름;
|
||
let control: HTMLElement;
|
||
const picks = spec.고르기;
|
||
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: "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;
|
||
// 긴 이름 줄만 한 칸 다 씀 — 고르기 줄은 짧으면 그대로 2열에 맞춰 나란히
|
||
const wide = label.length > 10;
|
||
return el("label", {
|
||
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
|
||
children: [el("span", { text: label }), control],
|
||
});
|
||
});
|
||
const out = el("div", { className: "m01-logic__calc-out" });
|
||
let manualRun = false; // [계산] 이 이미 답했으면 뒤늦게 오는 미리보기로 안 덮음
|
||
if (ctx.onText) {
|
||
const mySeq = ++previewSeq;
|
||
const draft = ctx.dirty() || ctx.savedKey === null;
|
||
const body = {
|
||
key: ctx.savedKey ?? ctx.row.키,
|
||
inputs: {},
|
||
...(draft ? { row: ctx.row, file: ctx.file } : {}),
|
||
};
|
||
void runText(body).then((answer) => {
|
||
if (manualRun || mySeq !== previewSeq) return; // [계산] 을 눌렀거나 다른 로직이 이미 열림
|
||
(answer as TextAnswer & { 계산됨?: boolean }).계산됨 = false;
|
||
ctx.onText?.(answer);
|
||
});
|
||
}
|
||
const run = async (): Promise<void> => {
|
||
manualRun = true;
|
||
const inputs: Record<string, unknown> = {};
|
||
for (const spec of ctx.row.입력 ?? []) {
|
||
const raw = (ctx.values[spec.이름] ?? "").trim();
|
||
if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤
|
||
// 고르기는 원래 값(수면 수) 그대로 — 글 "35" 로 보내면 「고르기 밖」
|
||
const option = spec.고르기?.find((o) => String(o) === raw);
|
||
inputs[spec.이름] =
|
||
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
|
||
}
|
||
const draft = ctx.dirty() || ctx.savedKey === null;
|
||
try {
|
||
const body = {
|
||
key: ctx.savedKey ?? ctx.row.키,
|
||
inputs,
|
||
...(draft ? { row: ctx.row, file: ctx.file } : {}),
|
||
};
|
||
const answer = await runCalc(body);
|
||
if (ctx.onText) ctx.onText(await runText(body));
|
||
ctx.onLines(answer.ok ? (answer.lines ?? null) : null);
|
||
out.replaceChildren(...answerView(answer, draft));
|
||
} catch (error) {
|
||
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
||
}
|
||
};
|
||
host.replaceChildren(
|
||
el("h3", { text: tx("Calc_Title") }),
|
||
...(fields.length
|
||
? [el("div", { className: "m01-logic__head", children: fields })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열
|
||
: [el("p", { className: "m01-logic__muted", text: tx("Calc_NoInputs") })]),
|
||
createButton({ label: tx("Calc_Run"), onClick: () => void run() }),
|
||
out,
|
||
);
|
||
}
|
||
|
||
function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] {
|
||
const note = draft ? [el("p", { className: "m01-logic__muted", text: tx("Calc_Draft") })] : [];
|
||
if (!answer.ok) {
|
||
return [
|
||
...note,
|
||
el("div", {
|
||
className: "m01-logic__reasons",
|
||
children: [el("strong", { text: tx("Calc_Stopped") }), el("div", { text: answer.reason })],
|
||
}),
|
||
];
|
||
}
|
||
const parts: HTMLElement[] = [...note];
|
||
if (answer.lines) {
|
||
const rows = answer.lines.map((line) =>
|
||
el("tr", {
|
||
children: [
|
||
el("td", { text: line.출처 ? `${line.이름} (${line.출처})` : line.이름 }),
|
||
el("td", { text: `${formatNumber(line.수량)} ${line.단위}` }),
|
||
el("td", { className: "m01-logic__money", text: formatNumber(line.금액) }),
|
||
],
|
||
}),
|
||
);
|
||
parts.push(
|
||
el("table", {
|
||
className: "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: formatNumber(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: formatNumber(answer.result) }),
|
||
],
|
||
}),
|
||
);
|
||
}
|
||
const middle = Object.entries(answer.middle ?? {});
|
||
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: formatNumber(v) }),
|
||
]),
|
||
}),
|
||
);
|
||
}
|
||
return parts;
|
||
}
|