Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
562 lines
19 KiB
TypeScript
562 lines
19 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_LogicLab.ts
|
|
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 단가산출 상세(호표) · 시험 계산 / 오른쪽 흐름도
|
|
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안
|
|
*
|
|
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
|
|
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
|
|
* ========================================================================== */
|
|
|
|
import { t as L } from "@ui/ui_template_locale";
|
|
import {
|
|
createButton,
|
|
el,
|
|
hideLoadingOverlay,
|
|
showConfirmDialog,
|
|
showLoadingOverlay,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import {
|
|
ApiError,
|
|
fetchLogic,
|
|
fetchLogicFiles,
|
|
fetchLogics,
|
|
fetchLogicSubs,
|
|
saveFiles,
|
|
type CalcLine,
|
|
type TextAnswer,
|
|
type ElementBrief,
|
|
type LogicRow,
|
|
type LogicSummary,
|
|
type SaveFile,
|
|
} from "./M01_MasterData_UI_Logic_Api";
|
|
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
|
|
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
|
|
import type { SideHandle } from "./M01_MasterData_UI_Side";
|
|
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
|
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,
|
|
emptyFlow,
|
|
examplesOf,
|
|
fetchFlow,
|
|
sameShape,
|
|
withExamples,
|
|
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 = 지움 */
|
|
interface Draft {
|
|
/** 구분(원문 + 부문) — 목록 id 를 세움 */
|
|
sub: string;
|
|
file: string;
|
|
version: string;
|
|
origKey: string | null;
|
|
row: LogicRow | null;
|
|
}
|
|
|
|
interface Opened extends Draft {
|
|
id: string;
|
|
row: LogicRow | null;
|
|
original: string | null;
|
|
prices: Record<string, ElementBrief | null>;
|
|
reasons: string[];
|
|
lines: CalcLine[] | null;
|
|
text: TextAnswer | null;
|
|
/** 마지막으로 잘 받은 읽는 식 — 멈춘 뒤에도 수량 칸 조각을 이걸로 */
|
|
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" });
|
|
|
|
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
|
|
|
function loadDrafts(): Record<string, Draft> {
|
|
try {
|
|
return JSON.parse(sessionStorage.getItem(cacheKey) ?? "{}") as Record<string, Draft>;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export interface LogicHandle {
|
|
/** 밖(요소 화면)에서 이 키를 바로 엶 — 소요량·계수 표의 「쓰는 로직」 눌렀을 때. */
|
|
openKey: (key: string) => Promise<void>;
|
|
}
|
|
|
|
export async function mountM01LogicLab(
|
|
host: HTMLElement,
|
|
side: SideHandle,
|
|
openKey?: string,
|
|
): Promise<LogicHandle> {
|
|
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" });
|
|
// 로직 상세 두 칸 — 왼쪽 지금 내용 · 오른쪽 흐름도(패널 자리) · 너비 비율 기억
|
|
const split = buildSplit(editor);
|
|
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
|
|
const calc = el("div", { className: "m01lab__calc" });
|
|
const filterHost = el("div");
|
|
const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") });
|
|
let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음
|
|
const pending = el("span", { className: "m01-logic__muted" });
|
|
const list = buildList((item) => void open(item.id, item.sub, item.key));
|
|
const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) });
|
|
|
|
const persist = (): void => {
|
|
try {
|
|
sessionStorage.setItem(cacheKey, JSON.stringify(drafts));
|
|
} catch {
|
|
/* 캐시가 막혀도 화면 안의 고친 것은 남음 */
|
|
}
|
|
const marks = new Map<string, ListMark>();
|
|
const extra: ListItem[] = [];
|
|
for (const [id, d] of Object.entries(drafts)) {
|
|
marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited");
|
|
if (d.origKey === null && d.row) {
|
|
extra.push({
|
|
id,
|
|
sub: d.sub,
|
|
detail: "",
|
|
key: d.row.키,
|
|
number: d.row.원문번호,
|
|
name: d.row.이름,
|
|
unit: d.row.결과단위,
|
|
source: d.row.출처,
|
|
blocked: false,
|
|
reasons: [],
|
|
});
|
|
}
|
|
}
|
|
list.setMarks(marks, extra);
|
|
const n = Object.keys(drafts).length;
|
|
pending.textContent = n ? tx("Bar_Pending", { n }) : "";
|
|
saveButton.disabled = discardButton.disabled = n === 0;
|
|
};
|
|
|
|
const touch = (): void => {
|
|
if (!opened?.row) return;
|
|
const now = JSON.stringify(opened.row);
|
|
if (opened.origKey !== null && now === opened.original) delete drafts[opened.id];
|
|
else drafts[opened.id] = pick(opened, clone(opened.row));
|
|
persist();
|
|
const inputs = JSON.stringify(opened.row.입력 ?? []);
|
|
if (inputs !== calcInputs) drawCalc();
|
|
if (isOwnLogic(opened.row)) {
|
|
// 자체 로직 — 고친 뒤 잠시 쉬면 다시 계산해 호표 소계와 텍스트 수식을 새로 그림
|
|
window.clearTimeout(recalc);
|
|
recalc = window.setTimeout(() => calc.querySelector("button")?.click(), 700);
|
|
}
|
|
};
|
|
|
|
const pick = (o: Opened, row: LogicRow | null): Draft => ({
|
|
sub: o.sub,
|
|
file: o.file,
|
|
version: o.version,
|
|
origKey: o.origKey,
|
|
row,
|
|
});
|
|
|
|
const drawCalc = (): void => {
|
|
if (!opened?.row) {
|
|
calc.replaceChildren();
|
|
return;
|
|
}
|
|
const current = opened;
|
|
calcInputs = JSON.stringify(current.row?.입력 ?? []);
|
|
buildCalc(calc, {
|
|
savedKey: current.origKey,
|
|
file: current.file,
|
|
row: current.row as LogicRow,
|
|
prices: current.prices,
|
|
dirty: () => current.id in drafts,
|
|
values: current.values,
|
|
onText: (text) => {
|
|
current.text = text;
|
|
if (text.ok) current.lastText = text;
|
|
drawEditor(); // [계산] 전 미리보기 응답도 왼쪽 이름 식 칸에 바로 비치게 — onLines 와 같은 자리
|
|
},
|
|
onLines: (lines) => {
|
|
current.lines = lines;
|
|
drawEditor();
|
|
},
|
|
});
|
|
};
|
|
|
|
const drawEditor = (): void => {
|
|
// 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산
|
|
const listing = !opened;
|
|
list.root.hidden = !listing || !loaded;
|
|
waiting.hidden = loaded;
|
|
split.root.hidden = !opened;
|
|
back.hidden = listing;
|
|
if (!opened) return;
|
|
if (!opened.row) {
|
|
editor.replaceChildren(
|
|
el("p", { className: "m01-logic__empty", text: `● ${tx("List_Deleted")}` }),
|
|
);
|
|
return;
|
|
}
|
|
const current = opened;
|
|
buildLabDetail(editor, {
|
|
row: current.row as LogicRow,
|
|
file: current.file,
|
|
reasons: current.reasons,
|
|
prices: current.prices,
|
|
lines: current.lines,
|
|
text: current.text,
|
|
lastText: current.lastText,
|
|
values: current.values,
|
|
ownHost: current.ownHost,
|
|
onChange: touch,
|
|
calcHost: calc,
|
|
flowHost: flowOf(current.row) ? current.flowHost : undefined,
|
|
admin,
|
|
});
|
|
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);
|
|
// 시험 입력 한 벌 — 열 때 왼쪽 시험 계산 입력을 흐름 변수 예시값으로
|
|
let base = withExamples(
|
|
own ?? got?.flow ?? (await emptyFlow(row.이름, row.결과단위)),
|
|
o.values,
|
|
);
|
|
if (opened !== o) return;
|
|
try {
|
|
const made = await mountLogicFlow({
|
|
split,
|
|
flow: clone(base),
|
|
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);
|
|
if (sameShape(lg, was ?? base)) {
|
|
// 예시값만 바뀜 — 고친 것 아님 · 왼쪽 시험 계산 입력에 같은 이름으로 넣고 다시 셈
|
|
base = lg;
|
|
syncLeft(o, examplesOf(lg));
|
|
return;
|
|
}
|
|
o.row.흐름 = lg;
|
|
touch();
|
|
if (!was) drawEditor(); // 이제 흐름 로직 — 왼쪽을 흐름 보기로
|
|
},
|
|
});
|
|
if (opened !== o) return made.destroy();
|
|
flowNow = made;
|
|
made.setExamples(o.values); // 띄우는 사이에 온 자동값까지
|
|
} catch (e) {
|
|
split.right.replaceChildren(
|
|
el("p", { className: "m01-logic__muted", text: tf("Failed", String(e)) }),
|
|
);
|
|
}
|
|
};
|
|
|
|
/** 흐름 예시값 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 왼쪽을 다시 그리고 셈 */
|
|
const syncLeft = (o: Opened, ex: Record<string, string>): void => {
|
|
const names = new Set((o.row?.입력 ?? []).map((x) => x.이름));
|
|
const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v);
|
|
if (!changed.length) return;
|
|
quiet = true;
|
|
for (const [k, v] of changed) o.values[k] = v;
|
|
quiet = false;
|
|
drawCalc();
|
|
calc.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
|
|
};
|
|
/** 왼쪽 시험 계산 입력 — 칸이 바뀌면 흐름 예시값으로(이 로직이 열려 있을 때만) */
|
|
let quiet = false;
|
|
const watchValues = (): Record<string, string> => {
|
|
const self: Record<string, string> = new Proxy({} as Record<string, string>, {
|
|
set(t, k, v) {
|
|
const was = t[k as string];
|
|
t[k as string] = v as string;
|
|
if (!quiet && was !== v && opened?.values === self)
|
|
flowNow?.setExamples({ [k as string]: v as string });
|
|
return true;
|
|
},
|
|
});
|
|
return self;
|
|
};
|
|
|
|
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> => {
|
|
const draft = drafts[id];
|
|
if (draft?.origKey === null) {
|
|
show({
|
|
...draft,
|
|
id,
|
|
original: null,
|
|
prices: {},
|
|
reasons: [],
|
|
lines: null,
|
|
text: null,
|
|
ownHost: ownHost(),
|
|
values: watchValues(),
|
|
flowHost: el("div"),
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
const one = await fetchLogic(key);
|
|
show({
|
|
id,
|
|
sub,
|
|
file: draft?.file ?? one.file,
|
|
version: draft?.version ?? one.version,
|
|
origKey: key,
|
|
row: draft ? (draft.row === null ? null : clone(draft.row)) : clone(one.logic),
|
|
original: JSON.stringify(one.logic),
|
|
prices: one.prices,
|
|
reasons: one.reasons,
|
|
lines: null,
|
|
text: null,
|
|
ownHost: ownHost(),
|
|
values: watchValues(),
|
|
flowHost: el("div"),
|
|
});
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
|
}
|
|
};
|
|
|
|
let allLogics: LogicSummary[] = [];
|
|
|
|
const reload = async (): Promise<void> => {
|
|
const [logics, subs] = await Promise.all([fetchLogics(), fetchLogicSubs()]);
|
|
allLogics = logics;
|
|
list.setItems(logics);
|
|
// 왼쪽 「전체」 + 구분 → 상세구분 거름 — 고르면 목록으로 돌아와 그 범위만
|
|
filterHost.replaceChildren(
|
|
side.filter({
|
|
id: "로직|",
|
|
store: "m01.filter.로직시험",
|
|
subs,
|
|
total: list.total(),
|
|
subLabel: L("M01_LaborSub"),
|
|
detailLabel: L("M01_LaborDetail"),
|
|
restore: true,
|
|
onPick: (sub, detail) => {
|
|
list.setPick(sub, detail);
|
|
if (opened) show(null);
|
|
},
|
|
}),
|
|
);
|
|
persist();
|
|
};
|
|
|
|
const onDelete = async (): Promise<void> => {
|
|
if (!opened?.row) return;
|
|
if (
|
|
!(await showConfirmDialog(
|
|
tx("Confirm_Delete", { v: opened.row.원문번호 || opened.row.키 }),
|
|
tx("Bar_Delete"),
|
|
))
|
|
)
|
|
return;
|
|
if (opened.origKey === null) {
|
|
delete drafts[opened.id];
|
|
persist();
|
|
show(null);
|
|
return;
|
|
}
|
|
drafts[opened.id] = pick(opened, null);
|
|
opened.row = null;
|
|
persist();
|
|
show(opened);
|
|
};
|
|
|
|
const onDiscard = async (): Promise<void> => {
|
|
if (!(await showConfirmDialog(tx("Confirm_Discard"), tx("Bar_Discard")))) return;
|
|
drafts = {};
|
|
persist();
|
|
const was = opened;
|
|
if (was && was.origKey !== null)
|
|
await open(logicId(was.sub, was.origKey), was.sub, was.origKey);
|
|
else show(null);
|
|
};
|
|
|
|
const onSave = async (): Promise<void> => {
|
|
const byFile = new Map<string, SaveFile>();
|
|
for (const d of Object.values(drafts)) {
|
|
const part = byFile.get(d.file) ?? { file: d.file, version: d.version, changes: [] };
|
|
if (d.origKey === null) {
|
|
if (d.row) part.changes.push({ op: "add", row: d.row });
|
|
} else if (d.row === null) part.changes.push({ op: "delete", key: d.origKey });
|
|
else part.changes.push({ op: "edit", key: d.origKey, row: d.row });
|
|
byFile.set(d.file, part);
|
|
}
|
|
if (!byFile.size) {
|
|
showToast(tx("Save_Nothing"), "info");
|
|
return;
|
|
}
|
|
showLoadingOverlay();
|
|
try {
|
|
// 키 없는 새 줄은 서버가 새 키를 줌(added) — 저장 뒤 그 로직을 엶
|
|
const saved = (await saveFiles([...byFile.values()])) as {
|
|
added?: { file: string; key: string }[];
|
|
};
|
|
const was = opened;
|
|
drafts = {};
|
|
await reload();
|
|
showToast(tx("Save_Done"), "success");
|
|
const newKey = was?.row && !was.row.키 ? saved.added?.at(-1)?.key : undefined;
|
|
const made = newKey ? allLogics.find((x) => x.키 === newKey) : 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);
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
};
|
|
|
|
const failed = (error: unknown): void => {
|
|
const detail =
|
|
error instanceof ApiError ? (error.detail as { stale?: string[]; errors?: string[] }) : null;
|
|
if (error instanceof ApiError && error.status === 409) {
|
|
showToast(tx("Save_Stale", { v: (detail?.stale ?? []).join(", ") }), "error");
|
|
} else if (error instanceof ApiError && error.status === 422) {
|
|
errors.replaceChildren(
|
|
el("strong", { text: tx("Save_Errors") }),
|
|
...(detail?.errors ?? []).map((x) => el("div", { text: x })),
|
|
);
|
|
errors.hidden = false;
|
|
} else {
|
|
showToast(error instanceof Error ? error.message : tx("Save_Failed"), "error");
|
|
}
|
|
};
|
|
|
|
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"),
|
|
variant: "ghost",
|
|
onClick: () => void onDiscard(),
|
|
});
|
|
const copyButton = createButton({
|
|
label: tl("Copy_Open"),
|
|
variant: "ghost",
|
|
onClick: () =>
|
|
opened?.origKey
|
|
? void openCopyScreen({
|
|
key: opened.origKey,
|
|
afterSave: async (newKey) => {
|
|
await reload();
|
|
await open(logicId("자체", newKey), "자체", newKey);
|
|
},
|
|
})
|
|
: showToast(tl("Copy_NeedOpen"), "info"),
|
|
});
|
|
const bar = el("div", {
|
|
className: "m01-logic__bar",
|
|
children: [
|
|
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,
|
|
saveButton,
|
|
],
|
|
});
|
|
host.replaceChildren(
|
|
el("div", {
|
|
className: "m01-logic",
|
|
children: [
|
|
el("main", {
|
|
className: "m01-logic__main",
|
|
children: [bar, errors, waiting, list.root, split.root],
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
side.logicHost.replaceChildren(filterHost);
|
|
show(null);
|
|
showLoadingOverlay();
|
|
const openKeyOn = async (key: string): Promise<void> => {
|
|
const found = allLogics.find((x) => x.키 === key);
|
|
if (found) await open(logicId(found.구분, found.키), found.구분, found.키);
|
|
else showToast(tx("Load_Failed"), "error");
|
|
};
|
|
|
|
try {
|
|
await reload();
|
|
loaded = true;
|
|
drawEditor();
|
|
if (openKey) await openKeyOn(openKey);
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
setLogicOpener((key) => void openKeyOn(key));
|
|
return { openKey: openKeyOn };
|
|
}
|