Files
Aislo/M01_MasterData/M01_MasterData_UI_Logic_Flow.ts
T
eomsangdonandClaude Opus 5 f2052b12d3 feat(M01): 흐름 그림에서 「본떠 만들기」 로 자체 로직 만들어 고치기
정본 로직 상자 위 [본떠 만들기] 가 `POST /logic/copy`(계약 5장)를 불러
자체 로직(`GX…`)을 만들고 그 줄을 바로 엶 — 거기서 [고치기] 가 열림.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
2026-09-21 09:40:33 +09:00

537 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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);
/**
* 자체 로직인지 — 자체 파일(`로직_자체.json`)이거나 키가 `GX…`(계약 5장).
* 아직 저장 안 한 새 줄(키 "")도 고칠 수 있음 · 정본은 「본떠 만들기」 뒤에야 고침.
*/
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();
}