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
+64 -39
View File
@@ -12,12 +12,13 @@ import {
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { fetchLogics, type LogicSummary } from "./M01_MasterData_UI_Logic_Api";
import { ApiError, fetchLogics, type LogicSummary } from "./M01_MasterData_UI_Logic_Api";
import {
deleteCombo,
editCombo,
fetchCombo,
fetchCombos,
saveCombo,
newCombo,
type Combo,
type ComboSummary,
} from "./M01_MasterData_UI_Combo_Api";
@@ -32,13 +33,25 @@ const FILTER_ID = "조합|";
const blankCombo = (): Combo => ({
: "",
: "",
: "",
: "",
: "",
: "",
: [],
구분: null,
상세구분: null,
단위: null,
: [],
: "자체",
: "현장",
비고: null,
});
/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 */
function reason(error: unknown): string {
if (error instanceof ApiError && typeof error.detail === "object" && error.detail) {
const d = error.detail as { errors?: string[]; stale?: string[] };
if (d.errors) return d.errors.join(" / ");
if (d.stale) return tc("Stale");
}
return error instanceof Error ? error.message : tc("Load_Failed");
}
export interface ComboHandle {
/** 컨테이너를 펼칠 때 — 목록을 새로 받아 그림 */
show: () => Promise<void>;
@@ -71,13 +84,15 @@ export function mountM01Combo(
/* --- 왼쪽 거름 --- */
const drawFilter = (): void => {
const subs = [...new Set(items.map((x) => x.).filter(Boolean))].map((name) => ({
name,
book: null,
details: [...new Set(items.filter((x) => x. === name).map((x) => x.))].filter(
Boolean,
),
}));
const subs = [...new Set(items.map((x) => x.).filter((v): v is string => !!v))].map(
(name) => ({
name,
book: null,
details: [...new Set(items.filter((x) => x. === name).map((x) => x.))].filter(
(v): v is string => !!v,
),
}),
);
filterHost.replaceChildren(
side.filter({
id: FILTER_ID,
@@ -99,7 +114,11 @@ export function mountM01Combo(
el("div", {
className: "m01-logic__section-head",
children: [
createButton({ label: tc("New"), variant: "filled", onClick: () => draw(blankCombo()) }),
createButton({
label: tc("New"),
variant: "filled",
onClick: () => draw(blankCombo(), ""),
}),
...more,
],
});
@@ -120,8 +139,8 @@ export function mountM01Combo(
const tr = el("tr", {
className: "m01-master__table-row",
attrs: { "data-combo-key": x. },
children: [x., x., x., x., x., String(x.)].map((v) =>
el("td", { text: v }),
children: [x., x., x. ?? "", x. ?? "", x. ?? "", String(x.count)].map(
(v) => el("td", { text: v }),
),
});
tr.addEventListener("click", () => void open(x.));
@@ -150,41 +169,46 @@ export function mountM01Combo(
const open = async (key: string): Promise<void> => {
try {
draw(await fetchCombo(key));
const one = await fetchCombo(key);
draw(one.combo, one.version);
} catch (error) {
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
}
};
/* --- 상세 --- */
const draw = (combo: Combo): void => {
const draw = (combo: Combo, version: string): void => {
const field = (label: string, name: "이름" | "구분" | "상세구분" | "단위" | "비고") => {
const f = createInputField({ type: "text", label });
f.input.value = combo[name];
f.input.value = combo[name] ?? "";
f.input.setAttribute("data-combo-field", name);
f.input.addEventListener("input", () => (combo[name] = f.input.value));
f.input.addEventListener("input", () => {
const v = f.input.value;
if (name === "이름") combo. = v;
else combo[name] = v || null;
});
return f.root;
};
const preview = buildPreview(() => combo.);
const preview = buildPreview(() => combo);
const table = el("div");
const drawTable = (): void => {
const move = (i: number, d: number): void => {
const j = i + d;
if (j < 0 || j >= combo..length) return;
[combo.[i], combo.[j]] = [combo.[j], combo.[i]];
if (j < 0 || j >= combo..length) return;
[combo.[i], combo.[j]] = [combo.[j], combo.[i]];
drawTable();
};
const trs = combo..map((row, i) => {
const info = logics.get(row.);
const trs = combo..map((row, i) => {
const info = logics.get(row.);
const memo = createInputField({ type: "text" });
memo.input.value = row.;
memo.input.addEventListener("input", () => (row. = memo.input.value));
memo.input.value = row. ?? "";
memo.input.addEventListener("input", () => (row. = memo.input.value || null));
const name = el("button", {
className: "ui-btn ui-btn--ghost",
text: info?.이름 ?? row.,
text: info?.이름 ?? row.,
attrs: { type: "button", title: tc("Open") },
});
name.addEventListener("click", () => onOpenLogic(row.));
name.addEventListener("click", () => onOpenLogic(row.));
const btns = el("div", {
className: "m01-logic__chips",
children: [
@@ -194,17 +218,17 @@ export function mountM01Combo(
label: tc("Remove"),
variant: "ghost",
onClick: () => {
combo..splice(i, 1);
combo..splice(i, 1);
drawTable();
},
}),
],
});
return el("tr", {
attrs: { "data-combo-logic": row. },
attrs: { "data-combo-logic": row. },
children: [
el("td", { text: String(i + 1) }),
el("td", { text: row. }),
el("td", { text: row.로직 }),
el("td", { children: [name] }),
el("td", { text: info?.결과단위 ?? "" }),
el("td", { children: [memo.root] }),
@@ -241,7 +265,7 @@ export function mountM01Combo(
onClick: () =>
openLogicPicker((x) => {
if (!logics.has(x.)) logics.set(x., x);
combo..push({ : x.키, : "", : {} });
combo..push({ 로직: x.키, 메모: null });
drawTable();
}),
});
@@ -251,12 +275,13 @@ export function mountM01Combo(
onClick: async () => {
if (!combo..trim()) return showToast(tc("NeedName"), "error");
try {
const saved = await saveCombo(combo);
const key = combo. ? combo. : (await newCombo(combo)).key;
if (combo.) await editCombo(combo., version, combo);
showToast(tc("Saved"), "success");
await load();
draw(saved);
await open(key);
} catch (error) {
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
showToast(reason(error), "error");
}
},
});
@@ -266,11 +291,11 @@ export function mountM01Combo(
onClick: async () => {
if (!combo. || !(await showConfirmDialog(tc("DeleteAsk")))) return;
try {
await deleteCombo(combo.);
await deleteCombo(combo., version);
showToast(tc("Deleted"), "success");
await load();
} catch (error) {
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
showToast(reason(error), "error");
}
},
});
+72 -22
View File
@@ -1,34 +1,72 @@
/* =============================================================================
* M01_MasterData_UI_Combo_Api.ts
* 일위대가 조합 화면이 부르는 서버 길 — 조합 마스터 `일위대가조합.json`(키 UA)
* 길 이름은 계약(PLAN 4-2)이 오면 이 파일만 맞춤 · 미리 보기는 로직마다 `/calc` 를 불러 화면에서 합침
* 길 = `_화면_계약.md` 9장
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { ApiError } from "./M01_MasterData_UI_Logic_Api";
/** 조합에 담은 로직 한 줄 — 입력 = 그 로직을 셀 때 넣을 값 */
/** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */
export interface ComboLogic {
: string;
메모: string;
입력: Record<string, string>;
로직: string;
메모: string | null;
}
/** 조합 한 줄 통째 — `_틀.md` 10장 · 키·소유는 서버 것이 이김 */
export interface Combo {
: string;
이름: string;
구분: string;
상세구분: string;
단위: string;
비고: string;
로직: ComboLogic[];
구분: string | null;
상세구분: string | null;
단위: string | null;
담은로직: ComboLogic[];
출처: string | null;
소유: string | null;
비고: string | null;
}
export interface ComboSummary {
: string;
export interface ComboSummary extends Combo {
count: number;
blocked: boolean;
reasons: string[];
}
/** `GET /combo` 의 `줄` — 담은 로직에 이름을 붙인 것 */
export interface ComboLine {
차례: number;
로직: string;
메모: string | null;
ok: boolean;
까닭?: string;
이름?: string;
구분?: string;
상세구분?: string;
결과단위?: string;
}
export interface ComboOne {
file: string;
version: string;
combo: Combo;
: ComboLine[];
}
export interface PreviewLine {
차례: number;
로직: string;
이름: string;
구분: string;
상세구분: string;
단위: string;
로직수: number;
ok: boolean;
까닭?: string;
갈래?: string;
결과?: number;
노무비?: number;
재료비?: number;
경비?: number;
계?: number;
}
export interface PreviewAnswer {
: PreviewLine[];
노무비: number;
재료비: number;
경비: number;
: number;
멈춤: string[];
}
async function request<T>(path: string, body?: unknown): Promise<T> {
@@ -46,11 +84,23 @@ async function request<T>(path: string, body?: unknown): Promise<T> {
export const fetchCombos = (): Promise<ComboSummary[]> =>
request<{ combos: ComboSummary[] }>("/combos").then((d) => d.combos);
export const fetchCombo = (key: string): Promise<Combo> =>
request<{ combo: Combo }>(`/combo?key=${encodeURIComponent(key)}`).then((d) => d.combo);
export const fetchCombo = (key: string): Promise<ComboOne> =>
request(`/combo?key=${encodeURIComponent(key)}`);
/** 키가 비면 새 조합 — 서버가 다음 번호(UA…)를 줌 */
export const saveCombo = (combo: Combo): Promise<Combo> =>
request<{ combo: Combo }>("/combo/save", { combo }).then((d) => d.combo);
export const newCombo = (combo: Combo): Promise<{ file: string; version: string; key: string }> =>
request("/combo/new", { combo, owner: "현장" });
export const deleteCombo = (key: string): Promise<unknown> => request("/combo/delete", { key });
export const editCombo = (
key: string,
version: string,
combo: Combo,
): Promise<{ file: string; version: string }> => request("/combo/edit", { key, version, combo });
export const deleteCombo = (key: string, version: string): Promise<unknown> =>
request("/combo/delete", { key, version });
/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */
export const previewCombo = (
combo: Combo | string,
inputs: Record<string, Record<string, unknown>>,
): Promise<PreviewAnswer> => request("/combo/preview", { combo, inputs });
@@ -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,
],
@@ -17,6 +17,7 @@ const TEXT = {
DeleteAsk: ["이 조합을 지울까요?", "Delete this combination?"],
Saved: ["저장함", "Saved"],
Deleted: ["지움", "Deleted"],
Stale: ["그 사이 조합 파일이 바뀜 — 다시 열어 주세요", "The file changed — reopen it"],
NeedName: ["이름을 적어야 저장됨", "A name is required"],
Load_Failed: ["불러오지 못함", "Load failed"],
Head_Key: ["키", "Key"],