feat(M01): 로직 상세 오른쪽 흐름도 패널 잇기 · 신규 로직

- 로직을 열면 오른쪽에 흐름도 패널(mountFlowPanel) — 줄의 흐름 · 없으면 흐름 받기(/api/m01/logic/flow) · 없으면 빈 흐름 · 변환 알림 한 줄
- 패널 고침 → 줄의 흐름으로 drafts(캐시) · [저장] 한 번 · [고친 것 버리기] 함께 · 흐름 로직이면 왼쪽 보기(buildFlowView) 다시
- 패널 너비 = 패널이 알린 px(두 칸 끌기 막대는 숨김)
- 「신규 로직」 — 기본정보 칸(이름 · 원문번호 · 결과 단위 · 출처) + 빈 흐름 → 로직_자체 새 줄 · 저장 뒤 새 키 열기
- 보기만 = 시스템 관리자가 아니면

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-01 23:43:11 +09:00
co-authored by Claude Opus 5.5
parent 31cdd84737
commit abbf61971a
4 changed files with 392 additions and 12 deletions
+95 -2
View File
@@ -19,6 +19,7 @@ import {
import {
ApiError,
fetchLogic,
fetchLogicFiles,
fetchLogics,
fetchLogicSubs,
saveFiles,
@@ -37,7 +38,17 @@ import { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail"
import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal";
import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import { buildSplit } from "./M01_MasterData_UI_LogicLab_Flow";
import {
buildSplit,
emptyFlow,
fetchFlow,
mountLogicFlow,
tf,
type LogicFlow,
} from "./M01_MasterData_UI_LogicLab_Flow";
import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
import "./M01_MasterData_UI_Logic_Style.css";
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
@@ -62,8 +73,14 @@ interface Opened extends Draft {
lastText?: TextAnswer;
ownHost: HTMLElement;
values: Record<string, string>;
/** 흐름 로직의 왼쪽 「지금 내용」 자리 — 흐름도 왼쪽 보기가 그림 */
flowHost: HTMLElement;
}
/** 줄의 흐름(있으면 흐름도가 정본) */
const flowOf = (row: LogicRow | null): CfLogic | null => (row?.흐름 as CfLogic | undefined) ?? null;
const OWN_FILE = "로직_자체.json";
const cacheKey = "m01_logic_drafts_lab";
const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" });
@@ -90,6 +107,11 @@ export async function mountM01LogicLab(
let drafts = loadDrafts();
let opened: Opened | null = null;
let calcInputs = "";
/** 오른쪽 흐름도 패널 — 로직을 열 때마다 새로 */
let flowNow: LogicFlow | null = null;
const admin = await fetchSessionUser()
.then((u) => u?.role === "SYSTEM_ADMIN")
.catch(() => false);
let recalc: number | undefined;
const editor = el("div", { className: "m01-logic__editor" });
// 로직 상세 두 칸 — 왼쪽 지금 내용 · 오른쪽 흐름도(패널 자리) · 너비 비율 기억
@@ -210,14 +232,54 @@ export async function mountM01LogicLab(
ownHost: current.ownHost,
onChange: touch,
calcHost: calc,
flowHost: flowOf(current.row) ? current.flowHost : undefined,
});
flowNow?.redrawLeft();
};
/** 오른쪽 흐름도 — 줄의 흐름 · 없으면 서버 변환(흐름 받기) · 그것도 없으면 빈 흐름 */
const startFlow = async (o: Opened): Promise<void> => {
const row = o.row;
if (!row) return;
split.right.replaceChildren(el("p", { className: "m01lab-flow__wait", text: tf("Waiting") }));
const own = flowOf(row);
const got = own || o.origKey === null ? null : await fetchFlow(o.origKey, o.file);
const flow = own ?? got?.flow ?? (await emptyFlow(row.이름, row.결과단위));
if (opened !== o) return;
try {
const made = await mountLogicFlow({
split,
flow: clone(flow),
title: row.이름 || row.키,
readOnly: !admin,
notes: got?.notes ?? [],
converted: !!got?.converted,
leftHost: () => (flowOf(o.row) ? o.flowHost : null),
onChange: (lg) => {
if (!o.row || opened !== o) return;
const was = flowOf(o.row);
o.row.흐름 = lg;
touch();
if (!was) drawEditor(); // 이제 흐름 로직 — 왼쪽을 흐름 보기로
},
});
if (opened !== o) return made.destroy();
flowNow = made;
} catch (e) {
split.right.replaceChildren(
el("p", { className: "m01-logic__muted", text: tf("Failed", String(e)) }),
);
}
};
const show = (next: Opened | null): void => {
flowNow?.destroy();
flowNow = null;
opened = next;
errors.hidden = true;
drawEditor();
drawCalc();
if (next?.row) void startFlow(next);
};
const open = async (id: string, sub: string, key: string): Promise<void> => {
@@ -233,6 +295,7 @@ export async function mountM01LogicLab(
text: null,
ownHost: ownHost(),
values: {},
flowHost: el("div"),
});
return;
}
@@ -252,6 +315,7 @@ export async function mountM01LogicLab(
text: null,
ownHost: ownHost(),
values: {},
flowHost: el("div"),
});
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
@@ -332,10 +396,16 @@ export async function mountM01LogicLab(
try {
await saveFiles([...byFile.values()]);
const was = opened;
const before = new Set(allLogics.map((x) => x.키));
drafts = {};
await reload();
showToast(tx("Save_Done"), "success");
if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키);
const made =
was?.row && !was.row.키
? allLogics.find((x) => !before.has(x.키) && x.이름 === was.row?.이름)
: undefined;
if (made) await open(logicId(made.구분, made.키), made.구분, made.키);
else if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키);
else show(null);
} catch (error) {
failed(error);
@@ -360,6 +430,28 @@ export async function mountM01LogicLab(
}
};
const onNew = async (): Promise<void> => {
const units = [...new Set(allLogics.map((x) => x.결과단위).filter(Boolean))];
const info = await askBasicInfo(units.length ? units : ["원"]);
if (!info) return;
try {
const own = (await fetchLogicFiles()).find((f) => f.file === OWN_FILE);
const row: LogicRow = {
키: "",
...info,
입력: [],
중간: [],
흐름: await emptyFlow(info.이름, info.결과단위),
};
const id = logicId("자체", `새${Date.now()}`);
drafts[id] = { sub: "자체", file: OWN_FILE, version: own?.version ?? "", origKey: null, row };
persist();
await open(id, "자체", "");
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
const discardButton = createButton({
label: tx("Bar_Discard"),
@@ -386,6 +478,7 @@ export async function mountM01LogicLab(
back,
el("h2", { text: tx("Title") }),
pending,
createButton({ label: tf("New"), variant: "filled", onClick: () => void onNew() }),
copyButton,
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
discardButton,