정본 로직 상자 위 [본떠 만들기] 가 `POST /logic/copy`(계약 5장)를 불러 자체 로직(`GX…`)을 만들고 그 줄을 바로 엶 — 거기서 [고치기] 가 열림. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
537 lines
20 KiB
TypeScript
537 lines
20 KiB
TypeScript
/* =============================================================================
|
||
* 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();
|
||
}
|