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,
@@ -0,0 +1,105 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Basic.ts
* 「신규 로직」 기본정보 칸 — 이름 · 원문번호 · 결과 단위 · 출처(구분 = 자체). 확인하면 값을 돌려줌 · 취소면 null
* 저장은 하지 않음 — 부른 쪽이 빈 흐름도와 함께 캐시(drafts)에 새 줄로 넣고 [저장] 때 서버가 GX 새 키를 줌.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
const TEXT = {
Title: ["신규 로직", "New logic"],
Name: ["이름", "Name"],
NamePh: ["예: 돌쌓기 메쌓기(인력)", "e.g. dry stone wall (labour)"],
Number: ["원문번호", "Source number"],
NumberPh: ["예: 13-4-1 · 없으면 비움", "e.g. 13-4-1 · may be empty"],
Unit: ["결과 단위", "Result unit"],
Source: ["출처", "Source"],
Ok: ["만들기", "Create"],
Cancel: ["취소", "Cancel"],
Close: ["닫기", "Close"],
NeedName: ["이름을 넣어 주세요", "Enter a name"],
} as const;
const tb = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
export interface BasicInfo {
이름: string;
원문번호: string;
결과단위: string;
출처: string;
}
/** 기본정보 묻기 — units = 결과 단위 후보(마스터 로직의 결과단위) */
export function askBasicInfo(units: string[]): Promise<BasicInfo | null> {
return new Promise((resolve) => {
let done = false;
const finish = (v: BasicInfo | null): void => {
if (done) return;
done = true;
resolve(v);
};
const name = createInputField({ label: tb("Name"), placeholder: tb("NamePh"), compact: true });
const number = createInputField({
label: tb("Number"),
placeholder: tb("NumberPh"),
compact: true,
});
const unit = createSelectField({
label: tb("Unit"),
options: units.map((u) => ({ value: u, text: u })),
value: units[0] ?? "원",
compact: true,
});
const source = createInputField({ label: tb("Source"), value: "자체", compact: true });
const why = el("p", { className: "m01-logic__muted", attrs: { role: "alert" } });
const modal = openModal({
title: tb("Title"),
closeLabel: tb("Close"),
beforeClose: () => {
finish(null);
return true;
},
mount: (body, close) => {
const ok = (): void => {
const 이름 = name.input.value.trim();
if (!이름) {
why.textContent = tb("NeedName");
return void name.input.focus();
}
finish({
이름,
원문번호: number.input.value.trim(),
결과단위: unit.select.value,
출처: source.input.value.trim() || "자체",
});
close();
};
name.input.addEventListener("keydown", (ev) => ev.key === "Enter" && ok());
body.append(
name.root,
number.root,
unit.root,
source.root,
why,
el("div", {
className: "m01-logic__bar",
children: [
createButton({ label: tb("Ok"), size: "sm", onClick: ok }),
createButton({
label: tb("Cancel"),
variant: "ghost",
size: "sm",
onClick: () => {
finish(null);
modal.close();
},
}),
],
}),
);
},
});
});
}
@@ -38,3 +38,17 @@
color: var(--color-text-secondary);
text-align: center;
}
.m01lab-flow__bar[hidden] {
display: none;
}
.m01lab-flow__note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m01lab-flow__note[hidden] {
display: none;
}
+178 -10
View File
@@ -1,19 +1,31 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Flow.ts
* 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 자리(패널은 LogicLab 이 붙임)
* 너비 비율은 브라우저에 기억(localStorage · 막히면 기본 반반). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 5 · 10
* 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 패널
* 너비 — 패널이 없으면 두 칸 비율(끌기 막대 · localStorage) · 패널이 있으면 패널이 알린 너비(px · 패널 가장자리 끌기)
* 잇기(mountLogicFlow) — 패널(M02_DF_Panel) · 왼쪽 보기(LogicLab_FlowView) · 입력 값 · 다른 로직 흐름 받기
* DataFlow 파일은 import() 로 늦게(엔진이 M01 첫 묶음에 안 들어가게). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 2 · 5 · 6 · 7
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { API_BASE_URL } from "@config/config_frontend";
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
import type { CfValues } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval";
import "./M01_MasterData_UI_LogicLab_Flow.css";
const TEXT = {
Resize: ["두 칸 너비 — 끌어서 조절", "Drag to resize the two columns"],
Waiting: ["흐름도 자리", "Flow diagram"],
New: ["신규 로직", "New logic"],
Waiting: ["흐름도 불러오는 중", "Loading the flow diagram"],
Converted: [
"옛 식을 흐름도로 바꿔 보임 — 저장하면 흐름도가 정본",
"Converted from the old formula — saving makes the flow the master",
],
Failed: ["흐름도를 못 띄움 — {v}", "Couldn't open the flow diagram — {v}"],
} as const;
export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
export const tf = (key: keyof typeof TEXT, v = ""): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
const STORE = "m01.logic.flowRatio";
const MIN = 0.25;
@@ -34,14 +46,13 @@ export interface SplitHandle {
root: HTMLElement;
/** 오른쪽 칸 — 흐름도 패널을 붙이는 곳 */
right: HTMLElement;
/** 패널이 정한 너비(px) — 끌기 막대는 숨김(패널 가장자리로 조절) · null = 비율로 돌아감 */
setRight(px: number | null): void;
}
/** 두 칸 만들기 — left 는 지금 화면 그대로 왼쪽에 둠 */
export function buildSplit(left: HTMLElement): SplitHandle {
const right = el("div", {
className: "m01lab-flow__right",
children: [el("p", { className: "m01lab-flow__wait", text: tf("Waiting") })],
});
const right = el("div", { className: "m01lab-flow__right" });
const bar = el("div", {
className: "m01lab-flow__bar",
attrs: {
@@ -55,8 +66,12 @@ export function buildSplit(left: HTMLElement): SplitHandle {
const leftCol = el("div", { className: "m01lab-flow__left", children: [left] });
const root = el("div", { className: "m01lab-flow", children: [leftCol, bar, right] });
let ratio = readRatio();
let panelPx: number | null = null;
const apply = (): void => {
root.style.gridTemplateColumns = `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)`;
root.style.gridTemplateColumns =
panelPx === null
? `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)`
: `minmax(0, 1fr) ${panelPx}px`;
};
const keep = (): void => {
try {
@@ -90,6 +105,159 @@ export function buildSplit(left: HTMLElement): SplitHandle {
apply();
keep();
});
const setRight = (px: number | null): void => {
panelPx = px;
bar.hidden = px !== null;
apply();
};
apply();
return { root, right };
return { root, right, setRight };
}
/* ── 흐름 받기 · 잇기 ─────────────────────────────────────────────────── */
export interface FlowAnswer {
flow: CfLogic;
converted: boolean;
notes: string[];
}
/** 마스터 로직의 흐름 — 줄에 흐름이 있으면 그대로 · 없으면 서버가 옛 식을 바꿔 줌 · 길이 없거나 못 받으면 null */
export async function fetchFlow(key: string, file = ""): Promise<FlowAnswer | null> {
try {
const q = new URLSearchParams({ key, ...(file ? { file } : {}) });
const r = await fetch(`${API_BASE_URL}/m01/logic/flow?${q}`, { credentials: "include" });
if (!r.ok) return null;
const got = (await r.json()) as Partial<FlowAnswer>;
return got.flow ? { flow: got.flow, converted: !!got.converted, notes: got.notes ?? [] } : null;
} catch {
return null;
}
}
const loadFlowMods = () =>
Promise.all([
import("../M02_MasterTemplete/DataFlow/M02_DF_Panel"),
import("./M01_MasterData_UI_LogicLab_FlowView"),
import("../M02_MasterTemplete/DataFlow/M02_DF_Model"),
import("../M02_MasterTemplete/DataFlow/M02_DF_Eval"),
import("../M02_MasterTemplete/DataFlow/M02_DF_Api"),
import("../M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail"),
]).then(([panel, view, model, ev, api, detail]) => ({ panel, view, model, ev, api, detail }));
/** 빈 흐름 — 신규 로직 · 흐름을 못 받은 로직 */
export async function emptyFlow(name: string, unit: string): Promise<CfLogic> {
return (await import("../M02_MasterTemplete/DataFlow/M02_DF_Model")).newLogic(name, unit);
}
export interface LogicFlowOpts {
split: SplitHandle;
flow: CfLogic;
title: string;
readOnly: boolean;
/** 변환 알림 — 패널 위 한 줄 */
notes: string[];
converted: boolean;
/** 흐름 로직이면 왼쪽 「지금 내용」 자리(LogicLab_Detail 의 flowHost) */
leftHost(): HTMLElement | null;
onChange(lg: CfLogic): void;
}
export interface LogicFlow {
/** 밖에서 바꿈(고친 것 버리기) — 패널 · 왼쪽 함께 */
set(lg: CfLogic): void;
/** 왼쪽만 다시(왼쪽 화면을 새로 그린 뒤) */
redrawLeft(): void;
destroy(): void;
}
/** 오른쪽 패널을 띄우고 왼쪽 보기 · 입력 값 · 다른 로직을 이음 */
export async function mountLogicFlow(opts: LogicFlowOpts): Promise<LogicFlow> {
const M = await loadFlowMods();
const host = el("div", { className: "m01lab-flow__panel" });
const note = el("p", { className: "m01lab-flow__note" });
const notes = [...(opts.converted ? [tf("Converted")] : []), ...opts.notes];
note.textContent = notes.join(" · ");
note.hidden = !notes.length;
opts.split.right.replaceChildren(note, host);
let lg = opts.flow;
let values: CfValues = {};
let valueSig = "";
let logics: Record<string, CfLogic> = {};
let logicSig = "";
let gone = false;
/** 다른 로직 — 흐름도 저장본(DF-) · 마스터 로직(흐름 받기) */
const getLogic = async (key: string): Promise<CfLogic | null> =>
key.startsWith("DF-") ? M.api.cfGet(key) : ((await fetchFlow(key))?.flow ?? null);
const left = (): void => {
const at = opts.leftHost();
if (!at || gone) return;
M.view.buildFlowView(at, lg, values, { logics });
};
/** 입력 값 · 다른 로직 — 물음이 바뀐 때만 다시 받음 · 받으면 왼쪽 다시 */
const refresh = (): void => {
let ports: Record<string, Record<string, unknown>> = {};
try {
ports = M.ev.evalLogic(lg, values, { logics }).ports;
} catch {
/* 셈이 막혀도 물음은 보냄 */
}
const pill = (p: { box: string; name: string }): unknown => ports[p.box]?.[p.name] ?? null;
const asks = M.detail.queriesOf(lg, pill, M.model.elementPort);
const sig = JSON.stringify(asks.map((a) => a.q));
if (sig !== valueSig) {
valueSig = sig;
void M.detail.fetchValues(M.api.cfValues, lg, pill, M.model.elementPort).then(
(got) => {
if (sig !== valueSig || gone) return;
values = got.values;
left();
refresh(); // 받은 값으로 조건 찾기 값이 바뀌면 한 번 더
},
() => undefined,
);
}
const keys = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? ""));
if (keys !== logicSig) {
logicSig = keys;
void M.detail.loadLogics(lg, getLogic).then((got) => {
if (keys !== logicSig || gone) return;
logics = got.logics;
left();
});
}
};
const panel = M.panel.mountFlowPanel(host, {
logic: lg,
title: opts.title,
readOnly: opts.readOnly,
logics: getLogic,
onResize: (px) => opts.split.setRight(px),
onChange: (next) => {
lg = next;
opts.onChange(next);
left();
refresh();
},
});
left();
refresh();
return {
set: (next) => {
lg = next;
panel.set(next);
left();
refresh();
},
redrawLeft: left,
destroy: () => {
gone = true;
panel.destroy();
opts.split.setRight(null);
opts.split.right.replaceChildren();
},
};
}