feat(M01): 일위대가 로직 기본 보기를 흐름 그림으로 · 상자에서 보고 고치기

방식 C 를 테스트 컨테이너에서 꺼내 로직 화면 기본 보기로 올림(옛 호표 표는 「고급」).
상자를 누르면 값·출처에 더해 쉬운 말 풀이와 원문 표 미리보기(걸린 줄 강조)가 펼쳐짐.
자체 로직은 [고치기] 로 상자 안에서 고치고 줄을 더하거나 지움 — 정본은 「본떠 만들기」 뒤.

- UI_Test_C·_Model·_Style → UI_Logic_Flow·_Model·_Style
- UI_Test_Pick 의 재료·기계 바꿔 고르기를 UI_Logic_Pick 에 합침
- 방식 B 의 설명 카드 로직을 UI_Logic_Note 로 옮겨 흐름 그림과 같이 씀

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
2026-09-21 09:35:33 +09:00
co-authored by Claude Opus 5
parent 2d24cd358e
commit 98e40f4496
13 changed files with 1213 additions and 728 deletions
@@ -0,0 +1,533 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Flow.ts
* 일위대가 로직 기본 보기 — 흐름 그림. 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계
*
* 상자를 누르면 값·출처 · 원문 표 미리보기 · 쉬운 말 풀이(`…_Logic_Note.ts`) · 부르는 로직 펼치기.
* [고치기] 를 켜면 상자 안에서 바로 고치고 줄을 더하거나 지움 — 자체 로직만(정본은 「본떠 만들기」 뒤).
* 값은 모두 시험 계산(`POST /calc`)에서 옴 — 여기서 새로 셈하지 않음.
* 뼈대(어느 상자가 어디에) = `M01_MasterData_UI_Logic_Flow_Model.ts`.
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
fetchLogic,
runCalc,
type CalcAnswer,
type HoLine,
type LogicOne,
type LogicRow,
type NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
import {
buildFlow,
isMoney,
type FlowBox,
type FlowColumn,
type FlowKind,
} from "./M01_MasterData_UI_Logic_Flow_Model";
import { buildNote } from "./M01_MasterData_UI_Logic_Note";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick";
import { guideLines, plainReason, tx, type TextKey } from "./M01_MasterData_UI_Logic_Text";
import "./M01_MasterData_UI_Logic_Flow_Style.css";
const title = (kind: FlowKind): string => tx(`Col_${kind}` as TextKey);
/** 자체 로직인지 — 자체 파일이거나 키가 `GX…` · 아직 저장 안 한 새 줄(키 "")도 고칠 수 있음 */
export const isOwnLogic = (file: string, row: LogicRow): boolean =>
file.startsWith("로직_자체") || (row.키 ?? "").startsWith("GX") || (row.키 ?? "") === "";
export interface FlowContext {
one: LogicOne;
/** 지금 보는 줄 — 고친 것이 있으면 그 줄(`one.logic` 과 다른 객체일 수 있음) */
row: LogicRow;
/** 상자에서 고칠 수 있는지 — 자체 로직만 */
editable: boolean;
/** 고친 뒤 — 화면이 캐시에 담음 */
onChange?: () => void;
/** 「본떠 만들기」 — 정본 로직일 때만 */
onCopy?: () => void;
}
/** 흐름 그림 하나를 `host` 에 그림 — 로직 키 하나(어느 로직이 와도 돎 · 읽기 전용) */
export function render(host: HTMLElement, logicKey: string): void {
host.replaceChildren(el("p", { className: "m01c__muted", text: tx("Load_Failed") }));
void fetchLogic(logicKey)
.then((one) => mountFlow(host, { one, row: one.logic, editable: false }))
.catch((error: unknown) => {
host.replaceChildren(
el("p", {
className: "m01c__bad",
text: error instanceof Error ? error.message : tx("Load_Failed"),
}),
);
});
}
export function mountFlow(host: HTMLElement, ctx: FlowContext): void {
const one = ctx.one;
const row = ctx.row;
const values: Record<string, string> = {};
// 고르기 칸은 첫 값으로 시작 — 설계자가 바로 흐름을 보게(수 칸은 비워 둠)
for (const spec of row.입력 ?? []) {
if (spec.고르기?.length) values[spec.이름] = String(spec.고르기[0]);
}
const swaps: Swaps = new Map();
const picker = pickPanel({ ...one, logic: row }, swaps, () => run());
const open = new Set<string>();
const subs = new Map<string, LogicRow>();
let answer: CalcAnswer | null = null;
let editing = false;
let timer: number | undefined;
const rest = el("div", { className: "m01c__rest" });
const stopped = el("p", { className: "m01c__bad", attrs: { hidden: "" } });
/** 고친 줄 — 화면에 알리고 계산을 다시(값 칸은 글쇠 자리를 지켜 늦춰 그림) */
const touched = (): void => {
ctx.onChange?.();
later();
};
/* ── 상자 ─────────────────────────────────────────────────────────── */
const boxView = (box: FlowBox): HTMLElement => {
const node = el("details", {
className: `m01c__box${box.bad ? " m01c__box--bad" : ""}`,
children: [
el("summary", {
children: [
el("span", { className: "m01c__label", text: box.label }),
el("span", { className: "m01c__value", text: box.value }),
...(box.note ? [el("span", { className: "m01c__note", text: box.note })] : []),
],
}),
el("dl", {
className: "m01c__detail",
children: box.detail.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: v })]),
}),
],
});
if (open.has(box.id)) node.open = true;
node.addEventListener("toggle", () => (node.open ? open.add(box.id) : open.delete(box.id)));
if (editing) node.append(...editView(box.id));
const expr = formulaOf(box);
if (expr) node.append(noteView(expr));
if (box.logic) node.append(subView(box.logic));
return node;
};
/** 이 상자가 풀어 볼 식 — 호표 줄의 수량 · 중간 값·덧줄의 식 */
const formulaOf = (box: FlowBox): string => {
const [kind, at] = box.id.split(":");
const i = Number(at);
if (kind === "수량") return (row.호표 ?? [])[i]?.수량 ?? "";
if (kind === "중간") return (row.중간 ?? [])[i]?.식 ?? "";
if (kind === "덧줄") return (row.덧줄 ?? [])[i]?.식 ?? "";
return "";
};
/** 쉬운 말 풀이 + 원문 표 미리보기 — 펼칠 때 한 번만 채움(표는 서버에서 읽음) */
const noteView = (expr: string): HTMLElement => {
const body = el("div", { className: "m01c__note-body" });
const node = el("details", {
className: "m01c__sub",
children: [el("summary", { text: tx("Note_Plain") }), body],
});
node.addEventListener("toggle", () => {
if (!node.open || body.childElementCount) return;
buildNote(body, {
expr,
middles: row.중간 ?? [],
ctx: { values, middle: answer?.ok ? (answer.middle ?? {}) : {} },
});
});
return node;
};
/** 로직이 부르는 로직 — 펼칠 때 한 번 읽어 그 호표를 상자 안에 보임 */
const subView = (key: string): HTMLElement => {
const body = el("div", { className: "m01c__sub-body" });
const node = el("details", {
className: "m01c__sub",
children: [el("summary", { text: `${tx("Flow_Sub")} · ${key}` }), body],
});
const fill = (sub: LogicRow): void => {
body.replaceChildren(
el("p", { className: "m01c__muted", text: `${sub.이름} (${sub.결과단위}) · ${sub.출처}` }),
el("p", { className: "m01c__muted", text: tx("Flow_SubLines") }),
el("ul", {
children: (sub.호표 ?? []).map((item) =>
el("li", { text: `${item.이름 ?? item.요소} · ${item.수량}` }),
),
}),
);
};
node.addEventListener("toggle", () => {
if (!node.open || body.childElementCount) return;
const had = subs.get(key);
if (had) {
fill(had);
return;
}
body.replaceChildren(el("p", { className: "m01c__muted", text: tx("Note_Searching") }));
void fetchLogic(key)
.then((deep) => {
subs.set(key, deep.logic);
fill(deep.logic);
})
.catch(() =>
body.replaceChildren(el("p", { className: "m01c__bad", text: tx("Load_Failed") })),
);
});
return node;
};
/* ── 상자에서 고치기 ──────────────────────────────────────────────── */
/** 글 칸 하나 — 고치면 그 자리에서 줄을 바꾸고 계산만 다시(다시 그려도 글쇠 자리가 남게 `data-at`) */
const editBox = (at: string, label: string, value: string, set: (v: string) => void) => {
const input = el("input", {
className: "m01c__input",
attrs: { type: "text", "data-at": at },
});
input.value = value;
input.addEventListener("input", () => {
set(input.value);
touched();
});
return el("label", {
className: "m01c__edit",
children: [el("span", { className: "m01c__muted", text: label }), input],
});
};
const dropButton = (onClick: () => void): HTMLElement =>
createButton({
label: tx("DeleteRow"),
variant: "ghost",
onClick: () => {
onClick();
ctx.onChange?.();
draw();
},
});
const editView = (id: string): HTMLElement[] => {
const [kind, at] = id.split(":");
const i = Number(at);
if (kind === "입력") {
const spec = (row.입력 ?? [])[i];
if (!spec) return [];
return [
el("div", {
className: "m01c__edits",
children: [
editBox(id + ":1", tx("Head_Name"), spec.이름, (v) => (spec.이름 = v)),
editBox(id + ":2", tx("Inputs_Unit"), spec.단위 ?? "", (v) => {
if (v.trim()) spec.단위 = v;
else delete spec.단위;
}),
editBox(id + ":3", tx("Inputs_Choices"), (spec.고르기 ?? []).join(", "), (v) => {
const items = v
.split(",")
.map((x) => x.trim())
.filter(Boolean);
// 모두 수면 수로 — 글 "35" 로 적으면 표의 35 와 안 맞음
if (items.length)
spec.고르기 = items.every((x) => !Number.isNaN(Number(x)))
? items.map(Number)
: items;
else delete spec.고르기;
}),
dropButton(() => (row.입력 ?? []).splice(i, 1)),
],
}),
];
}
if (kind === "수량") {
const line = (row.호표 ?? [])[i];
if (!line) return [];
return [
el("div", {
className: "m01c__edits",
children: [
editBox(id + ":1", tx("Ho_Element"), line.요소, (v) => (line.요소 = v)),
editBox(id + ":2", tx("Ho_Name"), line.이름 ?? "", (v) => (line.이름 = v)),
editBox(id + ":3", tx("Ho_Unit"), line.단위 ?? "", (v) => (line.단위 = v)),
editBox(id + ":4", tx("Edit_Qty"), line.수량, (v) => (line.수량 = v)),
dropButton(() => (row.호표 ?? []).splice(i, 1)),
],
}),
];
}
const list: NamedFormula[] | undefined =
kind === "중간" ? row.중간 : kind === "덧줄" ? row.덧줄 : undefined;
if (!list?.[i]) return [];
const item = list[i];
return [
el("div", {
className: "m01c__edits",
children: [
editBox(id + ":1", tx("Head_Name"), item.이름, (v) => (item.이름 = v)),
editBox(id + ":2", tx("Formula"), item.식, (v) => (item.식 = v)),
dropButton(() => list.splice(i, 1)),
],
}),
];
};
/** 칸 아래 「줄 더하기」 — 고치기를 켰을 때만 */
const addButton = (kind: FlowKind): HTMLElement | null => {
if (!editing) return null;
const add = (label: string, push: () => void): HTMLElement =>
createButton({
label,
variant: "ghost",
onClick: () => {
push();
ctx.onChange?.();
draw();
},
});
if (kind === "입력")
return add(tx("Edit_AddInput"), () => void (row.입력 ??= []).push({ 이름: "" }));
if (kind === "중간")
return add(tx("Edit_AddMiddle"), () => void (row.중간 ??= []).push({ 이름: "", 식: "" }));
if (kind === "수량")
return add(
tx("Edit_AddHo"),
() =>
void (row.호표 ??= []).push({
종류: "인력",
요소: "",
이름: "",
단위: "인",
수량: "",
비목: "노무비",
} as HoLine),
);
if (kind === "덧줄")
return add(
tx("Edit_AddExtra"),
() => void (row.덧줄 ??= []).push({ 이름: "", 식: "", 비목: "재료비", 출처: "" }),
);
return null;
};
/* ── 칸·다시 그리기 ───────────────────────────────────────────────── */
const columnView = (column: FlowColumn): HTMLElement => {
const head = el("h4", { className: "m01c__col-head", text: title(column.kind) });
// 상자가 많으면 비목으로 묶어 접음 — 줄이 많은 로직도 한 화면에(비목 없는 줄은 「그 밖」)
const costOf = (box: FlowBox): string => box.cost ?? tx("Flow_Other");
const costs = [...new Set(column.boxes.map(costOf))];
const body =
column.boxes.length > 6 && costs.length > 1 && !editing
? costs.map((cost) =>
el("details", {
className: "m01c__group",
attrs: { open: "" },
children: [
el("summary", { text: cost }),
...column.boxes.filter((b) => costOf(b) === cost).map(boxView),
],
}),
)
: column.boxes.map(boxView);
const add = addButton(column.kind);
return el("section", {
className: "m01c__col",
children: [head, ...body, ...(add ? [add] : [])],
});
};
/** 다시 그려도 적던 칸으로 돌아감 — `data-at` 이 같은 칸에 글쇠와 자리를 되돌림 */
const keepFocus = (paint: () => void): void => {
const was = document.activeElement as HTMLInputElement | null;
const at = was?.dataset?.at;
const start = at ? was?.selectionStart : null;
paint();
if (!at) return;
const next = host.querySelector<HTMLInputElement>(`[data-at="${CSS.escape(at)}"]`);
if (!next) return;
next.focus();
if (start !== null && start !== undefined) next.setSelectionRange(start, start);
};
/** 고치기를 켜면 비어 있어 빠진 칸(표 찾기·덧줄)도 세움 — 거기에 줄을 더할 수 있게 */
const withEmpty = (columns: FlowColumn[]): FlowColumn[] => {
if (!editing || !isMoney(row)) return columns;
const out = [...columns];
const put = (kind: FlowKind, before: FlowKind): void => {
if (out.some((c) => c.kind === kind)) return;
const at = out.findIndex((c) => c.kind === before);
out.splice(at < 0 ? out.length : at, 0, { kind, boxes: [] });
};
put("중간", "수량");
put("덧줄", "비목");
return out;
};
const redraw = (): void => {
keepFocus(() => {
const columns = withEmpty(buildFlow(row, answer, one.prices, values));
const nodes: HTMLElement[] = [];
for (const column of columns.slice(1)) {
nodes.push(el("span", { className: "m01c__arrow", text: "›" }), columnView(column));
}
rest.replaceChildren(...nodes);
});
const reason = answer && !answer.ok ? answer.reason : "";
stopped.textContent = reason ? `${tx("Calc_Stopped")} — ${plainReason(reason)}` : "";
stopped.hidden = !reason;
};
const run = (): void => {
const inputs: Record<string, unknown> = {};
for (const spec of row.입력 ?? []) {
const raw = (values[spec.이름] ?? "").trim();
if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤
const option = spec.고르기?.find((o) => String(o) === raw);
inputs[spec.이름] =
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
}
// 고친 줄·바꿔 본 재료는 그 줄을 같이 보냄 — 파일에 안 씀
const swapped = swappedRow(row, swaps);
const draft = swapped ?? (ctx.editable ? row : undefined);
void runCalc({ key: row.키, inputs, ...(draft ? { row: draft, file: one.file } : {}) })
.then((got) => {
answer = got;
redraw();
})
.catch((error: unknown) => {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
});
};
const later = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(run, 250);
};
/* ── 틀 ───────────────────────────────────────────────────────────── */
const bar = el("div", { className: "m01c__edit-bar" });
const fillBar = (): void => {
if (ctx.editable) {
bar.replaceChildren(
createButton({
label: editing ? tx("Edit_Off") : tx("Edit_On"),
variant: editing ? "filled" : "ghost",
onClick: () => {
editing = !editing;
draw();
},
}),
);
return;
}
bar.replaceChildren(
el("span", { className: "m01c__muted", text: tx("Edit_OwnOnly") }),
createButton({
label: tx("Edit_Copy"),
variant: "ghost",
onClick: () => (ctx.onCopy ? ctx.onCopy() : showToast(tx("Edit_CopySoon"), "info")),
}),
);
};
const draw = (): void => {
fillBar();
host.replaceChildren(
el("div", {
className: "m01c",
children: [
el("div", {
className: "m01c__head",
children: [
el("h3", { text: `${row.원문번호} ${row.이름}` }),
el("span", { className: "m01c__muted", text: `${row.결과단위} · ${row.출처}` }),
bar,
],
}),
...(one.reasons.length
? [el("p", { className: "m01c__bad", text: one.reasons.join(" · ") })]
: []),
el("ul", {
className: "m01c__muted",
children: guideLines().map((t) => el("li", { text: t })),
}),
stopped,
...(hasPickable(row)
? [
el("details", {
attrs: { open: "" },
children: [el("summary", { text: tx("Mat_Title") }), picker],
}),
]
: []),
el("div", { className: "m01c__flow", children: [inputColumn(), rest] }),
],
}),
);
redraw();
};
/**
* 설계 값 칸 — `draw()` 에서만 다시 세움(계산 때마다 다시 그리는 오른쪽과 달리
* 적던 값과 글쇠 자리가 안 날아감). 고치기를 켜면 칸 이름·단위·고르기도 여기서 고침.
*/
const inputColumn = (): HTMLElement => {
const fields = (row.입력 ?? []).map((spec, i) => {
let control: HTMLElement;
if (spec.고르기?.length) {
control = createSelectField({
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
value: values[spec.이름] ?? "",
compact: true,
onChange: (v) => {
values[spec.이름] = v;
later();
},
}).root;
} else {
const box = el("input", {
className: "m01c__input",
attrs: { type: "text", inputmode: "decimal" },
});
box.placeholder = spec.범위 ? `${spec.범위[0]} ∼ ${spec.범위[1]}` : tx("Enter_Value");
box.value = values[spec.이름] ?? "";
box.addEventListener("input", () => {
values[spec.이름] = box.value;
later();
});
control = box;
}
return el("label", {
className: "m01c__field",
children: [
el("span", {
className: "m01c__label",
text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름,
}),
control,
...(editing ? editView(`입력:${i}`) : []),
],
});
});
const add = addButton("입력");
return el("section", {
className: "m01c__col m01c__col--input",
children: [
el("h4", { className: "m01c__col-head", text: title("입력") }),
...(fields.length
? fields
: [el("p", { className: "m01c__muted", text: tx("Flow_NoInputs") })]),
...(add ? [add] : []),
],
});
};
draw();
run();
}