feat(M02): 관리 화면 항목 나무 — 좌측 폴더 나무 · 표 ↔ 나무 고름 · 오른쪽 클릭 더하기 · 이름 바꾸기 · 옮기기 · 지우기 (PLAN 26-3a)
- 좌측 「구조물 집계 양식」 아래 「항목 나무」 카드(세 층 공용 · 깊이 자유 · 검색 · 키보드 위아래 · 펼침 · F2 · Delete)
- 항목 누름 → 표 그 열 고름 + 카드 넷 · 표에서 열 고름 → 나무 표시
- 나무 고침 POST /api/m02/layers/{층}/tree · 판 409 다시 읽고 알림 · 저장 안 한 표 고침은 저장/버림/취소 물음
- 읽기만 층은 메뉴 · 키 없음 · 나무 메뉴 자리는 sub5 의 ui_template_tree 그대로
- 시험 test_m02_treepanel 8 · test_m02_layer_screen 문구 맞춤
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
@@ -403,3 +403,39 @@ export const saveInputLinks = (
|
||||
줄: InputLine[],
|
||||
): Promise<{ 판: string }> =>
|
||||
call(itemPath(layer, key, "input-links"), { method: "PUT", body: JSON.stringify({ 판, 줄 }) });
|
||||
|
||||
/* --- 항목 나무(PLAN 26-1 · 26-3a) — 폴더 경로 깊이 자유 · 고침은 층 쓰기 권한 --- */
|
||||
export interface TreeItem {
|
||||
key: string;
|
||||
열: string;
|
||||
경로: string[];
|
||||
이름: string;
|
||||
단위?: string | null;
|
||||
일위대가?: string | null;
|
||||
}
|
||||
export interface TreeNote {
|
||||
op: string;
|
||||
경로?: string[];
|
||||
열?: string;
|
||||
지운항목?: string[];
|
||||
}
|
||||
export interface TreeView {
|
||||
판: string;
|
||||
폴더: string[][];
|
||||
items: TreeItem[];
|
||||
알림?: TreeNote;
|
||||
}
|
||||
export interface TreeEdit {
|
||||
op: "add-folder" | "add-item" | "rename" | "move" | "delete";
|
||||
경로?: string[];
|
||||
이름?: string;
|
||||
새경로?: string[];
|
||||
키?: string;
|
||||
단위?: string;
|
||||
}
|
||||
|
||||
export const fetchTree = (layer: Layer): Promise<TreeView> => call(`/layers/${layer}/tree`);
|
||||
|
||||
/** 판이 다르면 409(`StaleError`) — 부르는 쪽이 다시 읽음 */
|
||||
export const editTree = (layer: Layer, edit: TreeEdit, 판: string): Promise<TreeView> =>
|
||||
call(`/layers/${layer}/tree`, { method: "POST", body: JSON.stringify({ ...edit, 판 }) });
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/* 관리 화면 항목 나무 — 줄 모양은 공용 ui-tree · ui-sidebar-row */
|
||||
.m02-tree {
|
||||
padding-top: var(--spacing-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-tree__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 55vh;
|
||||
min-height: 4rem;
|
||||
overflow: auto;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.m02-tree__body .ui-tree__row {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-tree__hint {
|
||||
margin: 0;
|
||||
padding: var(--spacing-8);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-treedlg {
|
||||
width: min(24rem, calc(100vw - 2 * var(--spacing-16)));
|
||||
}
|
||||
|
||||
.m02-treedlg .ui-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_TreePanel.ts
|
||||
* 관리 화면(마스터 · 회사 · 개인 공용) 좌측 「항목 나무」 — 폴더 → … → 항목(깊이 자유) · 찾기.
|
||||
* 항목을 누르면 표에서 그 열이 골라짐(카드 넷은 표가 엶) · 표에서 열을 고르면 `select` 로 나무도 그 항목.
|
||||
* 오른쪽 클릭 · 키보드(F2 · Delete)로 폴더 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 —
|
||||
* `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 메뉴 · 키 없음. 나무 자료는 `_Model`.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./M02_MasterTemplete_TreePanel.css";
|
||||
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createTreeRow,
|
||||
openTreeMenu,
|
||||
renderTree,
|
||||
type MakeRow,
|
||||
type TreeMenuItem,
|
||||
type TreeNode,
|
||||
} from "@ui/ui_template_tree";
|
||||
import {
|
||||
StaleError,
|
||||
editTree,
|
||||
fetchTree,
|
||||
type Layer,
|
||||
type TreeEdit,
|
||||
type TreeItem,
|
||||
type TreeNote,
|
||||
type TreeView,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import {
|
||||
ancestorsOf,
|
||||
buildFolders,
|
||||
countItems,
|
||||
folderIdOf,
|
||||
folderOf,
|
||||
isUnder,
|
||||
itemIdOf,
|
||||
leafName,
|
||||
listFolders,
|
||||
samePath,
|
||||
type FolderNode,
|
||||
} from "./M02_MasterTemplete_TreePanel_Model";
|
||||
|
||||
export interface TreePanelOptions {
|
||||
layer: Layer;
|
||||
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
|
||||
canEdit: boolean;
|
||||
/** 항목을 누름 — 표에서 그 열을 고름 */
|
||||
onPick: (item: TreeItem) => void;
|
||||
/** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */
|
||||
settle: () => Promise<false | "clean" | "changed">;
|
||||
/** 나무가 바뀜 — 표를 다시 읽고 그 열(`colId`)을 고름 */
|
||||
onChanged: (colId: string | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TreePanelHandle {
|
||||
root: HTMLElement;
|
||||
refresh: () => Promise<void>;
|
||||
/** 표에서 고른 열을 나무에 표시(null = 지움) */
|
||||
select: (colId: string | null) => void;
|
||||
}
|
||||
|
||||
type Target = { kind: "folder"; path: string[] } | { kind: "item"; item: TreeItem };
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint", text });
|
||||
|
||||
export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
const layerLabel = L(
|
||||
opt.layer === "company"
|
||||
? "M02_PickerCompany"
|
||||
: opt.layer === "personal"
|
||||
? "M02_PickerPersonal"
|
||||
: "M02_PickerSystem",
|
||||
);
|
||||
let view: TreeView | null = null;
|
||||
let text = "";
|
||||
let activeCol: string | null = null;
|
||||
let seq = 0;
|
||||
let busy = false;
|
||||
const open = new Set<string>([folderIdOf([])]);
|
||||
const targets = new Map<string, Target>();
|
||||
|
||||
const search = createInputField({ placeholder: L("M02_TreeSearch"), compact: true });
|
||||
const body = el("div", { className: "m02-tree__body", attrs: { tabindex: "-1" } });
|
||||
const root = el("div", { className: "m02-tree", children: [search.root, body] });
|
||||
|
||||
/* --- 그리기 --- */
|
||||
const menuOf = (target: Target): TreeMenuItem[] => {
|
||||
const isTop = target.kind === "folder" && !target.path.length;
|
||||
return [
|
||||
...(target.kind === "folder"
|
||||
? [
|
||||
{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) },
|
||||
{ label: L("M02_TreeAddItem"), run: () => void addItem(target.path) },
|
||||
]
|
||||
: []),
|
||||
...(isTop
|
||||
? []
|
||||
: [
|
||||
{ label: L("M02_TreeRename"), run: () => void rename(target) },
|
||||
{ label: L("M02_TreeMove"), run: () => void move(target) },
|
||||
{ label: L("M02_Delete"), run: () => void remove(target) },
|
||||
]),
|
||||
];
|
||||
};
|
||||
|
||||
function node(target: Target, base: Omit<TreeNode, "id" | "menu">, id: string): TreeNode {
|
||||
targets.set(id, target);
|
||||
return { ...base, id, ...(opt.canEdit ? { menu: () => menuOf(target) } : {}) };
|
||||
}
|
||||
|
||||
function folderNode(folder: FolderNode, searching: boolean): TreeNode {
|
||||
const id = folderIdOf(folder.path);
|
||||
const kids = [
|
||||
...folder.folders.map((one) => folderNode(one, searching)),
|
||||
...folder.items.map((item) =>
|
||||
node(
|
||||
{ kind: "item", item },
|
||||
{
|
||||
label: leafName(item),
|
||||
count: null,
|
||||
tip: `${item.이름} · ${item.key}`,
|
||||
run: () => opt.onPick(item),
|
||||
},
|
||||
itemIdOf(item),
|
||||
),
|
||||
),
|
||||
];
|
||||
return node(
|
||||
{ kind: "folder", path: folder.path },
|
||||
{
|
||||
label: folder.path.length ? folder.path[folder.path.length - 1] : layerLabel,
|
||||
count: countItems(folder),
|
||||
children: kids,
|
||||
open: searching || open.has(id),
|
||||
run: () => void (open.has(id) ? open.delete(id) : open.add(id)),
|
||||
},
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
const make: MakeRow = (one, caret, depth, onClick) => {
|
||||
const button = createTreeRow(one, caret, depth, onClick);
|
||||
button.dataset.node = one.id;
|
||||
button.classList.toggle("is-active", !!activeCol && one.id === `i|${activeCol}`);
|
||||
return button;
|
||||
};
|
||||
|
||||
function paint(): void {
|
||||
const focused = (document.activeElement as HTMLElement | null)?.dataset?.node;
|
||||
targets.clear();
|
||||
if (!view) return body.replaceChildren(hint(L("M02_PickerLoading")));
|
||||
const searching = text.trim() !== "";
|
||||
const tree = buildFolders(view, text);
|
||||
const none = countItems(tree) === 0 && (searching || !tree.folders.length);
|
||||
body.replaceChildren(
|
||||
...renderTree([folderNode(tree, searching)], make),
|
||||
...(none
|
||||
? [
|
||||
hint(
|
||||
L(searching ? "M02_TreeNoHit" : opt.canEdit ? "M02_TreeEmptyEdit" : "M02_TreeEmpty"),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
if (focused) rowOf(focused)?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
const rowOf = (id: string): HTMLElement | null =>
|
||||
[...body.querySelectorAll<HTMLElement>("[data-node]")].find((b) => b.dataset.node === id) ??
|
||||
null;
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const mine = ++seq;
|
||||
try {
|
||||
const got = await fetchTree(opt.layer);
|
||||
if (mine !== seq) return;
|
||||
view = got;
|
||||
} catch (error) {
|
||||
if (mine !== seq) return;
|
||||
view = null;
|
||||
body.replaceChildren(hint(L("M02_PickerLoadFailed").replace("{value}", why(error))));
|
||||
return;
|
||||
}
|
||||
paint();
|
||||
}
|
||||
|
||||
function select(colId: string | null): void {
|
||||
activeCol = colId;
|
||||
const need =
|
||||
view && colId ? ancestorsOf(view, colId).filter((p) => !open.has(folderIdOf(p))) : [];
|
||||
if (need.length) {
|
||||
need.forEach((p) => open.add(folderIdOf(p)));
|
||||
paint();
|
||||
} else {
|
||||
body
|
||||
.querySelectorAll<HTMLElement>("[data-node]")
|
||||
.forEach((b) =>
|
||||
b.classList.toggle("is-active", !!colId && b.dataset.node === `i|${colId}`),
|
||||
);
|
||||
}
|
||||
if (colId) rowOf(`i|${colId}`)?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
/* --- 고치기 --- */
|
||||
/** 저장 안 한 표 고침 처리 → (바뀌었으면 나무 다시 읽음) → 참이면 계속 */
|
||||
async function ready(): Promise<boolean> {
|
||||
const settled = await opt.settle();
|
||||
if (!settled) return false;
|
||||
if (settled === "changed") await refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function edit(body_: TreeEdit, keepCol: string | null = activeCol): Promise<void> {
|
||||
if (!view || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const got = await editTree(opt.layer, body_, view.판);
|
||||
view = got;
|
||||
const note: TreeNote | undefined = got.알림;
|
||||
// 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로
|
||||
const gone = note?.지운항목 ?? [];
|
||||
const col = note?.열 ?? (keepCol && !gone.includes(keepCol) ? keepCol : null);
|
||||
activeCol = col;
|
||||
const parent = note?.경로 ? note.경로.slice(0, -1) : [];
|
||||
parent.forEach((_, i) => open.add(folderIdOf(parent.slice(0, i + 1))));
|
||||
if (note?.op === "add-folder" || note?.op === "rename" || note?.op === "move") {
|
||||
if (note.경로) open.add(folderIdOf(note.경로));
|
||||
}
|
||||
paint();
|
||||
showToast(L("M02_TreeDone"), "success");
|
||||
await opt.onChanged(col);
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError) {
|
||||
await refresh();
|
||||
showToast(L("M02_TreeStale"), "error");
|
||||
} else {
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
const nameOf = (target: Target): string =>
|
||||
target.kind === "item" ? leafName(target.item) : target.path[target.path.length - 1];
|
||||
|
||||
async function addFolder(parent: string[]): Promise<void> {
|
||||
if (!(await ready())) return;
|
||||
const got = await askText(L("M02_TreeAddFolder"), [
|
||||
{ key: "name", label: L("M02_TreeFolderName") },
|
||||
]);
|
||||
if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name });
|
||||
}
|
||||
|
||||
async function addItem(parent: string[]): Promise<void> {
|
||||
if (!(await ready())) return;
|
||||
const got = await askText(L("M02_TreeAddItem"), [
|
||||
{ key: "name", label: L("M02_TreeItemName") },
|
||||
{ key: "unit", label: L("M02_TreeUnit"), optional: true },
|
||||
]);
|
||||
if (got) {
|
||||
await edit({
|
||||
op: "add-item",
|
||||
경로: parent,
|
||||
이름: got.name,
|
||||
...(got.unit ? { 단위: got.unit } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function rename(target: Target): Promise<void> {
|
||||
if (!(await ready())) return;
|
||||
const got = await askText(L("M02_TreeRename"), [
|
||||
{
|
||||
key: "name",
|
||||
label: L(target.kind === "item" ? "M02_TreeItemName" : "M02_TreeFolderName"),
|
||||
value: nameOf(target),
|
||||
},
|
||||
]);
|
||||
if (!got || got.name === nameOf(target)) return;
|
||||
await edit(
|
||||
target.kind === "item"
|
||||
? { op: "rename", 경로: folderOf(target.item), 키: target.item.key, 이름: got.name }
|
||||
: { op: "rename", 경로: target.path, 이름: got.name },
|
||||
);
|
||||
}
|
||||
|
||||
async function move(target: Target): Promise<void> {
|
||||
if (!(await ready()) || !view) return;
|
||||
const from = target.kind === "item" ? folderOf(target.item) : target.path.slice(0, -1);
|
||||
const to = await askFolder(
|
||||
L("M02_TreeMove"),
|
||||
listFolders(buildFolders(view)),
|
||||
(path) => target.kind === "folder" && isUnder(path, target.path),
|
||||
from,
|
||||
);
|
||||
if (!to || samePath(to, from)) return;
|
||||
await edit(
|
||||
target.kind === "item"
|
||||
? { op: "move", 경로: from, 키: target.item.key, 새경로: to }
|
||||
: { op: "move", 경로: target.path, 새경로: to },
|
||||
);
|
||||
}
|
||||
|
||||
async function remove(target: Target): Promise<void> {
|
||||
if (!(await ready()) || !view) return;
|
||||
const folder =
|
||||
target.kind === "folder" ? findFolder(buildFolders(view), target.path) : undefined;
|
||||
const message =
|
||||
target.kind === "item"
|
||||
? L("M02_TreeDeleteItem").replace("{value}", leafName(target.item))
|
||||
: L("M02_TreeDeleteFolder")
|
||||
.replace("{value}", nameOf(target))
|
||||
.replace("{n}", String(folder ? countItems(folder) : 0));
|
||||
if (!(await showConfirmDialog(message, L("M02_Delete")))) return;
|
||||
await edit(
|
||||
target.kind === "item"
|
||||
? { op: "delete", 경로: folderOf(target.item), 키: target.item.key }
|
||||
: { op: "delete", 경로: target.path },
|
||||
);
|
||||
}
|
||||
|
||||
const findFolder = (from: FolderNode, path: string[]): FolderNode | undefined =>
|
||||
samePath(from.path, path)
|
||||
? from
|
||||
: from.folders.map((one) => findFolder(one, path)).find(Boolean);
|
||||
|
||||
/* --- 이음 --- */
|
||||
search.input.addEventListener("input", () => {
|
||||
text = search.input.value;
|
||||
paint();
|
||||
});
|
||||
|
||||
// 빈 자리 오른쪽 클릭 = 맨 위 메뉴(폴더 · 항목 더하기)
|
||||
body.addEventListener("contextmenu", (event) => {
|
||||
if (!opt.canEdit || (event.target as HTMLElement).closest("[data-node]")) return;
|
||||
event.preventDefault();
|
||||
openTreeMenu(event, menuOf({ kind: "folder", path: [] }).slice(0, 2));
|
||||
});
|
||||
|
||||
body.addEventListener("keydown", (event) => {
|
||||
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-node]");
|
||||
if (!row) return;
|
||||
const rows = [...body.querySelectorAll<HTMLElement>("[data-node]")].filter(
|
||||
(b) => !b.closest("[hidden]"),
|
||||
);
|
||||
const at = rows.indexOf(row);
|
||||
const caret = row.querySelector(".ui-tree__caret")?.textContent;
|
||||
const target = targets.get(row.dataset.node ?? "");
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
rows[at + (event.key === "ArrowDown" ? 1 : -1)]?.focus();
|
||||
} else if (
|
||||
(event.key === "ArrowRight" && caret === "▸") ||
|
||||
(event.key === "ArrowLeft" && caret === "▾")
|
||||
) {
|
||||
event.preventDefault();
|
||||
row.click();
|
||||
} else if (opt.canEdit && target && event.key === "F2") {
|
||||
event.preventDefault();
|
||||
if (!(target.kind === "folder" && !target.path.length)) void rename(target);
|
||||
} else if (opt.canEdit && target && event.key === "Delete") {
|
||||
event.preventDefault();
|
||||
if (!(target.kind === "folder" && !target.path.length)) void remove(target);
|
||||
}
|
||||
});
|
||||
|
||||
return { root, refresh, select };
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_TreePanel_Dialogs.ts
|
||||
* 항목 나무 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음.
|
||||
* 모두 약속(Promise)으로 답함 — 닫기 · Esc · 바깥 누르기 = 취소(null / "cancel").
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
|
||||
export interface TextField {
|
||||
key: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
/** 비워도 됨(항목 단위) */
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
/** 글 칸 하나 이상을 받음 — 확인이면 칸 이름 → 글(앞뒤 공백 뺌) */
|
||||
export function askText(
|
||||
title: string,
|
||||
fields: TextField[],
|
||||
okLabel = L("M02_TreeOk"),
|
||||
): Promise<Record<string, string> | null> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value: Record<string, string> | null): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve(value);
|
||||
};
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: L("M02_Close"),
|
||||
dialogClass: "m02-modal m02-treedlg",
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body, close) => {
|
||||
const inputs = fields.map((f) => ({
|
||||
f,
|
||||
field: createInputField({ label: f.label, value: f.value }),
|
||||
}));
|
||||
const ok = createButton({ label: okLabel, variant: "filled" });
|
||||
const submit = (): void => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const { f, field } of inputs) {
|
||||
out[f.key] = field.input.value.trim();
|
||||
if (!out[f.key] && !f.optional) return showToast(L("M02_NameNeeded"), "error");
|
||||
}
|
||||
finish(out);
|
||||
close();
|
||||
};
|
||||
ok.addEventListener("click", submit);
|
||||
for (const { field } of inputs) {
|
||||
field.input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.isComposing) submit();
|
||||
});
|
||||
}
|
||||
body.append(...inputs.map(({ field }) => field.root), ok);
|
||||
inputs[0]?.field.input.select();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 */
|
||||
export function askFolder(
|
||||
title: string,
|
||||
folders: string[][],
|
||||
blocked: (path: string[]) => boolean,
|
||||
current: string[],
|
||||
): Promise<string[] | null> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value: string[] | null): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve(value);
|
||||
};
|
||||
const choices = [[], ...folders.filter((path) => !blocked(path))];
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: L("M02_Close"),
|
||||
dialogClass: "m02-modal m02-treedlg",
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body, close) => {
|
||||
const field = createSelectField({
|
||||
label: L("M02_TreeMoveTo"),
|
||||
options: choices.map((path, i) => ({
|
||||
value: String(i),
|
||||
text: path.length ? path.join(" / ") : L("M02_TreeTop"),
|
||||
})),
|
||||
value: String(choices.findIndex((path) => path.join("|") === current.join("|"))),
|
||||
});
|
||||
const ok = createButton({ label: L("M02_TreeOk"), variant: "filled" });
|
||||
ok.addEventListener("click", () => {
|
||||
finish(choices[Number(field.select.value)] ?? []);
|
||||
close();
|
||||
});
|
||||
body.append(field.root, ok);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type Settle = "save" | "discard" | "cancel";
|
||||
|
||||
/** 저장 안 한 표 고침 — 저장하고 계속 · 버리고 계속 · 취소 */
|
||||
export function askSettle(): Promise<Settle> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value: Settle): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve(value);
|
||||
};
|
||||
openModal({
|
||||
title: L("M02_TreeUnsaved"),
|
||||
closeLabel: L("M02_TreeCancel"),
|
||||
dialogClass: "m02-modal m02-treedlg",
|
||||
beforeClose: () => (finish("cancel"), true),
|
||||
mount: (body, close) => {
|
||||
const choose = (value: Settle) => () => {
|
||||
finish(value);
|
||||
close();
|
||||
};
|
||||
const save = createButton({ label: L("M02_TreeSaveGo"), variant: "filled" });
|
||||
const discard = createButton({ label: L("M02_TreeDiscardGo"), variant: "danger" });
|
||||
const cancel = createButton({ label: L("M02_TreeCancel"), variant: "ghost" });
|
||||
save.addEventListener("click", choose("save"));
|
||||
discard.addEventListener("click", choose("discard"));
|
||||
cancel.addEventListener("click", choose("cancel"));
|
||||
body.append(el("div", { className: "m02-side__row", children: [save, discard, cancel] }));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_TreePanel_Model.ts
|
||||
* 관리 화면 항목 나무의 자료 쪽(화면 없음) — 서버 나무(`폴더` + `items`)를 폴더 나무로 · 찾기 ·
|
||||
* 폴더 목록. 항목 경로는 폴더 이름 배열 + 마지막 마디가 항목 이름(PLAN 26-1 · 깊이 자유).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { TreeItem, TreeView } from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
export interface FolderNode {
|
||||
/** 이 폴더까지의 경로 — 맨 위 = 빈 배열 */
|
||||
path: string[];
|
||||
folders: FolderNode[];
|
||||
items: TreeItem[];
|
||||
}
|
||||
|
||||
export const samePath = (a: string[], b: string[]): boolean =>
|
||||
a.length === b.length && a.every((part, i) => part === b[i]);
|
||||
|
||||
/** `path` 가 `folder` 와 같거나 그 안인가 */
|
||||
export const isUnder = (path: string[], folder: string[]): boolean =>
|
||||
folder.length <= path.length && folder.every((part, i) => part === path[i]);
|
||||
|
||||
export const folderIdOf = (path: string[]): string => `f|${path.join("|")}`;
|
||||
export const itemIdOf = (item: TreeItem): string => `i|${item.열}`;
|
||||
export const leafName = (item: TreeItem): string => item.경로[item.경로.length - 1] ?? item.이름;
|
||||
export const folderOf = (item: TreeItem): string[] => item.경로.slice(0, -1);
|
||||
|
||||
/** 찾기 — 경로 · 키 · 일위대가 키에 모든 낱말이 들어 있는 항목(빈 글이면 전부) */
|
||||
export function matches(item: TreeItem, text: string): boolean {
|
||||
const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const hay = [...item.경로, item.key, item.일위대가 ?? ""].join(" ").toLowerCase();
|
||||
return words.every((word) => hay.includes(word));
|
||||
}
|
||||
|
||||
/** 폴더 나무 — 찾는 글이 있으면 맞는 항목과 그 위 폴더만 · 없으면 빈 폴더까지 전부 */
|
||||
export function buildFolders(view: TreeView, text = ""): FolderNode {
|
||||
const searching = text.trim() !== "";
|
||||
const root: FolderNode = { path: [], folders: [], items: [] };
|
||||
const at = (path: string[]): FolderNode =>
|
||||
path.reduce((node, name, i) => {
|
||||
let next = node.folders.find((f) => f.path[i] === name);
|
||||
if (!next) {
|
||||
next = { path: path.slice(0, i + 1), folders: [], items: [] };
|
||||
node.folders.push(next);
|
||||
}
|
||||
return next;
|
||||
}, root);
|
||||
if (!searching) view.폴더.forEach(at);
|
||||
for (const item of view.items) if (matches(item, text)) at(folderOf(item)).items.push(item);
|
||||
return root;
|
||||
}
|
||||
|
||||
export const countItems = (node: FolderNode): number =>
|
||||
node.items.length + node.folders.reduce((sum, f) => sum + countItems(f), 0);
|
||||
|
||||
/** 모든 폴더 경로(맨 위 제외) — 옮길 곳 고르기 */
|
||||
export function listFolders(root: FolderNode): string[][] {
|
||||
return root.folders.flatMap((f) => [f.path, ...listFolders(f)]);
|
||||
}
|
||||
|
||||
/** 그 열이 들어 있는 폴더 길 — 나무를 그 항목까지 펼칠 때 */
|
||||
export function ancestorsOf(view: TreeView, colId: string): string[][] {
|
||||
const item = view.items.find((one) => one.열 === colId);
|
||||
if (!item) return [];
|
||||
const folder = folderOf(item);
|
||||
return folder.map((_, i) => folder.slice(0, i + 1));
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type StructurePanelHandle,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { inputPills } from "./M02_MasterTemplete_Inputs";
|
||||
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { buildComboBar } from "./M02_MasterTemplete_Combo";
|
||||
import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel";
|
||||
import {
|
||||
@@ -91,6 +92,10 @@ export interface MainHandle {
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 양식 밖 화면(좌측 자재 조합 상세)을 띄움 — 머리는 제목만 · 고침 표시 손잡이를 돌려줌 */
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 구조물집계표를 열고 그 열을 고름 — 이미 표면 다시 안 읽음(`fresh` 면 다시 읽음) */
|
||||
openTable: (layer: Layer, colId?: string | null, fresh?: boolean) => Promise<void>;
|
||||
/** 나무를 고치기 전 — 저장 안 한 고침이 있으면 저장 · 버림 · 취소를 물음 */
|
||||
settle: () => Promise<false | "clean" | "changed">;
|
||||
}
|
||||
|
||||
/** 이 층에서 고칠 수 있나 — `isAdmin` = 이 화면 층의 고칠 권한(페이지가 층별로 정함) */
|
||||
@@ -501,6 +506,28 @@ export function createMain(
|
||||
return (d) => (dirty = d);
|
||||
}
|
||||
|
||||
async function openTable(layer: Layer, colId?: string | null, fresh = false): Promise<void> {
|
||||
const inTable = sel?.kind === "table" && sel.name === STRUCTURE_TABLE && !fresh;
|
||||
if (!inTable) {
|
||||
if (!fresh && !(await confirmLeave())) return;
|
||||
await open({ layer, projectId: null, kind: "table", name: STRUCTURE_TABLE });
|
||||
}
|
||||
if (colId && sel?.kind === "table") sheet?.selectColumn(colId);
|
||||
}
|
||||
|
||||
async function settle(): Promise<false | "clean" | "changed"> {
|
||||
if (!dirty) return "clean";
|
||||
const choice = await askSettle();
|
||||
if (choice === "cancel") return false;
|
||||
if (choice === "save") {
|
||||
await doSave();
|
||||
return dirty ? false : "changed"; // 저장이 막히면(판 다름 등) 나무도 안 고침
|
||||
}
|
||||
dirty = false; // 버림 — 화면을 서버 그대로 다시 읽음
|
||||
if (sel) await open(sel);
|
||||
return "changed";
|
||||
}
|
||||
|
||||
save.addEventListener("click", () => void doSave());
|
||||
back.addEventListener("click", async () => {
|
||||
const column = sel?.name;
|
||||
@@ -518,5 +545,5 @@ export function createMain(
|
||||
});
|
||||
bar();
|
||||
empty("");
|
||||
return { root, open, current: () => sel, confirmLeave, showView };
|
||||
return { root, open, current: () => sel, confirmLeave, showView, openTable, settle };
|
||||
}
|
||||
|
||||
@@ -39,7 +39,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
|
||||
isAdmin,
|
||||
() => void side.refresh(),
|
||||
() => side.paint(),
|
||||
{ top: readOnlyBar?.root, onColumn: (column) => readOnlyBar?.setColumn(column) },
|
||||
{
|
||||
top: readOnlyBar?.root,
|
||||
onColumn: (column) => {
|
||||
readOnlyBar?.setColumn(column);
|
||||
side.selectItem(column);
|
||||
},
|
||||
},
|
||||
);
|
||||
// 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음
|
||||
const home: Selection = {
|
||||
@@ -55,6 +61,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
|
||||
getOpen: main.current,
|
||||
confirmLeave: main.confirmLeave,
|
||||
showView: main.showView,
|
||||
tree: {
|
||||
onPick: (item) => void main.openTable(layer, item.열),
|
||||
settle: main.settle,
|
||||
onChanged: (colId) => main.openTable(layer, colId, true),
|
||||
},
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
|
||||
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
|
||||
import { createTreePanel, type TreePanelOptions } from "./M02_MasterTemplete_TreePanel";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
@@ -71,6 +72,8 @@ export interface SideOptions {
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 항목 나무(26-3a) — 항목 누름 · 고치기 전 저장 처리 · 고친 뒤 표 다시 읽기 */
|
||||
tree: Pick<TreePanelOptions, "onPick" | "settle" | "onChanged">;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -78,6 +81,8 @@ export interface SideHandle {
|
||||
refresh: () => Promise<void>;
|
||||
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
|
||||
paint: () => void;
|
||||
/** 표에서 고른 열을 항목 나무에 표시 */
|
||||
selectItem: (colId: string | null) => void;
|
||||
}
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
@@ -150,10 +155,15 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
);
|
||||
summary.classList.remove("is-collapsed");
|
||||
summary.classList.add("m02-side__summary");
|
||||
// 항목 나무 — 구조물 집계 양식 아래 · 고르기 모달과 같은 폴더 나무(세 층 공용)
|
||||
const tree = createTreePanel({ layer, canEdit: canEdit(layer, opt.isAdmin), ...opt.tree });
|
||||
const treeSection = section(L("M02_TreeTitle"), tree.root);
|
||||
treeSection.classList.remove("is-collapsed");
|
||||
const listHost = el("div", {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
summary,
|
||||
treeSection,
|
||||
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만
|
||||
...(layer === "system"
|
||||
? [
|
||||
@@ -279,7 +289,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
await Promise.all([refreshStructure(), matset.refresh()]);
|
||||
await Promise.all([refreshStructure(), matset.refresh(), tree.refresh()]);
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
@@ -360,5 +370,5 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
|
||||
void refresh();
|
||||
return { root, refresh, paint };
|
||||
return { root, refresh, paint, selectItem: tree.select };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* M02 항목 나무 자료(`M02_MasterTemplete_TreePanel_Model.ts`) 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고 JSON 으로 냄. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_TreePanel_Model.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const m = box.exports;
|
||||
|
||||
const item = (열, 경로, key) => ({ key, 열, 경로, 이름: 경로.join(" "), 단위: "m", 일위대가: null });
|
||||
const view = {
|
||||
판: "v1",
|
||||
폴더: [["빈 폴더"], ["돌기슭막이", "빈 아래"]],
|
||||
items: [
|
||||
item("c1", ["돌기슭막이", "메쌓기", "1.2m"], "SS000001"),
|
||||
item("c2", ["돌기슭막이", "찰쌓기"], "SS000002"),
|
||||
item("c3", ["다섯", "둘", "셋", "넷", "잎"], "SS000003"),
|
||||
item("c4", ["최상위 항목"], "SS000004"),
|
||||
],
|
||||
};
|
||||
const shape = (n) => ({
|
||||
path: n.path,
|
||||
items: n.items.map((i) => i.열),
|
||||
folders: n.folders.map(shape),
|
||||
});
|
||||
const all = m.buildFolders(view);
|
||||
const found = m.buildFolders(view, "찰쌓기");
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
all: shape(all),
|
||||
count: m.countItems(all),
|
||||
found: shape(found),
|
||||
foundCount: m.countItems(found),
|
||||
none: shape(m.buildFolders(view, "없는말")),
|
||||
folders: m.listFolders(all),
|
||||
ancestors: m.ancestorsOf(view, "c3"),
|
||||
ancestorsTop: m.ancestorsOf(view, "c4"),
|
||||
ancestorsNone: m.ancestorsOf(view, "zz"),
|
||||
under: [
|
||||
m.isUnder(["a", "b"], ["a"]),
|
||||
m.isUnder(["a"], ["a"]),
|
||||
m.isUnder(["a"], ["a", "b"]),
|
||||
m.isUnder(["x"], ["a"]),
|
||||
],
|
||||
leaf: m.leafName(view.items[0]),
|
||||
dir: m.folderOf(view.items[0]),
|
||||
byKey: m.buildFolders(view, "ss000003").items.length + m.buildFolders(view, "SS000003").folders.length,
|
||||
}),
|
||||
);
|
||||
@@ -104,7 +104,7 @@ def test_층_길_접두어와_고정_풀림():
|
||||
def test_읽기만_회사용_개인용으로_복사_막대():
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert 'layer === "company" && !isAdmin ? buildReadOnlyBar(user)' in page
|
||||
assert "onColumn: (column) => readOnlyBar?.setColumn(column)" in page
|
||||
assert "readOnlyBar?.setColumn(column)" in page # 열 고름 → 막대 · 항목 나무가 함께 받음
|
||||
assert "access.needCompany" in page and "buildNeedCompany()" in page
|
||||
assert "titleOf(layer)" in page
|
||||
layer = _text(LAYER)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""M02 관리 화면 항목 나무(PLAN 26-3a) 자료 셈 회귀검증 — 폴더 나무 · 빈 폴더 · 깊이 5 · 찾기 · 폴더 목록.
|
||||
|
||||
항목 경로 = 폴더 이름 배열 + 마지막 마디가 항목 이름 · 찾는 글이 있으면 맞는 항목과 위 폴더만.
|
||||
Node 헬퍼(`helper_m02_treepanel.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_treepanel.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_빈_폴더도_나무에_있음(results):
|
||||
tops = [f["path"] for f in results["all"]["folders"]]
|
||||
assert ["빈 폴더"] in tops
|
||||
dol = next(f for f in results["all"]["folders"] if f["path"] == ["돌기슭막이"])
|
||||
assert ["돌기슭막이", "빈 아래"] in [f["path"] for f in dol["folders"]]
|
||||
|
||||
|
||||
def test_맨_위_항목은_폴더_밖(results):
|
||||
assert results["all"]["items"] == ["c4"]
|
||||
assert results["count"] == 4
|
||||
|
||||
|
||||
def test_깊이_5_항목까지_이어짐(results):
|
||||
node = next(f for f in results["all"]["folders"] if f["path"] == ["다섯"])
|
||||
for depth in range(2, 5):
|
||||
node = node["folders"][0]
|
||||
assert len(node["path"]) == depth
|
||||
assert node["items"] == ["c3"]
|
||||
|
||||
|
||||
def test_찾기는_맞는_항목과_위_폴더만(results):
|
||||
assert results["foundCount"] == 1
|
||||
assert [f["path"] for f in results["found"]["folders"]] == [["돌기슭막이"]]
|
||||
assert results["found"]["folders"][0]["items"] == ["c2"] # 빈 폴더는 안 보임
|
||||
assert results["none"] == {"path": [], "items": [], "folders": []}
|
||||
|
||||
|
||||
def test_키로도_찾음(results):
|
||||
assert results["byKey"] == 1
|
||||
|
||||
|
||||
def test_폴더_목록과_위_길(results):
|
||||
assert ["다섯", "둘", "셋", "넷"] in results["folders"]
|
||||
assert ["빈 폴더"] in results["folders"]
|
||||
assert results["ancestors"] == [
|
||||
["다섯"],
|
||||
["다섯", "둘"],
|
||||
["다섯", "둘", "셋"],
|
||||
["다섯", "둘", "셋", "넷"],
|
||||
]
|
||||
assert results["ancestorsTop"] == []
|
||||
assert results["ancestorsNone"] == []
|
||||
|
||||
|
||||
def test_안_또는_같음_판정(results):
|
||||
assert results["under"] == [True, True, False, False]
|
||||
|
||||
|
||||
def test_잎_이름과_폴더(results):
|
||||
assert results["leaf"] == "1.2m"
|
||||
assert results["dir"] == ["돌기슭막이", "메쌓기"]
|
||||
@@ -97,4 +97,42 @@ export const ui_locales_m2 = {
|
||||
M02_PickerManage: ["관리 화면에서 고치기", "Edit in manager"],
|
||||
M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"],
|
||||
M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"],
|
||||
M02_TreeTitle: ["항목 나무", "Item tree"],
|
||||
M02_TreeSearch: ["항목 찾기", "Find item"],
|
||||
M02_TreeEmptyEdit: [
|
||||
"항목이 없음 — 맨 위 줄을 오른쪽 클릭해 폴더 · 항목을 더함",
|
||||
"No items — right-click the top row to add a folder or item",
|
||||
],
|
||||
M02_TreeEmpty: ["항목이 없음", "No items"],
|
||||
M02_TreeNoHit: ["찾은 항목이 없음", "No matching item"],
|
||||
M02_TreeAddFolder: ["폴더 더하기", "Add folder"],
|
||||
M02_TreeAddItem: ["항목 더하기", "Add item"],
|
||||
M02_TreeRename: ["이름 바꾸기", "Rename"],
|
||||
M02_TreeMove: ["옮기기", "Move"],
|
||||
M02_TreeFolderName: ["폴더 이름", "Folder name"],
|
||||
M02_TreeItemName: ["항목 이름", "Item name"],
|
||||
M02_TreeUnit: ["단위", "Unit"],
|
||||
M02_TreeMoveTo: ["옮길 폴더", "Move to"],
|
||||
M02_TreeTop: ["맨 위 (폴더 밖)", "Top (no folder)"],
|
||||
M02_TreeOk: ["확인", "OK"],
|
||||
M02_TreeCancel: ["취소", "Cancel"],
|
||||
M02_TreeDeleteFolder: [
|
||||
"「{value}」 폴더와 안의 항목 {n}개를 지움 — 이미 쓰는 프로젝트에는 영향 없음(프로젝트는 복사본을 씀)",
|
||||
"Delete folder “{value}” and its {n} item(s) — projects already using them are not affected (they keep their own copies)",
|
||||
],
|
||||
M02_TreeDeleteItem: [
|
||||
"「{value}」 항목을 지움 — 이미 쓰는 프로젝트에는 영향 없음(프로젝트는 복사본을 씀)",
|
||||
"Delete item “{value}” — projects already using it are not affected (they keep their own copies)",
|
||||
],
|
||||
M02_TreeStale: [
|
||||
"그 사이 나무가 바뀜 — 다시 읽었음, 한 번 더 해 볼 것",
|
||||
"The tree changed meanwhile — reloaded, try again",
|
||||
],
|
||||
M02_TreeUnsaved: [
|
||||
"저장 안 한 표 고침이 있음 — 나무를 고치기 전에 처리",
|
||||
"Unsaved table edits — settle them before changing the tree",
|
||||
],
|
||||
M02_TreeSaveGo: ["저장하고 계속", "Save and continue"],
|
||||
M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"],
|
||||
M02_TreeDone: ["나무를 고침", "Tree updated"],
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user