Merge remote-tracking branch 'origin/sub_laptop_3' into main_desktop_1

This commit is contained in:
2026-09-19 18:46:10 +09:00
13 changed files with 1241 additions and 0 deletions
+1
View File
@@ -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 {
+2
View File
@@ -61,6 +61,8 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(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,
};
/** 로그인 여부 (토큰 존재 확인) */
+5
View File
@@ -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;
}
+109
View File
@@ -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<string, unknown>;
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<string, string>;
값칸: Record<string, string>;
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<T>(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<T>(path: string, params: Record<string, string | number>): Promise<T> {
const query = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)]));
const { status, body } = await call<T & { detail?: unknown }>(`${path}?${query}`);
if (status !== 200) throw new Error(String(body.detail ?? `HTTP ${status}`));
return body;
}
export const fetchGroups = async (): Promise<GroupInfo[]> =>
(await get<{ groups: GroupInfo[] }>("/groups", {})).groups;
export const fetchFiles = async (group: string): Promise<FileInfo[]> =>
(await get<{ files: FileInfo[] }>(`/groups/${encodeURIComponent(group)}/files`, {})).files;
export const fetchRows = (file: string, page: number, size: number, q: string): Promise<RowsPage> =>
get<RowsPage>("/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<SaveResult> {
const { status, body } = await call<Record<string, unknown>>("/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) };
}
+161
View File
@@ -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<string, Row>;
adds: Row[];
deletes: string[];
tables: Record<string, TableDraft>;
}
const STORAGE_KEY = "m01_master_draft";
const listeners = new Set<() => void>();
function readStore(): Record<string, FileDraft> {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "{}") as Record<string, FileDraft>;
} 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("");
}
+121
View File
@@ -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<string, unknown> {
const out: Record<string, unknown> = {};
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;
}
+226
View File
@@ -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<void> {
const user = await fetchSessionUser().catch(() => null);
if (user?.role !== "SYSTEM_ADMIN") {
showToast(L("M01_AdminOnly"), "error");
navigateTo(ROUTES.B01_ACCOUNT);
return;
}
showLoadingOverlay();
let groups: GroupInfo[] = [];
try {
groups = (await fetchGroups()).filter((g) => g.group !== "로직");
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
} finally {
hideLoadingOverlay();
}
root.innerHTML = "";
root.append(buildPage(groups));
}
function buildPage(groups: GroupInfo[]): HTMLElement {
let group = "";
let current: FileInfo | null = null;
let query = "";
let dispose = (): void => {};
/* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */
const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") });
const search = el("input", {
className: "m01-master__search",
attrs: { type: "search", placeholder: L("M01_Search") },
});
const summary = el("span", { className: "m01-master__summary" });
const save = createButton({ label: L("M01_Save"), variant: "filled" });
const drop = createButton({ label: L("M01_Discard"), variant: "ghost" });
const notice = el("div", { className: "m01-master__notice", attrs: { hidden: "" } });
const body = el("div", { className: "m01-master__body" });
const head = el("div", {
className: "m01-master__head",
children: [title, search, summary, drop, save],
});
const showNotice = (nodes: (HTMLElement | string)[]): void => {
notice.replaceChildren(...nodes);
notice.hidden = nodes.length === 0;
};
const refreshBar = (): void => {
const n = totalCount();
summary.textContent = n ? L("M01_Changed").replace("{value}", String(n)) : "";
save.disabled = drop.disabled = n === 0;
};
onDraftChange(refreshBar);
refreshBar();
const openFile = (file: FileInfo): void => {
dispose();
current = file;
title.textContent = `${group} · ${fileLabel(file.file)}`;
const view = TABLE_GROUPS.includes(group) ? renderTables : renderRows;
dispose = view(body, file.file, query);
showNotice(isStale(file.file, file.version) ? [L("M01_FileStale")] : []);
};
let searchTimer: number | undefined;
search.addEventListener("input", () => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
query = search.value.trim();
if (current) openFile(current);
}, 300);
});
drop.addEventListener("click", () => {
if (!window.confirm(L("M01_DiscardConfirm"))) return;
discard();
showNotice([]);
});
save.addEventListener("click", async () => {
save.disabled = true;
try {
const result = await saveFiles(payload());
if (result.status === 200 && "files" in result) {
discard(result.files.map((f) => f.file));
showNotice([]);
showToast(L("M01_Saved"), "success");
await reloadFiles();
} else if (result.status === 409 && "stale" in result) {
showNotice(staleNotice(result.stale));
} else if (result.status === 422 && "errors" in result) {
showNotice([
el("strong", { text: L("M01_CheckErrors") }),
...result.errors.map((e) => el("p", { text: e })),
]);
} else if ("detail" in result) {
showNotice([L("M01_SaveFailed").replace("{value}", result.detail)]);
}
} catch (error) {
showNotice([
L("M01_SaveFailed").replace("{value}", error instanceof Error ? error.message : ""),
]);
} finally {
refreshBar();
}
});
const staleNotice = (stale: string[]): (HTMLElement | string)[] => {
const again = createButton({ label: L("M01_StaleReload"), variant: "ghost" });
again.addEventListener("click", async () => {
discard(stale);
showNotice([]);
await reloadFiles();
});
return [L("M01_Stale").replace("{value}", stale.map(fileLabel).join(", ")), again];
};
/* --- 좌측: 그룹 탭 + 파일 --- */
const tabs = el("div", { className: "m01-master__tabs" });
const files = el("div", { className: "m01-master__files" });
const reloadFiles = async (): Promise<void> => {
if (!group) return;
let list: FileInfo[] = [];
try {
list = await fetchFiles(group);
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
}
files.replaceChildren(
...list.map((f) => {
const button = el("button", {
className: "m01-master__file",
attrs: { type: "button" },
children: [
el("span", { text: fileLabel(f.file) }),
el("span", { className: "m01-master__muted", text: f.rows.toLocaleString("ko-KR") }),
],
});
button.classList.toggle("is-active", f.file === current?.file);
button.addEventListener("click", () => {
files.querySelector(".is-active")?.classList.remove("is-active");
button.classList.add("is-active");
openFile(f);
});
return button;
}),
);
const again = list.find((f) => f.file === current?.file);
if (again) openFile(again);
};
for (const g of groups) {
const tab = el("button", {
className: "m01-master__tab",
attrs: { type: "button" },
children: [
el("span", { text: g.group }),
el("span", { className: "m01-master__muted", text: String(g.files) }),
],
});
tab.addEventListener("click", () => {
tabs.querySelector(".is-active")?.classList.remove("is-active");
tab.classList.add("is-active");
group = g.group;
current = null;
dispose();
body.replaceChildren();
title.textContent = L("M01_PickFile");
void reloadFiles();
});
tabs.append(tab);
}
const 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;
}
+153
View File
@@ -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<void> => {
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] });
}
+219
View File
@@ -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);
}
+200
View File
@@ -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<HTMLElement>(".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<void> {
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<string, string>;
const values = (table. ?? {}) as Record<string, string>;
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();
}
+3
View File
@@ -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,
];
/* -----------------------------------------------------------------------------
+2
View File
@@ -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;
+39
View File
@@ -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;