Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
245 lines
8.4 KiB
TypeScript
245 lines
8.4 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_Page.ts
|
|
* 마스터 요소 화면 — 좌측 도킹 패널(그룹 탭 7 · 파일 고르기) / 우측 요소 표 · 표 목록
|
|
*
|
|
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
|
|
* 자동저장 없음. 로직 그룹은 다음 화면.
|
|
* ========================================================================== */
|
|
|
|
import "@ui/ui_template_workflow_layout.css";
|
|
import { ROUTES } from "@config/config_frontend";
|
|
import {
|
|
createButton,
|
|
el,
|
|
hideLoadingOverlay,
|
|
showLoadingOverlay,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import { t as L } from "@ui/ui_template_locale";
|
|
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
|
import { navigateTo } from "../A00_Common/router";
|
|
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
|
import {
|
|
fetchFiles,
|
|
fetchGroups,
|
|
saveFiles,
|
|
type FileInfo,
|
|
type GroupInfo,
|
|
} from "./M01_MasterData_Api_Fetch";
|
|
import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft";
|
|
import { renderRows } from "./M01_MasterData_UI_Rows";
|
|
import { renderTables } from "./M01_MasterData_UI_Tables";
|
|
import "./M01_MasterData_UI_Style.css";
|
|
|
|
const TABLE_GROUPS = ["소요량", "계수"];
|
|
const fileLabel = (file: string): string => file.replace(/^[^_]+_/, "").replace(/\.json$/, "");
|
|
|
|
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;
|
|
}
|
|
showLoadingOverlay();
|
|
let groups: GroupInfo[] = [];
|
|
try {
|
|
groups = (await fetchGroups()).filter((g) => g.group !== "로직");
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
root.innerHTML = "";
|
|
root.append(buildPage(groups));
|
|
}
|
|
|
|
function buildPage(groups: GroupInfo[]): HTMLElement {
|
|
let group = "";
|
|
let current: FileInfo | null = null;
|
|
let query = "";
|
|
let dispose = (): void => {};
|
|
|
|
/* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */
|
|
const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") });
|
|
const search = el("input", {
|
|
className: "m01-master__search",
|
|
attrs: { type: "search", placeholder: L("M01_Search") },
|
|
});
|
|
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, search, summary, drop, save],
|
|
});
|
|
|
|
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 openFile = (file: FileInfo): void => {
|
|
dispose();
|
|
current = file;
|
|
title.textContent = `${group} · ${fileLabel(file.file)}`;
|
|
const view = TABLE_GROUPS.includes(group) ? renderTables : renderRows;
|
|
dispose = view(body, file.file, query);
|
|
showNotice(isStale(file.file, 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", () => {
|
|
if (!window.confirm(L("M01_DiscardConfirm"))) return;
|
|
discard();
|
|
showNotice([]);
|
|
});
|
|
|
|
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];
|
|
};
|
|
|
|
/* --- 좌측: 그룹 탭 + 파일 --- */
|
|
const tabs = el("div", { className: "m01-master__tabs" });
|
|
const files = el("div", { className: "m01-master__files" });
|
|
|
|
const reloadFiles = async (): Promise<void> => {
|
|
if (!group) return;
|
|
let list: FileInfo[] = [];
|
|
try {
|
|
list = await fetchFiles(group);
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
|
}
|
|
files.replaceChildren(
|
|
...list.map((f) => {
|
|
const button = el("button", {
|
|
className: "m01-master__file",
|
|
attrs: { type: "button" },
|
|
children: [
|
|
el("span", { text: fileLabel(f.file) }),
|
|
el("span", { className: "m01-master__muted", text: f.rows.toLocaleString("ko-KR") }),
|
|
],
|
|
});
|
|
button.classList.toggle("is-active", f.file === current?.file);
|
|
button.addEventListener("click", () => {
|
|
files.querySelector(".is-active")?.classList.remove("is-active");
|
|
button.classList.add("is-active");
|
|
openFile(f);
|
|
});
|
|
return button;
|
|
}),
|
|
);
|
|
const again = list.find((f) => f.file === current?.file);
|
|
if (again) openFile(again);
|
|
};
|
|
|
|
for (const g of groups) {
|
|
const tab = el("button", {
|
|
className: "m01-master__tab",
|
|
attrs: { type: "button" },
|
|
children: [
|
|
el("span", { text: g.group }),
|
|
el("span", { className: "m01-master__muted", text: String(g.files) }),
|
|
],
|
|
});
|
|
tab.addEventListener("click", () => {
|
|
tabs.querySelector(".is-active")?.classList.remove("is-active");
|
|
tab.classList.add("is-active");
|
|
group = g.group;
|
|
current = null;
|
|
dispose();
|
|
body.replaceChildren();
|
|
title.textContent = L("M01_PickFile");
|
|
void reloadFiles();
|
|
});
|
|
tabs.append(tab);
|
|
}
|
|
|
|
const logicTab = el("button", {
|
|
className: "m01-master__tab",
|
|
attrs: { type: "button" },
|
|
text: L("M01_LogicTab"),
|
|
});
|
|
logicTab.addEventListener("click", () => {
|
|
tabs.querySelector(".is-active")?.classList.remove("is-active");
|
|
logicTab.classList.add("is-active");
|
|
group = "";
|
|
current = null;
|
|
dispose();
|
|
files.replaceChildren();
|
|
body.replaceChildren();
|
|
title.textContent = L("M01_LogicTab");
|
|
void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(body));
|
|
});
|
|
tabs.append(logicTab);
|
|
|
|
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: [head, notice, body] })],
|
|
});
|
|
const overlays = createWorkflowOverlays({
|
|
title: L("M01_Title"),
|
|
optionsContent: el("div", { className: "m01-master__side", children: [tabs, files] }),
|
|
showProjectName: false,
|
|
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
|
|
});
|
|
layout.append(
|
|
el("div", { className: "ui-workflow-layout__body", children: [main] }),
|
|
overlays.root,
|
|
);
|
|
return layout;
|
|
}
|