feat(M01): 시험 계산 재료 · 기계 고르기를 마스터 표 모달로 — 품목 고르기 · 고름 실어 다시 계산 · 단위경고 딱지 (PLAN 8-4)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
2026-09-24 20:24:26 +09:00
co-authored by Claude Sonnet 5
parent f29dc14c9a
commit f21a6cfbe7
7 changed files with 407 additions and 45 deletions
+76 -45
View File
@@ -12,9 +12,16 @@ import {
searchElements,
type CalcAnswer,
type CalcLine,
type ElementBrief,
type TextAnswer,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import {
isMaterialKeyInput,
machineControl,
materialRows,
pickBody,
} from "./M01_MasterData_UI_Logic_CalcPick";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
@@ -47,6 +54,8 @@ export interface CalcContext {
/** 저장 전 새 초안이면 비움 — 서버가 임시 자리에 얹어 셈 */
file?: string;
row: LogicRow;
/** 잡힌 품목을 보이는 데 씀 — 재료 줄의 단가 요소 · 조건 글 → 요소 */
prices?: Record<string, ElementBrief | null>;
dirty: () => boolean;
/** 로직마다 넣은 값 — 다시 그려도 남음 */
values: Record<string, string>;
@@ -68,49 +77,56 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
// 지금 쓰는 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] ?? "",
);
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: "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;
}
} else {
const box = el("input", {
className: "m01-logic__input",
attrs: { type: "text", inputmode: "decimal" },
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],
});
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 materials = materialRows(ctx.row, ctx.prices ?? {}, ctx.values, again);
const out = el("div", { className: "m01-logic__calc-out" });
const mySeqAtOpen = ctx.onText ? previewSeq + 1 : previewSeq; // 이 그리기가 받을 순번
let manualRun = false; // [계산] 이 이미 답했으면 뒤늦게 오는 미리보기로 안 덮음
@@ -131,7 +147,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
if (!fields.length && !autoAsked.has(ctx.values)) {
autoAsked.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 계산
queueMicrotask(() => {
if (previewSeq === mySeqAtOpen) host.querySelector("button")?.click();
if (previewSeq === mySeqAtOpen) again();
});
} else if (ctx.savedKey !== null && !autoAsked.has(ctx.values)) {
autoAsked.add(ctx.values);
@@ -142,7 +158,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
if (!names.length || previewSeq !== mySeqAtOpen) return; // 값이 없거나 다른 로직이 이미 열림
for (const n of names) ctx.values[n] = String(auto[n]);
buildCalc(host, ctx); // 채운 값을 새로 그리고 바로 계산(결과 · 텍스트 수식까지)
host.querySelector("button")?.click();
again();
});
}
const run = async (): Promise<void> => {
@@ -161,6 +177,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
const body = {
key: ctx.savedKey ?? ctx.row.키,
inputs,
...pickBody(ctx.values),
...(draft ? { row: ctx.row, file: ctx.file } : {}),
};
const answer = await runCalc(body);
@@ -171,12 +188,14 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
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") }),
...(fields.length
? [el("div", { className: "m01-logic__head", children: fields })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열
...(fields.length || materials.length
? [el("div", { className: "m01-logic__head", children: [...fields, ...materials] })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열
: [el("p", { className: "m01-logic__muted", text: tx("Calc_NoInputs") })]),
createButton({ label: tx("Calc_Run"), onClick: () => void run() }),
runButton,
out,
);
}
@@ -201,7 +220,19 @@ function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLEle
const rows = answer.lines.map((line) =>
el("tr", {
children: [
el("td", { text: line.출처 ? `${line.이름} (${line.출처})` : line.이름 }),
el("td", {
children: [
el("span", { text: line.출처 ? `${line.이름} (${line.출처})` : line.이름 }),
...(line.단위경고
? [
el("span", {
className: "m01-logic__tag m01lab__warn",
text: `${tx("Calc_UnitWarn")} · ${line.단위경고}`,
}),
]
: []),
],
}),
el("td", { text: `${formatNumber(line.수량)} ${line.단위}` }),
el("td", { className: "m01-logic__money", text: formatMoney(line.금액) }),
],