diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index d2113c51..78735084 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -6,7 +6,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import type { MasterColumn } from "./Z01_MasterData_UI_Cells"; +import type { MasterColumn, OverrideState, TableMeta } from "./Z01_MasterData_UI_Cells"; export interface MasterTable { id: string; @@ -32,36 +32,83 @@ export interface MasterGroup { files: MasterFile[]; } -export interface MasterRows { +/** 기초단가 표만 editable·locked·formula 를 실음 — 트리 표는 읽기 전용이라 안 옴. */ +export interface MasterRows extends TableMeta { columns: MasterColumn[]; rows: Record[]; total: number; } -async function request(path: string): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); +export interface RowsQuery { + page: number; + size: number; + q: string; +} + +/** 기초단가 다섯 — 고친 값은 원본이 아니라 덮개 파일에 쌓임(품셈이 갱신돼도 안 날아감). */ +export const BASE_PRICE_KINDS = ["labor", "machine", "material", "oil", "rate"] as const; +export type BasePriceKind = (typeof BASE_PRICE_KINDS)[number]; + +async function request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); const data = (await response.json()) as { detail?: string } & T; if (!response.ok) throw new Error(data.detail ?? "Request failed"); return data; } +const pageParams = (query: RowsQuery): Record => ({ + page: String(query.page), + size: String(query.size), + q: query.q, +}); + +export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise { + const params = new URLSearchParams(pageParams(query)); + return request(`/master-data/base-prices/${kind}?${params}`); +} + +export interface OverrideItem { + kind: BasePriceKind; + row_id: string; + row_label: string; + column: string; + column_label: string; + original: unknown; + current?: unknown; + value: unknown; + state: OverrideState; +} + +/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬해 줌. */ +export async function fetchOverrides(): Promise { + return (await request<{ items: OverrideItem[] }>("/master-data/overrides")).items; +} + +/** 칸 고치기 — 값 null = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */ +export function saveBasePrice( + kind: BasePriceKind, + rowId: string, + values: Record, +): Promise> { + return request(`/master-data/base-prices/${kind}/${encodeURIComponent(rowId)}`, { + method: "PUT", + body: JSON.stringify({ values }), + }); +} + export async function fetchMasterTree(): Promise { return (await request<{ groups: MasterGroup[] }>("/master-data/tree")).groups; } -export function fetchMasterRows(query: { - file: string; - table: string; - page: number; - size: number; - q: string; -}): Promise { - const params = new URLSearchParams({ - file: query.file, - table: query.table, - page: String(query.page), - size: String(query.size), - q: query.q, - }); +export function fetchMasterRows( + file: string, + table: string, + query: RowsQuery, +): Promise { + const params = new URLSearchParams({ file, table, ...pageParams(query) }); return request(`/master-data/rows?${params}`); } diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index 7b170044..09a9b098 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -40,6 +40,64 @@ export function rowCells(row: Record, columns: MasterColumn[]): ); } +/** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */ +export interface TableMeta { + editable?: string[]; + /** 열key → 왜 못 고치나 */ + locked?: Record; + /** 열key → 식 자체 */ + formula?: Record; +} + +/** 덮개 판정 낱말 셋 — 서버가 줌 · 이것만 씀(2026-09-15 브레인). */ +export type OverrideState = "edited" | "source_changed" | "orphan"; + +export interface CellInfo { + editable: boolean; + reason: string | null; + overridden: boolean; + /** 고쳐진 칸만 — 사람이 고칠 때의 원본 값 */ + original?: unknown; + /** 고친 칸의 서버 판정 — 안 고친 칸 null · 서버가 state 를 안 주면 「missing」(때우지 않음) */ + state: OverrideState | "missing" | null; + /** source_changed 칸만 — 지금 원본 값 */ + current?: unknown; + rule: string | null; + /** 이 줄 값을 넣은 식 — 줄마다 다를 때만 옴 */ + filled: string | null; +} + +/** 칸 하나의 고침 상태 — 줄의 `@overrides`·`@formula` 는 해당할 때만 실려 옴. */ +export function cellInfo(meta: TableMeta, row: Record, key: string): CellInfo { + const overrides = row["@overrides"] as + Record | undefined; + const filled = row["@formula"] as Record | undefined; + const override = overrides?.[key]; + // 판정은 서버 state 만 — 화면이 값을 견줘 따로 정하면 판정이 두 벌이 됨(2026-09-15 브레인). + const state = override ? (override.state ?? "missing") : null; + return { + editable: meta.editable?.includes(key) ?? false, + reason: meta.locked?.[key] ?? null, + overridden: override !== undefined, + ...(override ? { original: override.original } : {}), + state, + ...(state === "source_changed" ? { current: override?.current } : {}), + rule: meta.formula?.[key] ?? null, + filled: filled?.[key] ?? null, + }; +} + +const NUMBER_TEXT = /^-?[\d,]+(\.\d+)?$/; + +/** 고친 글자를 보낼 값으로 — 수 칸은 쉼표를 떼 수로, 못 읽으면 undefined(저장 안 함). */ +export function parseCellInput(text: string, original: unknown): unknown { + const trimmed = text.trim(); + const numeric = typeof original === "number" || (original == null && NUMBER_TEXT.test(trimmed)); + if (!numeric) return text; + if (!NUMBER_TEXT.test(trimmed)) return undefined; + return Number(trimmed.replaceAll(",", "")); +} + export function pageCount(total: number, size: number): number { return Math.max(1, Math.ceil(total / size)); } diff --git a/Z01_MasterData/Z01_MasterData_UI_Page.ts b/Z01_MasterData/Z01_MasterData_UI_Page.ts index df8c6f5b..d077e77e 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Page.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Page.ts @@ -1,46 +1,23 @@ /* ============================================================================= * Z01_MasterData_UI_Page.ts - * 마스터 데이터 — 좌 트리(갈래 → 파일 → 표) / 우 고른 표의 실제 줄(쪽 나누기 · 검색) + * 마스터 데이터 — B03~B09 와 같은 틀: 좌측 도킹 사이드바(기초단가 · 마스터 트리) / 우측 표 * - * 시스템 관리자만(서버도 require_system_admin 으로 다시 막음) · 읽기 전용부터 - * (2026-09-15 브레인 Z01 — 마스터를 사람이 보고 고칠 수 있게 먼저 만들고 그 위에 일위대가 재조립). + * 시스템 관리자만(서버도 require_system_admin 으로 다시 막음) · 워크플로 밖 화면이라 진행단계 + * 패널·프로젝트 이름은 안 세움 (2026-09-15 브레인 Z01 — 좌측 패널 양식 갈음 · 기초단가 칸). * ========================================================================== */ +import "@ui/ui_template_workflow_layout.css"; import { ROUTES } from "@config/config_frontend"; -import { - createButton, - createInputField, - el, - hideLoadingOverlay, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; -import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { 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 { - fetchMasterRows, - fetchMasterTree, - type MasterFile, - type MasterGroup, - type MasterRows, - type MasterTable, - VALUES_TABLE_ID, -} from "./Z01_MasterData_Api_Fetch"; -import { - clampPage, - columnTitle, - latestOnly, - pageCount, - rowCells, - shownColumns, -} from "./Z01_MasterData_UI_Cells"; +import { fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch"; +import { buildRowsView } from "./Z01_MasterData_UI_Rows"; +import { buildSidePanel } from "./Z01_MasterData_UI_Side"; import "./Z01_MasterData_UI_Style.css"; -const PAGE_SIZE = 50; -const SEARCH_DELAY_MS = 300; - export async function renderZ01MasterData(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); if (user?.role !== "SYSTEM_ADMIN") { @@ -63,223 +40,17 @@ export async function renderZ01MasterData(root: HTMLElement): Promise { function buildPage(groups: MasterGroup[]): HTMLElement { const view = buildRowsView(); - const tree = buildTree(groups, view.pick); - const content = el("div", { className: "z01-master__content", children: [tree, view.root] }); - return createGeneralLayout({ - pageClass: "z01-master", + const layout = el("div", { className: "ui-workflow-layout z01-master" }); + const main = el("main", { className: "ui-workflow-layout__main", children: [view.root] }); + const overlays = createWorkflowOverlays({ title: L("Z01_MasterData_Title"), - subtitle: L("Z01_MasterData_Subtitle"), - content, - }).root; -} - -function buildTree( - groups: MasterGroup[], - pick: (file: MasterFile, table: MasterTable) => void, -): HTMLElement { - const nav = el("nav", { className: "z01-master__tree" }); - for (const group of groups) { - const groupBox = folder("z01-master__group", group.label, String(group.files.length)); - groupBox.open = true; - // 부산물은 계산이 남긴 기록 — 고칠 정본이 아님(2026-09-15 브레인 갈래 나눔표). - if (group.key === "byproduct") { - groupBox.append( - el("p", { className: "z01-master__note", text: L("Z01_MasterData_Byproduct") }), - ); - } - for (const file of group.files) { - const fileBox = folder( - "z01-master__file", - file.label, - file.label === file.key ? "" : file.key, - ); - // 낱값 마디는 표들 밑에 — 누르면 여느 표와 같은 길로 그림(2026-09-15 브레인). - const ordered = [...file.tables].sort( - (a, b) => Number(a.id === VALUES_TABLE_ID) - Number(b.id === VALUES_TABLE_ID), - ); - for (const table of ordered) { - const button = el("button", { - className: "z01-master__table", - attrs: { type: "button", title: table.key }, - children: [ - el("span", { text: table.label }), - el("span", { - className: "z01-master__key", - text: table.row_count.toLocaleString("ko-KR"), - }), - ], - }); - button.addEventListener("click", () => { - nav.querySelector(".z01-master__table.is-active")?.classList.remove("is-active"); - button.classList.add("is-active"); - pick(file, table); - }); - fileBox.append(button); - } - groupBox.append(fileBox); - } - nav.append(groupBox); - } - return nav; -} - -/** 접는 칸 — 브라우저 기본
로 둠. */ -function folder(className: string, label: string, aside: string): HTMLDetailsElement { - const summary = el("summary", { - children: [ - el("span", { text: label }), - el("span", { className: "z01-master__key", text: aside }), - ], - }); - return el("details", { className, children: [summary] }); -} - -interface RowsView { - root: HTMLElement; - pick: (file: MasterFile, table: MasterTable) => void; -} - -function buildRowsView(): RowsView { - const state = { - file: null as MasterFile | null, - table: null as MasterTable | null, - page: 1, - q: "", - }; - let showHidden = false; - let last: MasterRows | null = null; - let searchTimer = 0; - const fetchLatest = latestOnly(fetchMasterRows); - - const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") }); - const titleKey = el("span", { className: "z01-master__key" }); - const search = createInputField({ - type: "search", - placeholder: L("Z01_MasterData_Search"), - onInput: (value) => { - window.clearTimeout(searchTimer); - searchTimer = window.setTimeout(() => { - state.q = value.trim(); - state.page = 1; - void load(); - }, SEARCH_DELAY_MS); - }, - }); - const hiddenToggle = el("input", { attrs: { type: "checkbox" } }); - hiddenToggle.addEventListener("change", () => { - showHidden = hiddenToggle.checked; - draw(); - }); - const hiddenLabel = el("label", { - className: "z01-master__check", - children: [hiddenToggle, L("Z01_MasterData_ShowHidden")], - }); - const grid = el("div", { className: "z01-master__grid-wrap" }); - const pageInput = el("input", { - className: "ui-input z01-master__page-input", - attrs: { type: "number", min: "1", "aria-label": L("Z01_MasterData_Page") }, - }); - pageInput.addEventListener("change", () => goTo(Number(pageInput.value))); - const pageInfo = el("span", { className: "z01-master__page-info" }); - // 큰 표(수만 줄 · 수백 쪽)는 한 칸씩 못 넘김 — 처음·끝 단추와 쪽 번호 칸을 둠(2026-09-15 브레인). - const pagerButton = (key: Parameters[0], onClick: () => void) => - createButton({ label: L(key), variant: "ghost", onClick }); - const first = pagerButton("Z01_MasterData_First", () => goTo(1)); - const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1)); - const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1)); - const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity)); - const toolbar = el("div", { - className: "z01-master__toolbar", - children: [search.root, hiddenLabel], - }); - const pager = el("div", { - className: "z01-master__pager", - children: [first, prev, pageInput, pageInfo, next, end], - }); - const root = el("section", { - className: "z01-master__panel", - children: [el("div", { children: [title, titleKey] }), toolbar, grid, pager], - }); - toolbar.hidden = true; - pager.hidden = true; - - function goTo(page: number): void { - const pages = pageCount(last?.total ?? 0, PAGE_SIZE); - state.page = page === Infinity ? pages : clampPage(page, pages); - void load(); - } - - async function load(): Promise { - if (!state.file || !state.table) return; - grid.classList.add("is-loading"); - let data: MasterRows | undefined; - try { - data = await fetchLatest({ - file: state.file.id, - table: state.table.id, - page: state.page, - size: PAGE_SIZE, - q: state.q, - }); - } catch (error) { - grid.classList.remove("is-loading"); - showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); - return; - } - // 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠. - if (!data) return; - grid.classList.remove("is-loading"); - last = data; - draw(); - } - - function draw(): void { - if (!last) return; - const columns = shownColumns(last.columns, showHidden); - const headRow = el("tr"); - for (const column of columns) { - const th = el("th", { children: [el("span", { text: columnTitle(column) })] }); - if (column.label !== column.key) { - th.append(el("span", { className: "z01-master__key", text: column.key })); - } - headRow.append(th); - } - const body = el("tbody"); - for (const row of last.rows) { - const tr = el("tr"); - for (const text of rowCells(row, columns)) { - tr.append(el("td", { text, attrs: { title: text } })); - } - body.append(tr); - } - grid.replaceChildren( - last.rows.length - ? el("table", { - className: "z01-master__grid", - children: [el("thead", { children: [headRow] }), body], - }) - : el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }), - ); - const pages = pageCount(last.total, PAGE_SIZE); - pageInput.value = String(state.page); - pageInput.max = String(pages); - pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${last.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`; - first.disabled = prev.disabled = state.page <= 1; - next.disabled = end.disabled = state.page >= pages; - } - - function pick(file: MasterFile, table: MasterTable): void { - state.file = file; - state.table = table; - state.page = 1; - title.textContent = `${file.label} › ${table.label}`; - titleKey.textContent = `${file.key} / ${table.key}`; - toolbar.hidden = false; - pager.hidden = false; - last = null; - grid.replaceChildren(); - void load(); - } - - return { root, pick }; + optionsContent: buildSidePanel(groups, view), + 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/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts new file mode 100644 index 00000000..75e6cca4 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -0,0 +1,339 @@ +/* ============================================================================= + * Z01_MasterData_UI_Rows.ts + * 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 칸을 고침 + * + * 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `save` 가 있어 editable 칸을 고침. + * 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 원래 값 · 산출근거가 섬 + * (2026-09-15 브레인 — 계산으로 나오는 칸은 못 고치고 왜 못 고치는지 옆에 보일 것). + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch"; +import { + cellInfo, + cellText, + clampPage, + columnTitle, + latestOnly, + pageCount, + parseCellInput, + rowCells, + shownColumns, + type CellInfo, + type MasterColumn, +} from "./Z01_MasterData_UI_Cells"; + +const PAGE_SIZE = 50; +const SEARCH_DELAY_MS = 300; + +type Row = Record; + +export interface RowsSource { + title: string; + key: string; + /** 표 위 알림 한 줄(요율 — 법이 정한 값) */ + notice?: string; + load: (query: RowsQuery) => Promise; + /** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */ + save?: (rowId: string, values: Row) => Promise; +} + +export interface RowsView { + root: HTMLElement; + show: (source: RowsSource) => void; +} + +/** 사전 문구의 {value} 자리에 값을 넣음. */ +function fill(key: Parameters[0], value: string): string { + return L(key).replace("{value}", value); +} + +/** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */ +function stateText(info: CellInfo, column: MasterColumn): string { + const original = cellText(info.original, column.unit); + switch (info.state) { + case "source_changed": + return `${fill("Z01_MasterData_State_SourceChanged", cellText(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`; + case "orphan": + return L("Z01_MasterData_State_Orphan"); + case "missing": + return L("Z01_MasterData_State_Missing"); + default: + return fill("Z01_MasterData_State_Edited", original); + } +} + +export function buildRowsView(): RowsView { + let source: RowsSource | null = null; + const state = { page: 1, q: "" }; + let showHidden = false; + let last: MasterRows | null = null; + let selected: { index: number; key: string } | null = null; + let searchTimer = 0; + const fetchLatest = latestOnly((from: RowsSource, query: RowsQuery) => from.load(query)); + + const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") }); + const titleKey = el("span", { className: "z01-master__key" }); + const search = createInputField({ + type: "search", + placeholder: L("Z01_MasterData_Search"), + onInput: (value) => { + window.clearTimeout(searchTimer); + searchTimer = window.setTimeout(() => { + state.q = value.trim(); + state.page = 1; + void load(); + }, SEARCH_DELAY_MS); + }, + }); + const hiddenToggle = el("input", { attrs: { type: "checkbox" } }); + hiddenToggle.addEventListener("change", () => { + showHidden = hiddenToggle.checked; + draw(); + }); + const hiddenLabel = el("label", { + className: "z01-master__check", + children: [hiddenToggle, L("Z01_MasterData_ShowHidden")], + }); + const notice = el("p", { className: "z01-master__notice" }); + const detail = el("div", { className: "z01-master__detail" }); + const grid = el("div", { className: "z01-master__grid-wrap" }); + const pageInput = el("input", { + className: "ui-input z01-master__page-input", + attrs: { type: "number", min: "1", "aria-label": L("Z01_MasterData_Page") }, + }); + pageInput.addEventListener("change", () => goTo(Number(pageInput.value))); + const pageInfo = el("span", { className: "z01-master__page-info" }); + // 큰 표(수만 줄 · 수백 쪽)는 한 칸씩 못 넘김 — 처음·끝 단추와 쪽 번호 칸을 둠(2026-09-15 브레인). + const pagerButton = (key: Parameters[0], onClick: () => void) => + createButton({ label: L(key), variant: "ghost", onClick }); + const first = pagerButton("Z01_MasterData_First", () => goTo(1)); + const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1)); + const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1)); + const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity)); + const toolbar = el("div", { + className: "z01-master__toolbar", + children: [search.root, hiddenLabel], + }); + const pager = el("div", { + className: "z01-master__pager", + children: [first, prev, pageInput, pageInfo, next, end], + }); + const root = el("section", { + className: "z01-master__panel", + children: [el("div", { children: [title, titleKey] }), notice, toolbar, detail, grid, pager], + }); + toolbar.hidden = true; + pager.hidden = true; + detail.hidden = true; + notice.hidden = true; + + function goTo(page: number): void { + const pages = pageCount(last?.total ?? 0, PAGE_SIZE); + state.page = page === Infinity ? pages : clampPage(page, pages); + void load(); + } + + async function load(): Promise { + if (!source) return; + grid.classList.add("is-loading"); + let data: MasterRows | undefined; + try { + data = await fetchLatest(source, { page: state.page, size: PAGE_SIZE, q: state.q }); + } catch (error) { + grid.classList.remove("is-loading"); + showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); + return; + } + // 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠. + if (!data) return; + grid.classList.remove("is-loading"); + last = data; + selected = null; + detail.hidden = true; + draw(); + } + + function headCell(meta: MasterRows, column: MasterColumn): HTMLElement { + const th = el("th", { children: [el("span", { text: columnTitle(column) })] }); + const marks: [string, string][] = []; + if (source?.save && meta.editable?.includes(column.key)) { + marks.push(["✎", L("Z01_MasterData_Edit_Can")]); + } + const reason = meta.locked?.[column.key]; + if (reason) marks.push(["🔒", reason]); + const rule = meta.formula?.[column.key]; + if (rule) marks.push(["ƒ", rule]); + for (const [mark, tip] of marks) { + th.append(el("span", { className: "z01-master__mark", text: mark, attrs: { title: tip } })); + } + if (column.label !== column.key) { + th.append(el("span", { className: "z01-master__key", text: column.key })); + } + return th; + } + + function draw(): void { + if (!last) return; + const meta = last; + const columns = shownColumns(meta.columns, showHidden); + const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); + const body = el("tbody"); + meta.rows.forEach((row, index) => { + const texts = rowCells(row, columns); + const tr = el("tr"); + columns.forEach((column, i) => { + const info = cellInfo(meta, row, column.key); + const td = el("td", { text: texts[i], attrs: { title: texts[i] } }); + if (info.state) { + // 원본이 바뀐 칸은 줄째 또렷이 — 흐리면 옛 값이 조용히 계속 쓰임(2026-09-15 브레인). + td.classList.add("is-overridden", `is-${info.state.replace("_", "-")}`); + if (info.state !== "edited") { + td.textContent = `⚠ ${texts[i]}`; + tr.classList.add("has-warning"); + } + td.title = stateText(info, column); + } + const canEdit = Boolean(source?.save) && info.editable && row["@id"] != null; + if (canEdit) td.classList.add("is-editable"); + if (selected?.index === index && selected.key === column.key) + td.classList.add("is-selected"); + td.addEventListener("click", () => { + if (td.querySelector("input")) return; + selectCell(index, column); + td.classList.add("is-selected"); + if (canEdit) startEdit(td, index, column); + }); + tr.append(td); + }); + body.append(tr); + }); + grid.replaceChildren( + meta.rows.length + ? el("table", { + className: "z01-master__grid", + children: [el("thead", { children: [headRow] }), body], + }) + : el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }), + ); + const pages = pageCount(meta.total, PAGE_SIZE); + pageInput.value = String(state.page); + pageInput.max = String(pages); + pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${meta.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`; + first.disabled = prev.disabled = state.page <= 1; + next.disabled = end.disabled = state.page >= pages; + } + + /** 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 원래 값(되돌리기) · 식과 값 넣은 식. */ + function selectCell(index: number, column: MasterColumn): void { + if (!last) return; + grid.querySelector("td.is-selected")?.classList.remove("is-selected"); + selected = { index, key: column.key }; + const row = last.rows[index]; + const info = cellInfo(last, row, column.key); + const lines: HTMLElement[] = [el("strong", { text: columnTitle(column) })]; + const line = (text: string) => el("span", { className: "z01-master__detail-line", text }); + if (source?.save) { + lines.push( + line( + info.editable + ? L("Z01_MasterData_Edit_Can") + : `${L("Z01_MasterData_Edit_Locked")} — ${info.reason ?? L("Z01_MasterData_Edit_NotListed")}`, + ), + ); + } + if (info.state) { + const stateLine = line(stateText(info, column)); + if (info.state !== "edited") stateLine.classList.add("is-warning"); + lines.push(stateLine); + // 단추 글자에 무엇으로 바뀌는지 값을 넣음 — 안 보이고 누르면 금액이 말없이 바뀜(브레인). + const target = + info.state === "source_changed" + ? fill("Z01_MasterData_Edit_TakeSource", cellText(info.current, column.unit)) + : info.state === "orphan" + ? null + : fill("Z01_MasterData_Edit_RevertTo", cellText(info.original, column.unit)); + if (target && source?.save) { + lines.push( + createButton({ + label: target, + variant: "ghost", + onClick: () => void commit(index, column, null), + }), + ); + } + } + if (info.rule) lines.push(line(`${L("Z01_MasterData_Formula")} ${info.rule}`)); + if (info.filled) lines.push(line(info.filled)); + detail.replaceChildren(...lines); + detail.hidden = false; + } + + function startEdit(td: HTMLElement, index: number, column: MasterColumn): void { + if (!last) return; + const current = last.rows[index][column.key]; + const input = el("input", { className: "ui-input z01-master__cell-input" }); + input.value = current == null ? "" : String(current); + td.replaceChildren(input); + input.focus(); + input.select(); + let finished = false; + const finish = (save: boolean): void => { + if (finished) return; + finished = true; + const value = save ? parseCellInput(input.value, current) : current; + if (value === undefined) showToast(L("Z01_MasterData_Edit_Invalid"), "error"); + if (value === undefined || value === current) { + draw(); + return; + } + void commit(index, column, value); + }; + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") finish(true); + if (event.key === "Escape") finish(false); + }); + input.addEventListener("blur", () => finish(true)); + } + + /** 저장 — null 은 덮개에서 그 칸을 빼 원래 값으로 되돌림. */ + async function commit(index: number, column: MasterColumn, value: unknown): Promise { + const from = source; + const table = last; + const rowId = table?.rows[index]["@id"]; + if (!from?.save || !table || rowId == null) return; + try { + const updated = await from.save(String(rowId), { [column.key]: value }); + if (from !== source || table !== last) return; + table.rows[index] = updated; + draw(); + selectCell(index, column); + showToast( + L(value === null ? "Z01_MasterData_Edit_Reverted" : "Z01_MasterData_Edit_Saved"), + "success", + ); + } catch (error) { + showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error"); + draw(); + } + } + + function show(picked: RowsSource): void { + source = picked; + state.page = 1; + title.textContent = picked.title; + titleKey.textContent = picked.key; + notice.textContent = picked.notice ?? ""; + notice.hidden = !picked.notice; + toolbar.hidden = false; + pager.hidden = false; + detail.hidden = true; + last = null; + selected = null; + grid.replaceChildren(); + void load(); + } + + return { root, show }; +} diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts new file mode 100644 index 00000000..b47e4fc4 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -0,0 +1,177 @@ +/* ============================================================================= + * Z01_MasterData_UI_Side.ts + * 좌측 패널 — B03~B09 와 같은 도킹 사이드바 안에 접히는 상자 둘(기초단가 · 마스터 트리) + * + * 상자 겉모습·접기는 공용(`ui-collapsible ui-sidebar-section`) · 누른 단추 하나만 강조 + * (두 상자를 가로질러 한 개) (2026-09-15 브레인 — 좌측 패널 양식 갈음 · 기초단가 칸). + * ========================================================================== */ + +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { + BASE_PRICE_KINDS, + fetchBasePrices, + fetchMasterRows, + fetchOverrides, + type MasterGroup, + type MasterRows, + type RowsQuery, + saveBasePrice, + VALUES_TABLE_ID, +} from "./Z01_MasterData_Api_Fetch"; +import type { RowsView } from "./Z01_MasterData_UI_Rows"; + +export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLElement { + const panel = el("div", { className: "z01-master__side" }); + const activate = (button: HTMLElement): void => { + panel.querySelector(".is-active")?.classList.remove("is-active"); + button.classList.add("is-active"); + }; + + const baseTitle = L("Z01_MasterData_BasePrices"); + const baseGrid = el("div", { className: "z01-master__base-grid" }); + for (const kind of BASE_PRICE_KINDS) { + const label = L(`Z01_MasterData_Base_${kind}` as const); + const button = el("button", { + className: "z01-master__base", + attrs: { type: "button" }, + text: label, + }); + button.addEventListener("click", () => { + activate(button); + view.show({ + title: `${baseTitle} › ${label}`, + key: `base-prices/${kind}`, + // 요율은 구간 줄에 코드가 없어 구간 이름으로 이음 — 법이 바뀌면 고친 값이 주인을 잃기 쉬움. + notice: kind === "rate" ? L("Z01_MasterData_Rate_Notice") : undefined, + load: (query) => fetchBasePrices(kind, query), + save: (rowId, values) => saveBasePrice(kind, rowId, values), + }); + }); + baseGrid.append(button); + } + const overridesButton = el("button", { + className: "z01-master__base z01-master__base--wide", + attrs: { type: "button" }, + text: L("Z01_MasterData_Overrides"), + }); + overridesButton.addEventListener("click", () => { + activate(overridesButton); + view.show({ + title: `${baseTitle} › ${L("Z01_MasterData_Overrides")}`, + key: "overrides", + load: loadOverrides, + }); + }); + baseGrid.append(overridesButton); + + const tree = el("nav", { className: "z01-master__tree" }); + for (const group of groups) { + const groupBox = folder("z01-master__group", group.label, String(group.files.length)); + groupBox.open = true; + // 부산물은 계산이 남긴 기록 — 고칠 정본이 아님(2026-09-15 브레인 갈래 나눔표). + if (group.key === "byproduct") { + groupBox.append( + el("p", { className: "z01-master__note", text: L("Z01_MasterData_Byproduct") }), + ); + } + for (const file of group.files) { + const fileBox = folder( + "z01-master__file", + file.label, + file.label === file.key ? "" : file.key, + ); + // 낱값 마디는 표들 밑에 — 누르면 여느 표와 같은 길로 그림(2026-09-15 브레인). + const ordered = [...file.tables].sort( + (a, b) => Number(a.id === VALUES_TABLE_ID) - Number(b.id === VALUES_TABLE_ID), + ); + for (const table of ordered) { + const button = el("button", { + className: "z01-master__table", + attrs: { type: "button", title: table.key }, + children: [ + el("span", { text: table.label }), + el("span", { + className: "z01-master__key", + text: table.row_count.toLocaleString("ko-KR"), + }), + ], + }); + button.addEventListener("click", () => { + activate(button); + view.show({ + title: `${file.label} › ${table.label}`, + key: `${file.key} / ${table.key}`, + load: (query) => fetchMasterRows(file.id, table.id, query), + }); + }); + fileBox.append(button); + } + groupBox.append(fileBox); + } + tree.append(groupBox); + } + + panel.append(section(baseTitle, baseGrid), section(L("Z01_MasterData_Tree"), tree)); + attachCollapsible(panel); + return panel; +} + +const STATE_LABEL = { + source_changed: "Z01_MasterData_List_SourceChanged", + orphan: "Z01_MasterData_List_Orphan", + edited: "Z01_MasterData_List_Edited", +} as const; + +/** + * 고친 것 목록을 표 한 벌로 — 서버 정렬(원본 바뀜 → 주인 없음 → 고쳐짐)을 그대로 두고 쪽만 자름. + * 쪽을 서버에서 나눠 받으면 맨 위가 그 쪽 안에서만 맨 위가 됨(2026-09-15 브레인 ②). + */ +async function loadOverrides(query: RowsQuery): Promise { + const needle = query.q.toLowerCase(); + const items = (await fetchOverrides()).filter( + (item) => !needle || JSON.stringify(item).toLowerCase().includes(needle), + ); + const start = (query.page - 1) * query.size; + const column = (key: string, label: Parameters[0]) => ({ key, label: L(label) }); + return { + columns: [ + column("state", "Z01_MasterData_List_State"), + column("kind", "Z01_MasterData_BasePrices"), + column("row_label", "Z01_MasterData_List_Row"), + column("column_label", "Z01_MasterData_List_Column"), + column("value", "Z01_MasterData_List_Value"), + column("original", "Z01_MasterData_List_Original"), + column("current", "Z01_MasterData_List_Current"), + ], + rows: items.slice(start, start + query.size).map((item) => ({ + ...item, + // state 가 안 오면 때우지 않고 드러냄(서버 버그). + state: L(STATE_LABEL[item.state] ?? "Z01_MasterData_State_Missing"), + kind: L(`Z01_MasterData_Base_${item.kind}` as const), + })), + total: items.length, + }; +} + +function section(title: string, body: HTMLElement): HTMLElement { + return el("section", { + className: "z01-master__section ui-collapsible ui-sidebar-section", + children: [ + el("p", { className: "z01-master__section-title ui-collapsible__title", text: title }), + body, + ], + }); +} + +/** 트리 접는 칸 — 브라우저 기본
로 둠. */ +function folder(className: string, label: string, aside: string): HTMLDetailsElement { + const summary = el("summary", { + children: [ + el("span", { text: label }), + el("span", { className: "z01-master__key", text: aside }), + ], + }); + return el("details", { className, children: [summary] }); +} diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css index a0c0a9aa..824dbfff 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -1,24 +1,66 @@ -/* Z01 마스터 데이터 — 좌 트리 / 우 표 */ +/* Z01 마스터 데이터 — 좌측 도킹 사이드바(기초단가 · 마스터 트리) / 우측 표 */ .z01-master [hidden] { display: none !important; } -.z01-master__content { +/* ── 좌측 패널: B08 과 같은 상자 틀(겉선·접기는 공용 ui-sidebar-section · ui-collapsible) ── */ +.z01-master__side { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding-top: var(--spacing-8); +} + +.z01-master__section { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: 0; + padding: 10px 12px; + border-radius: var(--radius-cards); + background-color: var(--color-surface-raised); +} + +.z01-master__section-title { + margin: 0 0 2px; + color: var(--color-text-secondary); + font-size: 12px; + font-weight: 600; +} + +.z01-master__base-grid { display: grid; - grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); - gap: var(--spacing-24); - align-items: start; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-4); +} + +.z01-master__base { + 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); + cursor: pointer; +} + +.z01-master__base--wide { + grid-column: 1 / -1; +} + +.z01-master__base:hover, +.z01-master__table:hover { + background: var(--color-paper); +} + +.z01-master__base.is-active, +.z01-master__table.is-active { + background: var(--color-mist-violet); + color: var(--color-accent); } .z01-master__tree { - position: sticky; - top: var(--spacing-16); - max-height: calc(100vh - 160px); - overflow-y: auto; - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); font-size: var(--text-body-sm); } @@ -26,7 +68,7 @@ display: flex; justify-content: space-between; gap: var(--spacing-8); - padding: var(--spacing-4) var(--spacing-8); + padding: var(--spacing-4) var(--spacing-4); cursor: pointer; color: var(--color-text-body); } @@ -68,20 +110,13 @@ cursor: pointer; } -.z01-master__table:hover { - background: var(--color-surface-raised); -} - -.z01-master__table.is-active { - background: var(--color-mist-violet); - color: var(--color-accent); -} - +/* ── 우측 표 ── */ .z01-master__panel { display: flex; flex-direction: column; - gap: var(--spacing-16); + gap: var(--spacing-12); min-width: 0; + padding: var(--spacing-16) var(--spacing-24); } .z01-master__title { @@ -90,6 +125,16 @@ font-size: var(--text-body); } +/* 요율 알림 — 막지 않고 알리기만 */ +.z01-master__notice { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-warning); + background: var(--color-paper); + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + .z01-master__toolbar { display: flex; flex-wrap: wrap; @@ -105,8 +150,30 @@ font-size: var(--text-body-sm); } +/* 칸 설명 줄 — 고칠 수 있나 · 까닭 · 원래 값 · 산출근거 */ +.z01-master__detail { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4) var(--spacing-12); + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + font-size: var(--text-body-sm); +} + +.z01-master__detail-line { + color: var(--color-text-body); +} + +.z01-master__detail-line.is-warning { + color: var(--color-danger); + font-weight: var(--font-weight-bold); +} + .z01-master__grid-wrap { - max-height: calc(100vh - 300px); + max-height: calc(100vh - 320px); overflow: auto; border: 1px solid var(--color-border); border-radius: var(--radius-cards); @@ -146,6 +213,52 @@ margin-left: 0; } +.z01-master__mark { + margin-left: var(--spacing-4); + cursor: help; +} + +.z01-master__grid td.is-editable { + cursor: text; + text-decoration: underline dotted var(--color-text-muted); +} + +.z01-master__grid td.is-selected { + outline: 2px solid var(--color-accent); + outline-offset: -2px; +} + +/* 고쳐짐 — 덮개 값을 쓰는 칸 */ +.z01-master__grid td.is-overridden { + background: var(--color-mist-violet); + color: var(--color-accent); + font-weight: var(--font-weight-medium); +} + +/* 원본 바뀜 · 주인 없음 · 판정 없음 — 줄째 또렷이(흐리면 옛 값이 조용히 쓰임) */ +.z01-master__grid tr.has-warning td { + background: color-mix(in srgb, var(--color-danger) 8%, var(--color-canvas)); +} + +.z01-master__grid tr.has-warning td:first-child { + box-shadow: inset 4px 0 0 var(--color-danger); +} + +/* tr 을 넣어 줄 배경(tr.has-warning td)보다 무겁게 — 칸이 줄보다 더 진해야 함 */ +.z01-master__grid tr td.is-source-changed, +.z01-master__grid tr td.is-orphan, +.z01-master__grid tr td.is-missing { + background: color-mix(in srgb, var(--color-danger) 22%, var(--color-canvas)); + color: var(--color-danger); + font-weight: var(--font-weight-bold); +} + +.z01-master__cell-input { + width: 100%; + min-width: 6em; + padding: 2px var(--spacing-4); +} + .z01-master__pager { display: flex; align-items: center; @@ -159,14 +272,3 @@ width: 6em; text-align: right; } - -@media (max-width: 860px) { - .z01-master__content { - display: block; - } - - .z01-master__tree { - position: static; - margin-bottom: var(--spacing-24); - } -} diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index d96a9299..d031a4de 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -24,9 +24,34 @@ SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Cells.ts" _RUNNER = """ import { writeFileSync } from "node:fs"; import { - cellText, clampPage, columnTitle, latestOnly, pageCount, rowCells, shownColumns, + cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, + shownColumns, } from "./Z01_MasterData_UI_Cells.js"; +// 기초단가 — 표 수준 editable·locked·formula · 줄 수준 @id·@overrides·@formula (2026-09-15 브레인) +const meta = { + editable: ["daily_wage_krw"], + locked: { hourly_krw: "계산으로 나옴" }, + formula: { hourly_krw: "기계경비 = 손료 + 연료 + 노무" }, +}; +const machineRow = { + "@id": "M-001", + daily_wage_krw: 300000, + hourly_krw: 12340, + "@overrides": { daily_wage_krw: { original: 250000, value: 300000 } }, + "@formula": { hourly_krw: "12,340 = 8,200 + 2,140 + 2,000" }, +}; +// 판정은 서버 state 만 — edited · source_changed · orphan (화면이 original≠current 로 따로 안 셈) +const withState = (state, current) => ({ + "@id": "M-002", + daily_wage_krw: 300000, + "@overrides": { daily_wage_krw: { original: 250000, value: 300000, current, state } }, +}); +// 값은 같아도 서버가 바뀜이라 하면 바뀜 · 값이 달라도 서버가 edited 라 하면 edited +const changedRow = withState("source_changed", 250000); +const sameRow = withState("edited", 270000); +const orphanRow = withState("orphan", undefined); + const wait = (ms, value, fail) => new Promise((ok, no) => setTimeout(() => (fail ? no(new Error(value)) : ok(value)), ms)); const fetchLatest = latestOnly(wait); @@ -62,6 +87,21 @@ writeFileSync(process.argv[2], JSON.stringify({ race: race.map((v) => v ?? null), raceError: raceError.map((v) => v ?? null), latestError, + info: [ + cellInfo(meta, machineRow, "daily_wage_krw"), + cellInfo(meta, machineRow, "hourly_krw"), + cellInfo({}, { name: "x" }, "name"), + cellInfo(meta, changedRow, "daily_wage_krw"), + cellInfo(meta, sameRow, "daily_wage_krw"), + cellInfo(meta, orphanRow, "daily_wage_krw"), + ], + parsed: [ + parseCellInput("12,340", 100), + parseCellInput("abc", 100), + parseCellInput("", 100), + parseCellInput("보통인부2", "보통인부"), + parseCellInput("3.5", null), + ].map((v) => (v === undefined ? "invalid" : v)), })); """ @@ -109,3 +149,27 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert got["race"] == [None, "new"] assert got["raceError"] == [None, "new"] assert got["latestError"] == "thrown boom" + # 고칠 수 있는 칸 · 고쳐짐이면 원래 값 · 못 고치는 칸은 까닭과 식 · 줄마다 값 넣은 식 + wage, hourly, plain, changed, same, orphan = got["info"] + # state 가 안 온 고친 칸은 화면이 때우지 않고 「missing」으로 드러냄(서버 버그) + assert wage == { + "editable": True, + "reason": None, + "overridden": True, + "original": 250000, + "state": "missing", + "rule": None, + "filled": None, + } + # 원본이 바뀐 고친 칸 — 고친 값은 그대로 쓰되 또렷이 알림 · 지금 원본 값을 곁들임(브레인) + assert changed["state"] == "source_changed" and changed["current"] == 250000 + assert same["state"] == "edited" and "current" not in same + assert orphan["state"] == "orphan" + assert hourly["state"] is None and plain["state"] is None + assert hourly["editable"] is False and hourly["reason"] == "계산으로 나옴" + assert hourly["overridden"] is False and "original" not in hourly + assert hourly["rule"] == "기계경비 = 손료 + 연료 + 노무" + assert hourly["filled"] == "12,340 = 8,200 + 2,140 + 2,000" + assert plain["editable"] is False and plain["reason"] is None and plain["rule"] is None + # 수 칸은 쉼표를 떼고 수로 · 못 읽으면 저장 안 함 · 글자 칸은 글자 그대로 · 빈 칸에 수를 적으면 수 + assert got["parsed"] == [12340, "invalid", "invalid", "보통인부2", 3.5] diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 8194f52d..a170ecde 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -140,10 +140,54 @@ export const ui_locales_b3 = { B01_Dashboard_SystemSettings: ["시스템 설정", "System Settings"], B01_Dashboard_MasterData: ["마스터 데이터", "Master Data"], Z01_MasterData_Title: ["마스터 데이터", "Master Data"], - Z01_MasterData_Subtitle: [ - "로직·기초값을 표로 봄 — 읽기 전용", - "Browse logic and base values — read only", + Z01_MasterData_Tree: ["마스터 트리", "Master tree"], + Z01_MasterData_BasePrices: ["기초단가", "Base prices"], + Z01_MasterData_Base_labor: ["노임단가", "Labor"], + Z01_MasterData_Base_machine: ["기계단가", "Machine"], + Z01_MasterData_Base_material: ["자재단가", "Material"], + Z01_MasterData_Base_oil: ["유가", "Fuel"], + Z01_MasterData_Base_rate: ["요율", "Rates"], + Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"], + Z01_MasterData_Rate_Notice: [ + "법이 정한 값 — 고치면 갱신 때 주인을 잃기 쉬움", + "Values set by law — edits may lose their row when the law is updated", ], + Z01_MasterData_Edit_Can: [ + "고칠 수 있는 칸 — 눌러 고치고 Enter 로 저장 (Esc 취소)", + "Editable — click, type, Enter to save (Esc cancels)", + ], + Z01_MasterData_Edit_Locked: ["못 고치는 칸", "Not editable"], + Z01_MasterData_Edit_NotListed: ["고칠 수 있는 칸 목록에 없음", "Not in the editable list"], + Z01_MasterData_Edit_RevertTo: ["원래 값 {value} 으로 되돌리기", "Revert to original {value}"], + Z01_MasterData_Edit_TakeSource: ["새 원본 값 {value} 으로 받기", "Take new source value {value}"], + Z01_MasterData_Edit_OriginalWas: ["고칠 때 원본 {value}", "source when edited {value}"], + Z01_MasterData_Edit_Invalid: ["수로 못 읽음 — 저장 안 함", "Not a number — not saved"], + Z01_MasterData_Edit_Saved: ["저장했음 — 덮개 파일에 쌓임", "Saved to the override file"], + Z01_MasterData_Edit_Reverted: ["덮개를 뺐음 — 원본 값을 씀", "Override removed — using source"], + Z01_MasterData_Edit_Failed: ["저장 못 함", "Save failed"], + Z01_MasterData_Formula: ["산출근거", "Basis"], + Z01_MasterData_State_Edited: ["고쳐짐 — 원래 값 {value}", "Edited — original {value}"], + Z01_MasterData_State_SourceChanged: [ + "⚠ 고친 뒤 원본이 바뀜 — 고친 값을 그대로 씀 · 새 원본 값 {value}", + "⚠ Source changed after edit — edited value still used · new source {value}", + ], + Z01_MasterData_State_Orphan: [ + "⚠ 주인 없음 — 갱신으로 그 줄이 사라짐", + "⚠ Orphan — the row disappeared on update", + ], + Z01_MasterData_State_Missing: [ + "⚠ 서버가 판정(state)을 안 줌 — 버그", + "⚠ Server sent no state — bug", + ], + Z01_MasterData_List_State: ["상태", "State"], + Z01_MasterData_List_Row: ["줄", "Row"], + Z01_MasterData_List_Column: ["칸", "Column"], + Z01_MasterData_List_Value: ["고친 값", "Edited value"], + Z01_MasterData_List_Original: ["고칠 때 원본", "Source when edited"], + Z01_MasterData_List_Current: ["지금 원본", "Current source"], + Z01_MasterData_List_SourceChanged: ["⚠ 원본 바뀜", "⚠ Source changed"], + Z01_MasterData_List_Orphan: ["⚠ 주인 없음", "⚠ Orphan"], + Z01_MasterData_List_Edited: ["고쳐짐", "Edited"], Z01_MasterData_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"], Z01_MasterData_LoadFailed: ["마스터 데이터를 못 읽음", "Failed to load master data"], Z01_MasterData_PickTable: ["왼쪽에서 표를 고를 것", "Pick a table on the left"], diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 9d1dfccc..53cf2505 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -13,9 +13,12 @@ const PROGRESS_OVERLAY_POSITION_KEY = "frd_workflow_progress_overlay_pos"; export interface WorkflowOverlayOptions { title: string; - progressContent: HTMLElement; + /** 없으면 진행단계 패널을 안 세움 — 워크플로 밖 화면(Z01 마스터 데이터)용. */ + progressContent?: HTMLElement; optionsContent?: HTMLElement; showTitlePanel?: boolean; + /** false 면 제목 줄 오른쪽 프로젝트 이름을 안 붙임 — 프로젝트와 무관한 화면용. */ + showProjectName?: boolean; onOptionsOpenChange?: (isOpen: boolean) => void; } @@ -239,15 +242,6 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo const root = document.createElement("div"); root.className = "ui-workflow-overlay"; - const progressBody = document.createElement("div"); - progressBody.append(options.progressContent); - - const progressPanel = createPanel( - "progress", - t("Workflow_Progress_Title"), - progressBody, - PROGRESS_OVERLAY_STATE_KEY, - ); let setTitleOpen = (_isOpen: boolean): void => {}; if (options.showTitlePanel !== false) { const titleBody = document.createElement("div"); @@ -258,16 +252,24 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo titleBody, TITLE_OVERLAY_STATE_KEY, options.onOptionsOpenChange, - createProjectNameTag(), + options.showProjectName === false ? undefined : createProjectNameTag(), ); root.append(titlePanel.root); setTitleOpen = titlePanel.setOpen; } - root.append(progressPanel.root); + let setProgressOpen = (_isOpen: boolean): void => {}; + if (options.progressContent) { + const progressBody = document.createElement("div"); + progressBody.append(options.progressContent); + const progressPanel = createPanel( + "progress", + t("Workflow_Progress_Title"), + progressBody, + PROGRESS_OVERLAY_STATE_KEY, + ); + root.append(progressPanel.root); + setProgressOpen = progressPanel.setOpen; + } - return { - root, - setTitleOpen, - setProgressOpen: progressPanel.setOpen, - }; + return { root, setTitleOpen, setProgressOpen }; }