소요량·계수의 쓰는 로직 · 조합 화면 로직 이름 · 패널 제목 세 자리를 모두 새 컨테이너(UI_LogicLab)로 돌림 · 옛 컨테이너(UI_Logic_Page)는 old_code/M01_MasterData/ 로 옮김(지우지 않음 · import 경로만 고침 · 통째 복사 아님) · 정본 GC·GF 를 화면에서 바로 고쳐 저장 · 정본 파일에 빈 줄 더하기 두 길은 사용자 확정으로 버림. 분석 = resources/master_data/ref/_분석_로직컨테이너_통합.md. typecheck 통과 · M01 시험 107 통과 · ORCA — 패널에 「단가산출 로직」 하나만 · 소요량 표 쓰는 로직 키(GF000002 · GF000051) 클릭 각각 그 로직이 새 컨테이너에서 열림 · 조합 화면 로직 이름도 같은 컨테이너로 이어짐. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_Page.ts
|
|
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직·일위대가 조합)
|
|
* / 우측 요소 표 · 표 목록 · 로직 편집
|
|
*
|
|
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
|
|
* 자동저장 없음.
|
|
* ========================================================================== */
|
|
|
|
import "@ui/ui_template_workflow_layout.css";
|
|
import {
|
|
createButton,
|
|
createInputField,
|
|
el,
|
|
showConfirmDialog,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import { t as L } from "@ui/ui_template_locale";
|
|
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
|
import { ROUTES } from "@config/config_frontend";
|
|
import { navigateTo } from "../A00_Common/router";
|
|
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
|
import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch";
|
|
import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft";
|
|
import { renderRows } from "./M01_MasterData_UI_Rows";
|
|
import {
|
|
buildSide,
|
|
fileLabel,
|
|
loadView,
|
|
saveView,
|
|
setMasterJump,
|
|
setPendingLogicKey,
|
|
takePendingLogicKey,
|
|
type MasterJump,
|
|
type Pick,
|
|
} from "./M01_MasterData_UI_Side";
|
|
import { loadTable } from "./M01_MasterData_UI_Logic_Note";
|
|
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
|
import { renderTables } from "./M01_MasterData_UI_Tables";
|
|
import type { LogicHandle } from "./M01_MasterData_UI_LogicLab";
|
|
import type { ComboHandle } from "./M01_MasterData_UI_Combo";
|
|
import "./M01_MasterData_UI_Style.css";
|
|
|
|
const TABLE_GROUPS = ["소요량", "계수"];
|
|
|
|
export async function renderM01MasterData(root: HTMLElement): Promise<void> {
|
|
const user = await fetchSessionUser().catch(() => null);
|
|
if (user?.role !== "SYSTEM_ADMIN") {
|
|
showToast(L("M01_AdminOnly"), "error");
|
|
navigateTo(ROUTES.B01_ACCOUNT);
|
|
return;
|
|
}
|
|
root.innerHTML = "";
|
|
root.append(buildPage());
|
|
}
|
|
|
|
function buildPage(): HTMLElement {
|
|
let current: Pick | null = null;
|
|
let query = "";
|
|
let dispose = (): void => {};
|
|
let logicMounted = false;
|
|
let comboHandle: ComboHandle | null = null;
|
|
|
|
/* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */
|
|
const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") });
|
|
const searchField = createInputField({ type: "search", placeholder: L("M01_Search") });
|
|
const search = searchField.input;
|
|
const summary = el("span", { className: "m01-master__summary" });
|
|
const save = createButton({ label: L("M01_Save"), variant: "filled" });
|
|
const drop = createButton({ label: L("M01_Discard"), variant: "ghost" });
|
|
const notice = el("div", { className: "m01-master__notice", attrs: { hidden: "" } });
|
|
const body = el("div", { className: "m01-master__body" });
|
|
const head = el("div", {
|
|
className: "m01-master__head",
|
|
children: [title, searchField.root, summary, drop, save],
|
|
});
|
|
const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
|
|
const comboHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
|
|
const elementView = el("div", {
|
|
className: "m01-master__elements",
|
|
children: [
|
|
head,
|
|
notice,
|
|
el("p", { className: "m01-master__hint", text: L("M01_WideHint") }),
|
|
body,
|
|
],
|
|
});
|
|
|
|
const showNotice = (nodes: (HTMLElement | string)[]): void => {
|
|
notice.replaceChildren(...nodes);
|
|
notice.hidden = nodes.length === 0;
|
|
};
|
|
const refreshBar = (): void => {
|
|
const n = totalCount();
|
|
summary.textContent = n ? L("M01_Changed").replace("{value}", String(n)) : "";
|
|
save.disabled = drop.disabled = n === 0;
|
|
};
|
|
onDraftChange(refreshBar);
|
|
refreshBar();
|
|
|
|
const showMode = (mode: "elements" | "logic" | "combo"): void => {
|
|
logicHost.hidden = mode !== "logic";
|
|
comboHost.hidden = mode !== "combo";
|
|
elementView.hidden = mode !== "elements";
|
|
};
|
|
|
|
const openFile = (pick: Pick): void => {
|
|
dispose();
|
|
showMode("elements");
|
|
if (pick.view) {
|
|
query = pick.view.q;
|
|
search.value = query;
|
|
} else saveView(pick.group, { q: query, page: 1 });
|
|
current = { ...pick, view: undefined };
|
|
const groupTitle = pick.group === pick.label ? [pick.group] : [pick.group, pick.label];
|
|
title.textContent = groupTitle.join(" · ");
|
|
dispose = TABLE_GROUPS.includes(pick.group)
|
|
? renderTables(body, pick, query, openLogicTab)
|
|
: renderRows(body, pick, query);
|
|
showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []);
|
|
};
|
|
|
|
let searchTimer: number | undefined;
|
|
search.addEventListener("input", () => {
|
|
window.clearTimeout(searchTimer);
|
|
searchTimer = window.setTimeout(() => {
|
|
query = search.value.trim();
|
|
if (current) openFile(current);
|
|
}, 300);
|
|
});
|
|
|
|
drop.addEventListener("click", async () => {
|
|
if (!(await showConfirmDialog(L("M01_DiscardConfirm")))) return;
|
|
discard();
|
|
showNotice([]);
|
|
});
|
|
|
|
/** 저장·되받기 뒤 — 지금 열린 그룹의 새 판본으로 다시 그림 */
|
|
const reloadFiles = async (): Promise<void> => {
|
|
if (!current) return;
|
|
const pick = current;
|
|
try {
|
|
const again = (await side.refresh(pick.group)).find((f) => f.file === pick.file.file);
|
|
if (again) openFile({ ...pick, file: again, view: loadView(pick.group) });
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
|
}
|
|
};
|
|
|
|
save.addEventListener("click", async () => {
|
|
save.disabled = true;
|
|
try {
|
|
const result = await saveFiles(payload());
|
|
if (result.status === 200 && "files" in result) {
|
|
discard(result.files.map((f) => f.file));
|
|
showNotice([]);
|
|
showToast(L("M01_Saved"), "success");
|
|
await reloadFiles();
|
|
} else if (result.status === 409 && "stale" in result) {
|
|
showNotice(staleNotice(result.stale));
|
|
} else if (result.status === 422 && "errors" in result) {
|
|
showNotice([
|
|
el("strong", { text: L("M01_CheckErrors") }),
|
|
...result.errors.map((e) => el("p", { text: e })),
|
|
]);
|
|
} else if ("detail" in result) {
|
|
showNotice([L("M01_SaveFailed").replace("{value}", result.detail)]);
|
|
}
|
|
} catch (error) {
|
|
showNotice([
|
|
L("M01_SaveFailed").replace("{value}", error instanceof Error ? error.message : ""),
|
|
]);
|
|
} finally {
|
|
refreshBar();
|
|
}
|
|
});
|
|
|
|
const staleNotice = (stale: string[]): (HTMLElement | string)[] => {
|
|
const again = createButton({ label: L("M01_StaleReload"), variant: "ghost" });
|
|
again.addEventListener("click", async () => {
|
|
discard(stale);
|
|
showNotice([]);
|
|
await reloadFiles();
|
|
});
|
|
return [L("M01_Stale").replace("{value}", stale.map(fileLabel).join(", ")), again];
|
|
};
|
|
|
|
let logicHandle: LogicHandle | null = null;
|
|
|
|
/** 「단가산출 로직」 탭으로 전환 — `key` 가 있으면 그 로직을 바로 엶. */
|
|
const openLogicTab = (key?: string): void => {
|
|
if (key) setPendingLogicKey(key);
|
|
dispose();
|
|
dispose = (): void => {};
|
|
current = null;
|
|
showMode("logic");
|
|
if (logicMounted) {
|
|
const pending = takePendingLogicKey();
|
|
if (pending) void logicHandle?.openKey(pending);
|
|
return;
|
|
}
|
|
logicMounted = true;
|
|
void import("./M01_MasterData_UI_LogicLab").then(async (m) => {
|
|
const pending = takePendingLogicKey();
|
|
logicHandle = await m.mountM01LogicLab(logicHost, side, pending ?? undefined);
|
|
});
|
|
};
|
|
|
|
/** 「일위대가 조합」 — 목록을 새로 받아 그림 · 로직 줄을 누르면 단가산출 로직 탭으로 */
|
|
const openComboTab = (): void => {
|
|
dispose();
|
|
dispose = (): void => {};
|
|
current = null;
|
|
showMode("combo");
|
|
if (comboHandle) {
|
|
void comboHandle.show();
|
|
return;
|
|
}
|
|
void import("./M01_MasterData_UI_Combo").then((m) => {
|
|
comboHandle = m.mountM01Combo(comboHost, side, (key) => openLogicTab(key));
|
|
void comboHandle.show();
|
|
});
|
|
};
|
|
|
|
/** 알약 → 마스터 화면 — 찾기 글을 그 키로 두고 열고, 알림줄에 「단가산출 로직으로 돌아가기」를 둠 */
|
|
const jump = async (target: MasterJump): Promise<void> => {
|
|
let group = target.kind === "element" ? target.group : "";
|
|
let file: FileInfo | undefined;
|
|
let ref = "";
|
|
if (target.kind === "table") {
|
|
const table = await loadTable(target.key);
|
|
group =
|
|
["소요량", "계수"].find((g) => side.files(g).some((f) => f.file === table?.file)) ?? "";
|
|
file = side.files(group).find((f) => f.file === table?.file);
|
|
ref = target.key;
|
|
} else {
|
|
const list = side.files(group);
|
|
file = list.find((f) => f.file === target.file) ?? list[0];
|
|
ref = target.ref;
|
|
}
|
|
if (!file) return showToast(L("M01_LoadFailed"), "error");
|
|
side.setActive(null);
|
|
openFile({ group, file, sub: "", detail: "", label: group, view: { q: ref, page: 1 } });
|
|
const back = createButton({ label: tl("Pill_Back"), variant: "ghost" });
|
|
back.addEventListener("click", () => {
|
|
showNotice([]);
|
|
openLogicTab();
|
|
});
|
|
showNotice([back]);
|
|
if (target.kind === "table") {
|
|
const key = target.key;
|
|
const seen = new MutationObserver(() => {
|
|
const row = body.querySelector<HTMLElement>(`[data-key="${CSS.escape(key)}"]`);
|
|
if (!row) return;
|
|
seen.disconnect();
|
|
row.click();
|
|
});
|
|
seen.observe(body, { childList: true, subtree: true });
|
|
window.setTimeout(() => seen.disconnect(), 8000);
|
|
}
|
|
};
|
|
setMasterJump((target) => void jump(target));
|
|
|
|
/* --- 좌측: 컨테이너 --- */
|
|
const side = buildSide(openFile, () => openLogicTab(), openComboTab);
|
|
|
|
const layout = el("div", { className: "ui-workflow-layout m01-master" });
|
|
const main = el("main", {
|
|
className: "ui-workflow-layout__main",
|
|
children: [
|
|
el("div", {
|
|
className: "m01-master__panel",
|
|
children: [elementView, logicHost, comboHost],
|
|
}),
|
|
],
|
|
});
|
|
const overlays = createWorkflowOverlays({
|
|
title: L("M01_Title"),
|
|
optionsContent: el("div", { className: "m01-master__side", children: [side.root] }),
|
|
showProjectName: false,
|
|
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
|
|
});
|
|
// 좁은 화면(공용 레이아웃 860px)에서는 패널이 목록을 덮음 — 거름을 다 고르면 접음
|
|
side.setOnPicked(() => {
|
|
if (window.matchMedia("(max-width: 860px)").matches) overlays.setTitleOpen(false);
|
|
});
|
|
layout.append(
|
|
el("div", { className: "ui-workflow-layout__body", children: [main] }),
|
|
overlays.root,
|
|
);
|
|
return layout;
|
|
}
|