diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 26d914b8..537077f4 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -27,6 +27,7 @@ const FOOTERLESS_ROUTES: readonly RoutePath[] = [ ROUTES.B10_PAYMENT, ROUTES.B11_STATUS, ROUTES.Z01_MASTER_DATA, + ROUTES.M01_MASTER_DATA, ]; function bootstrap(): void { diff --git a/A00_Common/router.ts b/A00_Common/router.ts index b6c99652..547dc50f 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -61,6 +61,8 @@ const routeTable: Partial Promise>> = { (await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading, [ROUTES.Z01_MASTER_DATA]: async () => (await import("../Z01_MasterData/Z01_MasterData_UI_Page")).renderZ01MasterData, + [ROUTES.M01_MASTER_DATA]: async () => + (await import("../M01_MasterData/M01_MasterData_UI_Page")).renderM01MasterData, }; /** 로그인 여부 (토큰 존재 확인) */ diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 9f675b88..6e47a210 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -139,6 +139,11 @@ function buildSystemSettingsPanel(): HTMLElement { variant: "ghost", onClick: () => navigateTo(ROUTES.Z01_MASTER_DATA), }), + createButton({ + label: L("B01_Dashboard_MasterElements"), + variant: "ghost", + onClick: () => navigateTo(ROUTES.M01_MASTER_DATA), + }), ); return wrap; } diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts new file mode 100644 index 00000000..76e11db9 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -0,0 +1,109 @@ +/* ============================================================================= + * M01_MasterData_Api_Fetch.ts + * 마스터 요소 화면 서버 호출 — 계약 `resources/master_data/_화면_계약.md` · 시스템 관리자만 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +/** 줄·표 한 개 — 파일 그대로(한글 칸). */ +export type Row = Record; + +export interface GroupInfo { + group: string; + files: number; + rows: number; +} + +export interface FileInfo { + file: string; + book: string | null; + chapter: string; + edition: string | null; + rows: number; + version: string; +} + +export interface RowsPage { + file: string; + version: string; + total: number; + page: number; + size: number; + rows: Row[]; +} + +export interface TableHead { + 열쇠: string; + 이름: string; + 기준: string; + 출처: string; + 조건: Record; + 값칸: Record; + count: number; +} + +export interface Change { + op: "edit" | "add" | "delete"; + key?: string; + row?: Row; +} + +export interface FileChanges { + file: string; + version: string; + changes: Change[]; +} + +/** 저장 결과 — 200 은 새 판본 · 나머지는 화면이 그대로 안내할 상태. */ +export type SaveResult = + | { status: 200; files: { file: string; version: string }[] } + | { status: 409; stale: string[] } + | { status: 422; errors: string[] } + | { status: number; detail: string }; + +async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: T }> { + const response = await fetch(`${API_BASE_URL}/m01${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + return { status: response.status, body: (await response.json()) as T }; +} + +async function get(path: string, params: Record): Promise { + const query = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])); + const { status, body } = await call(`${path}?${query}`); + if (status !== 200) throw new Error(String(body.detail ?? `HTTP ${status}`)); + return body; +} + +export const fetchGroups = async (): Promise => + (await get<{ groups: GroupInfo[] }>("/groups", {})).groups; + +export const fetchFiles = async (group: string): Promise => + (await get<{ files: FileInfo[] }>(`/groups/${encodeURIComponent(group)}/files`, {})).files; + +export const fetchRows = (file: string, page: number, size: number, q: string): Promise => + get("/rows", { file, page, size, q }); + +export const fetchTables = ( + file: string, + q: string, +): Promise<{ version: string; tables: TableHead[] }> => get("/tables", { file, q }); + +export const fetchTable = ( + file: string, + key: string, +): Promise<{ version: string; table: Row & { 줄: Row[] } }> => get("/table", { file, key }); + +export async function saveFiles(files: FileChanges[]): Promise { + const { status, body } = await call>("/save", { + method: "POST", + body: JSON.stringify({ files }), + }); + const detail = body.detail as { stale?: string[]; errors?: string[] } | string | undefined; + if (status === 200) return { status, files: body.files as { file: string; version: string }[] }; + if (status === 409 && typeof detail === "object") return { status, stale: detail.stale ?? [] }; + if (status === 422 && typeof detail === "object") return { status, errors: detail.errors ?? [] }; + return { status, detail: typeof detail === "string" ? detail : JSON.stringify(detail) }; +} diff --git a/M01_MasterData/M01_MasterData_Draft.ts b/M01_MasterData/M01_MasterData_Draft.ts new file mode 100644 index 00000000..4201d541 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Draft.ts @@ -0,0 +1,161 @@ +/* ============================================================================= + * M01_MasterData_Draft.ts + * 고친 것 초안 — sessionStorage 에만 쌓임(자동저장 없음) · [저장] 때만 서버로. + * + * 요소 파일 = 줄 단위(edits · adds · deletes) · 표형 파일 = 표 하나 통째(tables). + * ========================================================================== */ + +import type { FileChanges, Row } from "./M01_MasterData_Api_Fetch"; + +/** o = 원래 줄 번호(새 줄은 null) — 노랑 칠은 이 번호의 원래 줄과 견줌. */ +export interface TableItem { + o: number | null; + r: Row; +} + +interface TableDraft { + base: Row; + items: TableItem[]; +} + +interface FileDraft { + version: string; + edits: Record; + adds: Row[]; + deletes: string[]; + tables: Record; +} + +const STORAGE_KEY = "m01_master_draft"; +const listeners = new Set<() => void>(); + +function readStore(): Record { + try { + return JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "{}") as Record; + } catch { + return {}; + } +} + +let store = readStore(); + +export const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); + +/** 구독 — 돌려받은 함수를 부르면 해제. */ +export function onDraftChange(fn: () => void): () => void { + listeners.add(fn); + return () => void listeners.delete(fn); +} + +function touch(file: string): void { + const d = store[file]; + if ( + d && + !Object.keys(d.edits).length && + !d.adds.length && + !d.deletes.length && + !Object.keys(d.tables).length + ) { + delete store[file]; + } + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } catch { + /* 저장소 막힘 — 이 탭 메모리에서만 유지 */ + } + listeners.forEach((fn) => fn()); +} + +function slot(file: string, version: string): FileDraft { + return (store[file] ??= { version, edits: {}, adds: [], deletes: [], tables: {} }); +} + +export function peek(file: string): FileDraft | undefined { + return store[file]; +} + +const countOf = (d: FileDraft): number => + Object.keys(d.edits).length + d.adds.length + d.deletes.length + Object.keys(d.tables).length; + +export const fileCount = (file: string): number => (store[file] ? countOf(store[file]) : 0); +export const totalCount = (): number => Object.values(store).reduce((n, d) => n + countOf(d), 0); + +/** 그 파일을 초안이 받은 판본과 다른 판본으로 읽었나 — 저장하면 409 로 막힐 초안. */ +export const isStale = (file: string, version: string): boolean => + store[file] !== undefined && store[file].version !== version; + +/* --- 요소 파일 --- */ +export function editRow(file: string, version: string, key: string, orig: Row, next: Row): void { + const d = slot(file, version); + if (same(orig, next)) delete d.edits[key]; + else d.edits[key] = next; + touch(file); +} + +export function addRow(file: string, version: string, row: Row): void { + slot(file, version).adds.push(row); + touch(file); +} + +export function setAdd(file: string, index: number, row: Row): void { + store[file].adds[index] = row; + touch(file); +} + +export function removeAdd(file: string, index: number): void { + store[file].adds.splice(index, 1); + touch(file); +} + +export function toggleDelete(file: string, version: string, key: string): void { + const d = slot(file, version); + d.deletes = d.deletes.includes(key) ? d.deletes.filter((k) => k !== key) : [...d.deletes, key]; + touch(file); +} + +/* --- 표형 파일 --- */ +export function setTable( + file: string, + version: string, + key: string, + base: Row, + items: TableItem[], + orig: Row[], +): void { + const d = slot(file, version); + if (items.length === orig.length && items.every((it, i) => it.o === i && same(it.r, orig[i]))) { + delete d.tables[key]; + } else { + d.tables[key] = { base, items }; + } + touch(file); +} + +export const tableItems = (file: string, key: string): TableItem[] | undefined => + store[file]?.tables[key]?.items; + +/* --- 저장·버리기 --- */ +export function payload(): FileChanges[] { + return Object.entries(store).map(([file, d]) => ({ + file, + version: d.version, + changes: [ + ...Object.entries(d.edits) + .filter(([key]) => !d.deletes.includes(key)) + .map(([key, row]) => ({ op: "edit" as const, key, row })), + ...Object.entries(d.tables).map(([key, t]) => ({ + op: "edit" as const, + key, + row: { ...t.base, 줄: t.items.map((it) => it.r) }, + })), + ...d.adds.map((row) => ({ op: "add" as const, row })), + ...d.deletes.map((key) => ({ op: "delete" as const, key })), + ], + })); +} + +/** files 를 안 주면 전부 버림. */ +export function discard(files?: string[]): void { + for (const file of files ?? Object.keys(store)) delete store[file]; + touch(""); +} diff --git a/M01_MasterData/M01_MasterData_UI_Cells.ts b/M01_MasterData/M01_MasterData_UI_Cells.ts new file mode 100644 index 00000000..f1e4441b --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Cells.ts @@ -0,0 +1,121 @@ +/* ============================================================================= + * M01_MasterData_UI_Cells.ts + * 칸 다루기 — 값 → 글 · 글 → 값 · 눌러 고치기 · 쪽 나눔 띠 (요소·표 화면 공용) + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import type { Row } from "./M01_MasterData_Api_Fetch"; + +/** 묶음 값(`값 = {가격: …}`)을 「값›가격」 칸으로 펼칠 때의 구분자. */ +export const SEP = "›"; + +export const isScalar = (v: unknown): boolean => + v === null || v === undefined || ["string", "number", "boolean"].includes(typeof v); + +/** 줄 → 칸 이름별 값. 묶음(dict) 칸은 한 단계 펼침. */ +export function flatten(row: Row): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(row)) { + if (v && typeof v === "object" && !Array.isArray(v)) { + for (const [k2, v2] of Object.entries(v)) out[`${k}${SEP}${k2}`] = v2; + } else { + out[k] = v; + } + } + return out; +} + +/** 칸 이름(펼친 것 포함)에 값을 넣은 새 줄. */ +export function withCell(row: Row, path: string, value: unknown): Row { + const [head, sub] = path.split(SEP); + if (sub === undefined) return { ...row, [head]: value }; + return { ...row, [head]: { ...(row[head] as Row), [sub]: value } }; +} + +export function show(v: unknown): string { + if (v === null || v === undefined) return ""; + if (Array.isArray(v)) return v.map((x) => (x === null ? "" : String(x))).join(" ~ "); + return typeof v === "object" ? JSON.stringify(v) : String(v); +} + +const NUMBER = /^-?\d+(\.\d+)?$/; + +/** 글 → 수(수 꼴이면) 아니면 글. 빈 글 = null. */ +export function toNumberish(text: string): number | string | null { + const s = text.replace(/,/g, "").trim(); + if (s === "") return null; + return NUMBER.test(s) ? Number(s) : text.trim(); +} + +/** 원래 값의 꼴을 따라 글을 값으로 — 글이었으면 글 · 수·빈 칸이었으면 수 꼴이면 수. */ +export const coerce = (text: string, orig: unknown): unknown => + typeof orig === "string" ? text : toNumberish(text); + +/** 「아래 ~ 위」 → [아래, 위] (빈 쪽 = null · 「~」 없으면 한 값). */ +export function toRange(text: string): unknown { + if (!text.includes("~")) return toNumberish(text); + return text.split("~").map((s) => (s.trim() === "" ? null : toNumberish(s))); +} + +/** 칸을 눌러 고침 — Enter·칸 밖 누름 = 확정 · Esc = 취소. */ +export function startEdit(td: HTMLElement, text: string, commit: (value: string) => void): void { + if (td.querySelector("input")) return; + const input = el("input", { className: "m01-master__cell-input", attrs: { type: "text" } }); + input.value = text; + td.textContent = ""; + td.append(input); + input.focus(); + input.select(); + let done = false; + const finish = (ok: boolean): void => { + if (done) return; + done = true; + if (ok && input.value !== text) commit(input.value); + else td.textContent = text; + }; + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") finish(true); + else if (e.key === "Escape") finish(false); + }); + input.addEventListener("blur", () => finish(true)); +} + +/** 쪽 나눔 띠 — 이전·다음 · 「n / N 쪽 · 전체 줄」. */ +export function buildPager( + total: number, + size: number, + page: number, + goto: (page: number) => void, +): HTMLElement { + const last = Math.max(1, Math.ceil(total / size)); + const prev = createButton({ label: L("M01_Prev"), variant: "ghost", disabled: page <= 1 }); + const next = createButton({ label: L("M01_Next"), variant: "ghost", disabled: page >= last }); + prev.addEventListener("click", () => goto(page - 1)); + next.addEventListener("click", () => goto(page + 1)); + return el("div", { + className: "m01-master__pager", + children: [ + el("span", { + text: `${page} / ${last} ${L("M01_Page")} · ${total.toLocaleString("ko-KR")} ${L("M01_Rows")}`, + }), + prev, + next, + ], + }); +} + +/** 칸 하나 — 노랑(고침) · 눌러 고침. */ +export function buildCell( + text: string, + opts: { changed: boolean; onEdit?: (value: string) => void }, +): HTMLTableCellElement { + const td = el("td", { text, attrs: { title: text } }); + td.classList.toggle("is-changed", opts.changed); + if (opts.onEdit) { + const onEdit = opts.onEdit; + td.classList.add("is-editable"); + td.addEventListener("click", () => startEdit(td, text, onEdit)); + } + return td; +} diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts new file mode 100644 index 00000000..69f178ac --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -0,0 +1,226 @@ +/* ============================================================================= + * 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 { + 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 => { + 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 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; +} diff --git a/M01_MasterData/M01_MasterData_UI_Rows.ts b/M01_MasterData/M01_MasterData_UI_Rows.ts new file mode 100644 index 00000000..93f346b9 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Rows.ts @@ -0,0 +1,153 @@ +/* ============================================================================= + * M01_MasterData_UI_Rows.ts + * 요소 파일(인력·재료·기계·환율·요율) 표 — 한 줄 = 요소 · 이름 찾기 · 쪽 나눔 · 눌러 고침(노랑) + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch"; +import { + addRow, + editRow, + onDraftChange, + peek, + removeAdd, + same, + setAdd, + toggleDelete, +} from "./M01_MasterData_Draft"; +import { + buildCell, + buildPager, + coerce, + flatten, + isScalar, + show, + withCell, +} from "./M01_MasterData_UI_Cells"; + +const SIZE = 50; +const FALLBACK: Row = { 열쇠: "", 이름: "", 규격: "", 단위: "", 값: null, 출처: "" }; + +/** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */ +function blank(sample: Row): Row { + const out: Row = {}; + for (const [k, v] of Object.entries(sample)) { + if (v && typeof v === "object" && !Array.isArray(v)) out[k] = blank(v as Row); + else out[k] = typeof v === "string" && k !== "값" ? "" : null; + } + return out; +} + +/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */ +export function renderRows(host: HTMLElement, file: string, q: string): () => void { + let page = 1; + let data: RowsPage | null = null; + + const paint = (): void => { + if (!data) return; + const d = data; + const draft = peek(file); + const adds = draft?.adds ?? []; + const cols: string[] = []; + for (const row of [...adds, ...d.rows]) { + for (const k of Object.keys(flatten(row))) if (!cols.includes(k)) cols.push(k); + } + const head = el("tr", { children: [el("th"), ...cols.map((c) => el("th", { text: c }))] }); + const body = el("tbody"); + + adds.forEach((row, i) => { + const flat = flatten(row); + const tr = el("tr", { className: "is-new" }); + tr.append(actionCell(L("M01_RowRemove"), () => removeAdd(file, i))); + for (const c of cols) { + tr.append( + buildCell(show(flat[c]), { + changed: true, + onEdit: (text) => setAdd(file, i, withCell(row, c, coerce(text, flat[c]))), + }), + ); + } + body.append(tr); + }); + + for (const row of d.rows) { + const key = String(row["열쇠"]); + const cur = draft?.edits[key] ?? row; + const deleted = draft?.deletes.includes(key) ?? false; + const orig = flatten(row); + const now = flatten(cur); + const tr = el("tr"); + tr.classList.toggle("is-deleted", deleted); + tr.append( + actionCell(deleted ? L("M01_RowRestore") : L("M01_RowDelete"), () => + toggleDelete(file, d.version, key), + ), + ); + for (const c of cols) { + const editable = !deleted && isScalar(orig[c]); + tr.append( + buildCell(show(now[c]), { + changed: !same(now[c], orig[c]), + onEdit: editable + ? (text) => + editRow(file, d.version, key, row, withCell(cur, c, coerce(text, orig[c]))) + : undefined, + }), + ); + } + body.append(tr); + } + + const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" }); + add.addEventListener("click", () => addRow(file, d.version, blank(d.rows[0] ?? FALLBACK))); + const empty = + d.rows.length || adds.length + ? [] + : [el("p", { className: "m01-master__empty", text: L("M01_NoRows") })]; + host.replaceChildren( + el("div", { + className: "m01-master__bar", + children: [add, buildPager(d.total, SIZE, d.page, go)], + }), + el("div", { + className: "m01-master__grid-wrap", + children: [ + el("table", { + className: "m01-master__grid", + children: [el("thead", { children: [head] }), body], + }), + ], + }), + ...empty, + ); + }; + + const go = (next: number): void => { + page = next; + void load(); + }; + + const load = async (): Promise => { + try { + data = await fetchRows(file, page, SIZE, q); + paint(); + } catch (error) { + showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); + } + }; + + const off = onDraftChange(paint); + void load(); + return off; +} + +function actionCell(label: string, onClick: () => void): HTMLTableCellElement { + const button = el("button", { + className: "m01-master__row-btn", + text: label, + attrs: { type: "button" }, + }); + button.addEventListener("click", onClick); + return el("td", { children: [button] }); +} diff --git a/M01_MasterData/M01_MasterData_UI_Style.css b/M01_MasterData/M01_MasterData_UI_Style.css new file mode 100644 index 00000000..ef501483 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Style.css @@ -0,0 +1,219 @@ +/* M01 마스터 요소 — 좌측 도킹 패널(그룹 탭 · 파일) / 우측 표 */ +.m01-master [hidden] { + display: none !important; +} + +.m01-master__side { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding-top: var(--spacing-8); +} + +.m01-master__tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-4); +} + +.m01-master__files { + display: flex; + flex-direction: column; + gap: 2px; +} + +.m01-master__tab, +.m01-master__file { + display: flex; + justify-content: space-between; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-buttons); + background: var(--color-surface); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); + text-align: left; + cursor: pointer; +} + +.m01-master__file { + border-color: transparent; + background: transparent; +} + +.m01-master__tab:hover, +.m01-master__file:hover { + background: var(--color-paper); +} + +.m01-master__tab.is-active, +.m01-master__file.is-active { + background: var(--color-mist-violet); + color: var(--color-accent); +} + +.m01-master__muted { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +/* ── 우측 ── */ +.m01-master__panel { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; + padding: var(--spacing-16) var(--spacing-24); +} + +.m01-master__head, +.m01-master__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-12); +} + +.m01-master__title { + flex: 1 1 auto; + margin: 0; + color: var(--color-text); + font-size: var(--text-body); +} + +.m01-master__summary { + color: var(--color-accent); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-bold); +} + +.m01-master__notice { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8) var(--spacing-12); + max-height: 30vh; + overflow: auto; + border-left: 4px solid var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 10%, var(--color-canvas)); + color: var(--color-danger); + font-size: var(--text-body-sm); +} + +.m01-master__notice p { + margin: 0; +} + +.m01-master__body { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.m01-master__grid-wrap { + max-height: calc(100vh - 300px); + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); +} + +.m01-master__grid { + border-collapse: collapse; + font-size: var(--text-body-sm); +} + +.m01-master__grid th, +.m01-master__grid td { + max-width: 320px; + padding: var(--spacing-8) var(--spacing-12); + overflow: hidden; + border-bottom: 1px solid var(--color-border); + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m01-master__grid th { + position: sticky; + top: 0; + z-index: 1; + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); +} + +.m01-master__grid td.is-editable { + cursor: text; +} + +/* 고친 칸 — 노랑 */ +.m01-master__grid td.is-changed { + background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas)); +} + +.m01-master__grid tr.is-deleted td { + color: var(--color-text-muted); + text-decoration: line-through; + background: color-mix(in srgb, var(--color-danger) 8%, var(--color-canvas)); +} + +.m01-master__cell-input { + width: 100%; + min-width: 6em; + padding: 2px var(--spacing-4); +} + +.m01-master__row-btn { + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-buttons); + background: var(--color-surface); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-caption); + cursor: pointer; +} + +.m01-master__pager { + display: flex; + align-items: center; + gap: var(--spacing-12); + margin-left: auto; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-master__tables { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--spacing-8); +} + +.m01-master__table-card { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + color: var(--color-text-body); + font: inherit; + text-align: left; + cursor: pointer; +} + +.m01-master__table-card:hover { + background: var(--color-paper); +} + +.m01-master__table-card.is-changed { + background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas)); +} + +.m01-master__empty { + color: var(--color-text-muted); +} diff --git a/M01_MasterData/M01_MasterData_UI_Tables.ts b/M01_MasterData/M01_MasterData_UI_Tables.ts new file mode 100644 index 00000000..2915f944 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Tables.ts @@ -0,0 +1,200 @@ +/* ============================================================================= + * M01_MasterData_UI_Tables.ts + * 표형 파일(소요량·계수) — 표 목록 → 표 하나(조건 칸 + 값 칸의 긴 줄) · 눌러 고침(노랑) · 줄 더하기·지우기 + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { fetchTable, fetchTables, type Row, type TableHead } from "./M01_MasterData_Api_Fetch"; +import { + onDraftChange, + peek, + same, + setTable, + tableItems, + type TableItem, +} from "./M01_MasterData_Draft"; +import { + buildCell, + buildPager, + coerce, + show, + toNumberish, + toRange, +} from "./M01_MasterData_UI_Cells"; + +const LIST_SIZE = 30; + +/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */ +export function renderTables(host: HTMLElement, file: string, q: string): () => void { + let stop = (): void => {}; + const list = (page = 1): void => { + stop(); + stop = (): void => {}; + void showList(host, file, q, page, (key) => { + stop = openTable(host, file, key, () => list(page)); + }); + }; + list(); + const off = onDraftChange( + () => host.querySelector(".m01-master__tables") && markChanged(host, file), + ); + return () => { + stop(); + off(); + }; +} + +/** 목록 위 「고침」 표시만 다시 — 목록을 다시 받지 않음. */ +function markChanged(host: HTMLElement, file: string): void { + const changed = peek(file)?.tables ?? {}; + host.querySelectorAll(".m01-master__table-card").forEach((card) => { + card.classList.toggle("is-changed", card.dataset.key! in changed); + }); +} + +async function showList( + host: HTMLElement, + file: string, + q: string, + page: number, + open: (key: string) => void, +): Promise { + let tables: TableHead[]; + try { + tables = (await fetchTables(file, q)).tables; + } catch (error) { + showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); + return; + } + const cards = tables.slice((page - 1) * LIST_SIZE, page * LIST_SIZE).map((t) => { + const card = el("button", { + className: "m01-master__table-card", + attrs: { type: "button", "data-key": t.열쇠 }, + children: [ + el("strong", { text: t.이름 || t.열쇠 }), + el("span", { className: "m01-master__muted", text: `${t.열쇠} · ${t.기준 || "—"}` }), + el("span", { + className: "m01-master__muted", + text: `${Object.keys(t.조건 ?? {}).join(" · ") || "—"} → ${Object.keys(t.값칸 ?? {}).join(" · ")} · ${t.count} ${L("M01_Rows")}`, + }), + ], + }); + card.classList.toggle("is-changed", t.열쇠 in (peek(file)?.tables ?? {})); + card.addEventListener("click", () => open(t.열쇠)); + return card; + }); + host.replaceChildren( + buildPager(tables.length, LIST_SIZE, page, (p) => void showList(host, file, q, p, open)), + el("div", { className: "m01-master__tables", children: cards }), + ); +} + +/** 표 하나. 돌려받은 함수 = 해제. */ +function openTable(host: HTMLElement, file: string, key: string, back: () => void): () => void { + let off = (): void => {}; + void (async () => { + let version: string; + let table: Row & { 줄: Row[] }; + try { + ({ version, table } = await fetchTable(file, key)); + } catch (error) { + showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); + return; + } + const { 줄: orig, ...base } = table; + const conds = (table.조건 ?? {}) as Record; + const values = (table.값칸 ?? {}) as Record; + const cols = [...Object.keys(conds), ...Object.keys(values)]; + for (const row of orig) for (const k of Object.keys(row)) if (!cols.includes(k)) cols.push(k); + const items = (): TableItem[] => tableItems(file, key) ?? orig.map((r, o) => ({ o, r })); + const commit = (next: TableItem[]): void => setTable(file, version, key, base, next, orig); + + const parse = (col: string, text: string, prev: unknown): unknown => { + if (conds[col] === "범위") return toRange(text); + if (conds[col] === "고르기") return text; + return col in values || conds[col] === "수" ? toNumberish(text) : coerce(text, prev); + }; + const headText = (c: string): string => + c in conds ? `${c} (${conds[c]})` : c in values ? `${c} (${values[c]})` : c; + + const paint = (): void => { + const now = items(); + const body = el("tbody"); + now.forEach((item, i) => { + const tr = el("tr"); + tr.classList.toggle("is-new", item.o === null); + const del = el("button", { + className: "m01-master__row-btn", + text: L("M01_RowRemove"), + attrs: { type: "button" }, + }); + del.addEventListener("click", () => commit(now.filter((_, j) => j !== i))); + tr.append(el("td", { children: [del] })); + for (const c of cols) { + const was = item.o === null ? undefined : orig[item.o][c]; + tr.append( + buildCell(show(item.r[c]), { + changed: item.o === null || !same(item.r[c], was), + onEdit: (text) => { + const value = parse(c, text, item.r[c]); + const r = { ...item.r }; + if (value === null || value === "") + delete r[c]; // 원문 「-」 = 칸을 뺌 + else r[c] = value; + commit(now.map((it, j) => (j === i ? { ...it, r } : it))); + }, + }), + ); + } + body.append(tr); + }); + const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" }); + add.addEventListener("click", () => commit([...now, { o: null, r: {} }])); + const backButton = createButton({ label: L("M01_BackToTables"), variant: "ghost" }); + backButton.addEventListener("click", () => { + off(); + back(); + }); + const notes = ((table.주 ?? []) as string[]).map((n) => + el("p", { className: "m01-master__muted", text: n }), + ); + host.replaceChildren( + el("div", { + className: "m01-master__bar", + children: [ + backButton, + el("strong", { text: String(table.이름 ?? key) }), + el("span", { + className: "m01-master__muted", + text: `${key} · ${table.기준 || "—"} · ${table.출처 || ""}`, + }), + add, + ], + }), + el("div", { + className: "m01-master__grid-wrap", + children: [ + el("table", { + className: "m01-master__grid", + children: [ + el("thead", { + children: [ + el("tr", { + children: [el("th"), ...cols.map((c) => el("th", { text: headText(c) }))], + }), + ], + }), + body, + ], + }), + ], + }), + ...notes, + ); + }; + off = onDraftChange(paint); + paint(); + })(); + return () => off(); +} diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 277a9238..a5738a7f 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -118,6 +118,8 @@ export const ROUTES = { B11_LOADING: "b11-loading", // 시스템 관리자 전용 — 로직·기초값 마스터 보기 (2026-09-15 브레인 Z01). Z01_MASTER_DATA: "z01-master-data", + // 시스템 관리자 전용 — 마스터 요소(파일 첫 층 JSON) 보기·고치기. + M01_MASTER_DATA: "m01-master-data", } as const; export type RouteKey = keyof typeof ROUTES; @@ -141,6 +143,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [ ROUTES.B11_STATUS, ROUTES.B11_LOADING, ROUTES.Z01_MASTER_DATA, + ROUTES.M01_MASTER_DATA, ]; /* ----------------------------------------------------------------------------- diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index df2909ad..a36c79d4 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -24,6 +24,7 @@ import { ui_locales_a } from "./ui_template_locale_a"; import { ui_locales_b1 } from "./ui_template_locale_b1"; import { ui_locales_b2 } from "./ui_template_locale_b2"; import { ui_locales_b3 } from "./ui_template_locale_b3"; +import { ui_locales_m1 } from "./ui_template_locale_m1"; /** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */ export const LANGUAGES = ["ko", "en"] as const; @@ -58,6 +59,7 @@ export const ui_locales = { ...ui_locales_b1, ...ui_locales_b2, ...ui_locales_b3, + ...ui_locales_m1, } as const; export type LocaleKey = keyof typeof ui_locales; diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts new file mode 100644 index 00000000..1429d66d --- /dev/null +++ b/ui_template/ui_template_locale_m1.ts @@ -0,0 +1,39 @@ +/* ============================================================================= + * ui_template_locale_m1.ts + * M01 마스터 요소 화면(시스템 관리자) 사전 — 신규 문구는 최하단에 추가. + * ========================================================================== */ + +export const ui_locales_m1 = { + B01_Dashboard_MasterElements: ["마스터 요소", "Master Elements"], + M01_Title: ["마스터 요소", "Master Elements"], + M01_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"], + M01_LoadFailed: ["마스터 요소를 못 읽음", "Failed to load master elements"], + M01_PickFile: ["왼쪽에서 그룹과 파일을 고를 것", "Pick a group and a file on the left"], + M01_Search: ["이름 찾기", "Find by name"], + M01_Prev: ["이전", "Prev"], + M01_Next: ["다음", "Next"], + M01_Page: ["쪽", "pages"], + M01_Rows: ["줄", "rows"], + M01_NoRows: ["줄 없음", "No rows"], + M01_RowAdd: ["줄 더하기", "Add row"], + M01_RowDelete: ["지우기", "Delete"], + M01_RowRestore: ["되살리기", "Restore"], + M01_RowRemove: ["빼기", "Remove"], + M01_BackToTables: ["← 표 목록", "← Tables"], + M01_Changed: ["고친 곳 {value}", "{value} changes"], + M01_Save: ["저장", "Save"], + M01_Discard: ["고친 것 버리기", "Discard changes"], + M01_DiscardConfirm: ["고친 것을 모두 버릴까?", "Discard all changes?"], + M01_Saved: ["저장했음", "Saved"], + M01_SaveFailed: ["저장 못 함 — {value}", "Save failed — {value}"], + M01_Stale: [ + "그 사이 파일이 바뀜({value}) — 고친 것을 버리고 다시 읽은 뒤 다시 고칠 것", + "Files changed meanwhile ({value}) — discard and reload, then edit again", + ], + M01_StaleReload: ["버리고 다시 읽기", "Discard and reload"], + M01_FileStale: [ + "고치기 시작한 뒤 이 파일이 바뀜 — 저장하면 막힘", + "This file changed after you started editing — saving will be refused", + ], + M01_CheckErrors: ["검사에 걸림", "Validation failed"], +} as const;