feat(M02-일위대가조합): 조합 다이어그램 - 로직별 넣을 값 → 로직 카드 → 비용 구분 칸 · 미리 보기와 값 한 벌 · 카드 누르면 단가산출 로직 화면

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
2026-10-02 09:34:08 +09:00
co-authored by Claude Sonnet 5.5
parent 7418571b63
commit af201fbbd3
7 changed files with 999 additions and 2 deletions
@@ -13,6 +13,7 @@ import {
previewCombo,
type Combo,
} from "./M02_MasterTemplete_Labor_Api";
import { buildDiagramData, type DiagramData } from "./M02_MasterTemplete_Labor_Diagram";
import { tc, type ComboTextKey } from "./M02_MasterTemplete_Labor_Text";
const COSTS = ["노무비", "재료비", "경비"] as const;
@@ -35,6 +36,7 @@ function inputCell(
key: string,
values: Record<string, Record<string, string>>,
onEdit: () => void,
onInput: () => void,
): HTMLElement {
const mine = (values[key] ??= {});
const value = mine[input.이름] ?? initial(input);
@@ -48,6 +50,7 @@ function inputCell(
control.setAttribute("data-input", `${key}|${input.이름}`);
const edit = (): void => {
mine[input.이름] = control.value;
onInput();
};
control.addEventListener("input", edit);
control.addEventListener("change", () => {
@@ -61,6 +64,12 @@ export interface PreviewHandle {
root: HTMLElement;
/** 담은 로직이 바뀐 뒤 입력 칸을 다시 그림 */
redraw: () => Promise<void>;
/** 다이어그램이 쓰는 값 한 벌 — 넣은 값 · 로직 이름 · 결과(저장 안 함) */
data: () => DiagramData;
/** 입력 칸이 바뀌거나 결과가 나올 때마다 부름(돌려받은 함수 = 그만 받기) */
onUpdate: (fn: () => void) => () => void;
/** 그 입력 칸으로 가서 깜빡임 */
focusInput: (key: string, name: string) => void;
}
export function buildPreview(combo: () => Combo): PreviewHandle {
@@ -69,6 +78,11 @@ export function buildPreview(combo: () => Combo): PreviewHandle {
const inputs = new Map<string, LogicInput[]>();
/** 로직마다 자동값은 한 번만 받음 */
const asked = new Set<string>();
/** 다이어그램에 넘길 것 — 로직 이름 · 결과 단위 · 마지막 미리 보기 줄 */
const meta = new Map<string, { name: string; unit: string }>();
let lines: Parameters<typeof buildDiagramData>[0]["lines"] = null;
const listeners = new Set<() => void>();
const notify = (): void => listeners.forEach((f) => f());
const form = el("div", { className: "m01-logic__stack" });
const out = el("div", {
className: "m01-master__grid-wrap",
@@ -85,6 +99,8 @@ export function buildPreview(combo: () => Combo): PreviewHandle {
const run = async (): Promise<void> => {
out.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" }));
lines = null;
notify();
const send: Record<string, Record<string, unknown>> = {};
for (const [key, defs] of inputs) {
send[key] = {};
@@ -97,6 +113,8 @@ export function buildPreview(combo: () => Combo): PreviewHandle {
let got;
try {
got = await previewCombo(combo(), send);
lines = got.줄;
notify();
} catch (error) {
out.replaceChildren(
el("p", { text: `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}` }),
@@ -160,6 +178,7 @@ export function buildPreview(combo: () => Combo): PreviewHandle {
mine[i.이름] = String(got);
}
inputs.set(row.로직, one.logic.입력);
meta.set(row.로직, { name: one.logic.이름, unit: one.logic.결과단위 ?? "" });
blocks.push(
el("div", {
className: "m01lab__info",
@@ -168,7 +187,9 @@ export function buildPreview(combo: () => Combo): PreviewHandle {
className: "m01lab__cell--wide",
text: `${row.로직} ${one.logic.이름}`,
}),
...one.logic.입력.map((i) => inputCell(i, row.로직, values, () => void run())),
...one.logic.입력.map((i) =>
inputCell(i, row.로직, values, () => void run(), notify),
),
],
}),
);
@@ -177,6 +198,7 @@ export function buildPreview(combo: () => Combo): PreviewHandle {
}
}
form.replaceChildren(...blocks);
notify();
if (blocks.length)
await run(); // 열자마자 한 번 미리 보기 계산
else out.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Prev_Wait") }));
@@ -195,5 +217,28 @@ export function buildPreview(combo: () => Combo): PreviewHandle {
out,
],
});
return { root, redraw };
const data = (): DiagramData =>
buildDiagramData({
rows: combo().담은로직.map((r) => ({
key: r.로직,
name: meta.get(r.로직)?.name ?? r.로직,
unit: meta.get(r.로직)?.unit ?? "",
inputs: (inputs.get(r.로직) ?? []).map((i) => ({ name: i.이름, unit: i.단위 ?? "" })),
})),
values,
lines,
});
const focusInput = (key: string, name: string): void => {
const box = root.querySelector<HTMLElement>(`[data-input="${CSS.escape(`${key}|${name}`)}"]`);
if (!box) return;
box.scrollIntoView({ block: "center", behavior: "smooth" });
box.focus({ preventScroll: true });
box.classList.add("m02-labor__flash");
window.setTimeout(() => box.classList.remove("m02-labor__flash"), 1600);
};
const onUpdate = (fn: () => void): (() => void) => {
listeners.add(fn);
return () => listeners.delete(fn);
};
return { root, redraw, data, onUpdate, focusInput };
}