fix(M01): 일위대가 조합 화면 — 계약 9장대로 맞춤 · 수량은 저장하지 않고 미리 보기 때만 보냄

- 담은 로직 줄 = {로직, 메모} 뿐 · 입력값(수량)은 화면 안 메모로만 두고 조합에 안 저장
- 서버 길 실제 이름으로 맞춤: /combo/new · /combo/edit(판본) · /combo/delete(판본) · 422 검사 글·409 낡은 판본 안내
- 미리 보기는 화면이 로직마다 부르지 않고 POST /combo/preview 한 번에 받아 비목별 합계를 찍음

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
2026-09-22 00:01:48 +09:00
co-authored by Claude Sonnet 5
parent 29fad2057c
commit ca805b3076
4 changed files with 202 additions and 109 deletions
@@ -1,15 +1,15 @@
/* =============================================================================
* M01_MasterData_UI_Combo_Preview.ts
* 조합 미리 보기 — 담은 로직 저마다 입력값으로 `/calc` 를 불러 노무비·재료비·경비로 갈라 합침(보기만 · 저장 없음)
* 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST /combo/preview`)에 실어 비목별 합계를 받음
* 입력값(수량)은 이 화면 안에만 두고 조합에 저장하지 않음(계약 9장)
* ========================================================================== */
import { createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { fetchLogic, runCalc, type LogicInput } from "./M01_MasterData_UI_Logic_Api";
import type { ComboLogic } from "./M01_MasterData_UI_Combo_Api";
import { fetchLogic, type LogicInput } from "./M01_MasterData_UI_Logic_Api";
import { previewCombo, type Combo } from "./M01_MasterData_UI_Combo_Api";
import { tc, type ComboTextKey } from "./M01_MasterData_UI_Combo_Text";
const COSTS = ["노무비", "재료비", "경비"] as const;
type Costs = Record<(typeof COSTS)[number], number>;
const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = {
노무비: "Prev_Labor",
재료비: "Prev_Material",
@@ -18,25 +18,28 @@ const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = {
const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v);
const fmt = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
const total = (c: Costs): number => c.노무비 + c.재료비 + c.경비;
const blank = (): Costs => ({ 노무비: 0, 재료비: 0, 경비: 0 });
/** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */
const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? "");
/** 입력 칸 — 바꾸면 그 줄의 입력에 바로 적음(조합과 함께 저장됨) */
function inputCell(input: LogicInput, row: ComboLogic): HTMLElement {
const value = row.입력[input.이름] ?? initial(input);
row.입력[input.이름] = value;
/** 입력 칸 — 바꾼 값은 `values`(이 화면 메모)에만 적음 */
function inputCell(
input: LogicInput,
key: string,
values: Record<string, Record<string, string>>,
): HTMLElement {
const mine = (values[key] ??= {});
const value = mine[input.이름] ?? initial(input);
mine[input.이름] = value;
const choices = input.고르기?.map((v) => ({ value: String(v), text: String(v) }));
const field = choices
? createSelectField({ options: choices, value, compact: true, label: input.이름 })
: createInputField({ type: "text", label: input.이름 });
const control = "select" in field ? field.select : field.input;
control.value = value;
control.setAttribute("data-input", `${row.키}|${input.이름}`);
control.addEventListener("input", () => (row.입력[input.이름] = control.value));
control.addEventListener("change", () => (row.입력[input.이름] = control.value));
control.setAttribute("data-input", `${key}|${input.이름}`);
control.addEventListener("input", () => (mine[input.이름] = control.value));
control.addEventListener("change", () => (mine[input.이름] = control.value));
return field.root;
}
@@ -46,8 +49,11 @@ export interface PreviewHandle {
redraw: () => Promise<void>;
}
export function buildPreview(rows: () => ComboLogic[]): PreviewHandle {
const inputs = el("div", { className: "m01-logic__stack" });
export function buildPreview(combo: () => Combo): PreviewHandle {
const values: Record<string, Record<string, string>> = {};
/** 서버가 받을 꼴 — 원래 숫자·고르기 값으로 되돌림 */
const inputs = new Map<string, LogicInput[]>();
const form = el("div", { className: "m01-logic__stack" });
const out = el("div", { attrs: { "data-combo": "preview" } });
const rowOf = (cells: string[], note = "", bold = false): HTMLElement =>
el("tr", {
@@ -57,34 +63,43 @@ export function buildPreview(rows: () => ComboLogic[]): PreviewHandle {
const run = async (): Promise<void> => {
out.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" }));
const sum = blank();
const trs: HTMLElement[] = [];
for (const row of rows()) {
const cost = blank();
let name = row.키;
let note = "";
try {
const one = await fetchLogic(row.키);
name = one.logic.이름;
const values: Record<string, unknown> = {};
for (const i of one.logic.입력) {
const raw = row.입력[i.이름] ?? initial(i);
if (raw.trim() === "") continue; // 빈 칸은 안 보냄(로직 화면과 같음)
values[i.이름] = i.고르기?.find((o) => String(o) === raw) ?? num(raw);
}
const got = await runCalc({ key: row.키, inputs: values });
if (got.ok) {
for (const line of got.lines ?? []) for (const c of COSTS) cost[c] += line.비목?.[c] ?? 0;
} else note = `${tc("Prev_Fail")}: ${got.reason}`;
} catch (error) {
note = `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}`;
const send: Record<string, Record<string, unknown>> = {};
for (const [key, defs] of inputs) {
send[key] = {};
for (const i of defs) {
const raw = values[key]?.[i.이름] ?? initial(i);
if (raw.trim() === "") continue; // 빈 칸은 안 보냄(로직 화면과 같음)
send[key][i.이름] = i.고르기?.find((o) => String(o) === raw) ?? num(raw);
}
for (const c of COSTS) sum[c] += cost[c];
trs.push(
rowOf([`${row.키} ${name}`, ...COSTS.map((c) => fmt(cost[c])), fmt(total(cost))], note),
);
}
trs.push(rowOf([tc("Prev_Sum"), ...COSTS.map((c) => fmt(sum[c])), fmt(total(sum))], "", true));
let got;
try {
got = await previewCombo(combo(), send);
} catch (error) {
out.replaceChildren(
el("p", { text: `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}` }),
);
return;
}
const trs = got.줄.map((l) =>
rowOf(
[
`${l.로직} ${l.이름}`,
fmt(l.노무비 ?? 0),
fmt(l.재료비 ?? 0),
fmt(l.경비 ?? 0),
fmt(l.계 ?? 0),
],
l.ok ? "" : `${tc("Prev_Fail")}: ${l.까닭 ?? ""}`,
),
);
trs.push(
rowOf(
[tc("Prev_Sum"), fmt(got.노무비), fmt(got.재료비), fmt(got.경비), fmt(got.계)],
"",
true,
),
);
const heads = [tc("Prev_Row"), ...COSTS.map((c) => tc(LABEL[c])), tc("Prev_Sum")];
out.replaceChildren(
el("table", {
@@ -108,26 +123,28 @@ export function buildPreview(rows: () => ComboLogic[]): PreviewHandle {
const redraw = async (): Promise<void> => {
const blocks: HTMLElement[] = [];
for (const row of rows()) {
inputs.clear();
for (const row of combo().담은로직) {
try {
const one = await fetchLogic(row.키);
const one = await fetchLogic(row.로직);
inputs.set(row.로직, one.logic.입력);
blocks.push(
el("div", {
className: "m01lab__info",
children: [
el("strong", {
className: "m01lab__cell--wide",
text: `${row.키} ${one.logic.이름}`,
text: `${row.로직} ${one.logic.이름}`,
}),
...one.logic.입력.map((i) => inputCell(i, row)),
...one.logic.입력.map((i) => inputCell(i, row.로직, values)),
],
}),
);
} catch {
/* 못 읽은 로직은 계산 때 까닭이 뜸 */
/* 못 읽은 로직은 미리 보기에서 그 줄만 까닭이 뜸 */
}
}
inputs.replaceChildren(...blocks);
form.replaceChildren(...blocks);
out.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Prev_Wait") }));
};
@@ -139,7 +156,7 @@ export function buildPreview(rows: () => ComboLogic[]): PreviewHandle {
children: [el("h3", { text: tc("Prev_Title") })],
}),
el("h4", { text: tc("Prev_Inputs") }),
inputs,
form,
button,
out,
],