Files
Aislo/M01_MasterData/M01_MasterData_UI_Page.ts
T
eomsangdonandClaude Opus 5 10418ccbd4 knowledge(마스터): 소요량·계수·로직에 구분·상세구분 — 표·줄마다 칸 · 갈래 목록은 서버가 줌
- 장 파일 137 개의 표 3,028 · 로직 1,351 줄에 구분(원문 + 부문 · 「건설품셈 공통」) · 상세구분(「03장 토공사」) 칸을 직접 둠 · 파일 머리의 부문·차례는 목록 차례로 남김
- 열 차례 — 표는 키 · 원문번호 · 구분 · 상세구분 · 이름 · 기준 · 출처 · 비고 · 조건 · 범위규칙 · 값칸 · 줄 · 주 · 그룹(맨 뒤) · 로직은 소유 다음에 비고
- 서버 — GET /subs?group= 이 구분·상세구분 목록(장 차례대로) · GET /tables 가 파일을 가로질러 구분·상세구분·찾기로 거르고 쪽 나눔 · GET /logics 도 구분·상세구분으로 거름 · 새 줄은 서버가 갈래를 채움 · 죽은 book 칸 걷어냄
- check_master 가 구분·상세구분이 파일 머리·이름과 맞는지 봄 · adopt 가 빌더 줄에 갈래를 찍어 되돌리기 시험 글자까지 같음 · _틀.md · 화면 계약 · 화면 트리 · 시험 같이 고침

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
2026-09-20 16:30:48 +09:00

187 lines
6.9 KiB
TypeScript

/* =============================================================================
* M01_MasterData_UI_Page.ts
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너 여덟: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직)
* / 우측 요소 표 · 표 목록 · 로직 편집
*
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
* 자동저장 없음.
* ========================================================================== */
import "@ui/ui_template_workflow_layout.css";
import { createButton, el, 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 } 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, type Pick } from "./M01_MasterData_UI_Side";
import { renderTables } from "./M01_MasterData_UI_Tables";
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;
/* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */
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 logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const elementView = el("div", {
className: "m01-master__elements",
children: [head, notice, 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 = (logic: boolean): void => {
logicHost.hidden = !logic;
elementView.hidden = logic;
};
const openFile = (pick: Pick): void => {
dispose();
showMode(false);
current = pick;
const groupTitle = pick.group === pick.label ? [pick.group] : [pick.group, pick.label];
title.textContent = groupTitle.join(" · ");
const view = TABLE_GROUPS.includes(pick.group) ? renderTables : renderRows;
dispose = view(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", () => {
if (!window.confirm(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 });
} 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];
};
/* --- 좌측: 컨테이너 여덟 --- */
const side = buildSide(openFile, () => {
dispose();
dispose = (): void => {};
current = null;
showMode(true);
if (logicMounted) return;
logicMounted = true;
void import("./M01_MasterData_UI_Logic_Page").then((m) =>
m.mountM01Logic(logicHost, side.logicHost),
);
});
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] })],
});
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),
});
layout.append(
el("div", { className: "ui-workflow-layout__body", children: [main] }),
overlays.root,
);
return layout;
}