Merge remote-tracking branch 'origin/sub_laptop_3' into main_desktop_1
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/** 로그인 여부 (토큰 존재 확인) */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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] });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user