feat(M02): 「〇〇로 복사」 · 끌어다 옮기기 · 켜진 층 단추 = 목록으로 (PLAN 30-2 더함 · 30-4)

- 오른쪽 클릭 「마스터 · 회사 · 개인으로 복사」 — 쓸 수 있는 자기 층(지금 층 뺌) 맨 위에 바로 · 새 키 · 같은 이름 「(2)」
- 끌어다 옮기기 — 같은 층 · 폴더 줄 위 = 그 안 · 빈 자리 = 맨 위 · 고른 것 전부 · 제 안으로 막음 · 읽기 층은 끌기 없음
- 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 · 저장 안 한 고침은 물음)
- 시험 맞춤 · 30-4 시험 한 건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 19:12:25 +09:00
co-authored by Claude Opus 5.5
parent 0f510eb53b
commit d1675e6218
9 changed files with 156 additions and 13 deletions
+10 -2
View File
@@ -44,6 +44,8 @@ export interface LaborLibrary {
tree: HTMLElement;
/** 나무 맨 위 더하기 「+」(층 단추 옆) */
add: HTMLElement;
/** 켜진 층 단추를 다시 누름 — 조합 목록으로(저장 안 한 고침은 물음 · PLAN 30-4) */
back: () => Promise<void>;
refresh: () => Promise<void>;
/** 메인 칸 「저장 안 한 고침」 손잡이 */
bind: (setDirty: (dirty: boolean) => void) => void;
@@ -115,6 +117,12 @@ export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary {
root: el("div", { className: "m02-labor", children: [tree.search, editor.root] }),
tree: tree.root,
add: tree.add,
back: async () => {
await opt.reveal(); // 일위대가 화면이 아니면 먼저 띄움
if (editor.current() === null) return;
if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;
editor.showList();
},
refresh: () => tree.refresh(),
bind: (fn) => (setDirty = fn),
};
@@ -130,7 +138,7 @@ export interface LaborSectionOptions {
/** 라이브러리 — `reveal` = 메인 칸에 일위대가 화면을 띄움(나무 항목을 누를 때) */
make: (reveal: () => Promise<void>) => LaborLibrary;
/** 층 단추(PLAN 29-2) — `add` = 나무 맨 위 더하기 「+」를 단추 옆에 */
bar?: (add: HTMLElement) => HTMLElement;
bar?: (add: HTMLElement, back: () => void) => HTMLElement;
/** 나무를 감쌈 — 남의 회사 「개인」이면 사람 나무 안에(PLAN 29-6) */
wrap?: (tree: HTMLElement) => HTMLElement;
}
@@ -163,7 +171,7 @@ export function buildLaborSection(opt: LaborSectionOptions): LaborSection {
const body = el("div", {
className: "m02-side__items ui-sidebar-list",
children: [
...(opt.bar ? [opt.bar(library.add)] : []),
...(opt.bar ? [opt.bar(library.add, () => void library.back())] : []),
opt.wrap ? opt.wrap(library.tree) : library.tree,
],
});
@@ -82,6 +82,13 @@
display: none;
}
/* 끌어다 놓을 자리 — 폴더 줄 · 빈 자리(맨 위) */
.m02-tree__body .ui-tree__row.is-drop,
.m02-tree__body.is-drop {
outline: 2px dashed var(--color-accent);
outline-offset: -2px;
}
/* 여러 개 고른 줄 배경 */
.m02-tree__body .ui-tree__row.is-picked {
background: var(--color-mist-violet);
@@ -81,6 +81,8 @@ export interface TreePanelOptions {
};
/** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */
clipKind?: string;
/** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */
copyTo?: { layer: Layer; owner: string | null; label: string }[];
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
canEdit: boolean;
/** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */
@@ -157,10 +159,17 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
]
: [];
};
const copyItem = (list: Target[]): TreeMenuItem[] =>
canCopy
? [{ label: L("M02_TreeCopy").replace("{n}", String(list.length)), run: () => copy(list) }]
: [];
const copyItem = (list: Target[]): TreeMenuItem[] => {
if (!canCopy) return [];
const n = String(list.length);
return [
{ label: L("M02_TreeCopy").replace("{n}", n), run: () => copy(list) },
...(opt.copyTo ?? []).map((to) => ({
label: to.label.replace("{n}", n),
run: () => void copyTo(list, to),
})),
];
};
/** 맨 위(빈 자리 오른쪽 클릭 · 「+」) — 폴더 · 항목 더하기 · 붙여넣기 */
const topMenu = (): TreeMenuItem[] => [
...(opt.canEdit ? menuOf({ kind: "folder", path: [] }) : []),
@@ -272,9 +281,72 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
},
{ capture: true },
);
// 끌어다 옮기기(30-2) — 쓸 수 있는 층만 · 고른 것 안의 줄을 끌면 고른 것 전부
if (opt.canEdit) {
button.draggable = true;
button.addEventListener("dragstart", (event) => {
const own = targets.get(one.id);
dragging = picked.size > 1 && picked.has(one.id) ? pickedTargets() : own ? [own] : null;
event.dataTransfer?.setData("text/plain", one.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
});
button.addEventListener("dragend", () => {
dragging = null;
dropMark(null);
});
}
return button;
};
/* --- 끌어다 놓기: 폴더 줄 위 = 그 안 · 빈 자리 = 맨 위 · 제 안으로는 안 됨 --- */
let dragging: Target[] | null = null;
const dropDest = (event: DragEvent): string[] | null => {
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-node]");
const target = row ? targets.get(row.dataset.node ?? "") : undefined;
const dest = !row ? [] : target?.kind === "folder" ? target.path : null;
const blocked = dragging?.some((t) => t.kind === "folder" && dest && isUnder(dest, t.path));
return dest && !blocked ? dest : null;
};
const dropMark = (row: HTMLElement | null): void => {
body.querySelectorAll(".is-drop").forEach((b) => b.classList.remove("is-drop"));
row?.classList.add("is-drop");
};
body.addEventListener("dragover", (event) => {
if (!dragging || !dropDest(event)) return;
event.preventDefault();
dropMark((event.target as HTMLElement).closest<HTMLElement>("[data-node]") ?? body);
});
body.addEventListener("drop", (event) => {
const dest = dragging && dropDest(event);
const list = dragging;
dragging = null;
dropMark(null);
if (!dest || !list) return;
event.preventDefault();
void dropMove(list, dest);
});
/** 놓은 자리로 옮김 — 이미 그 자리인 것은 뺌 · 하나면 한 개 옮기기 · 여럿이면 한 번에 */
async function dropMove(list: Target[], dest: string[]): Promise<void> {
const moving = list.filter(
(t) => !samePath(t.kind === "item" ? folderOf(t.item) : t.path.slice(0, -1), dest),
);
if (!moving.length || !(await ready())) return;
if (moving.length === 1) {
const t = moving[0];
return edit(
t.kind === "item"
? { op: "move", 경로: folderOf(t.item), 키: t.item.key, 새경로: dest }
: { op: "move", 경로: t.path, 새경로: dest },
);
}
const run = api.many;
if (run)
await apply((판) =>
run(opt.layer, { op: "move", 대상: moving.map(refOf), 새경로: dest }, 판, owner),
);
}
const visibleIds = (): string[] =>
[...body.querySelectorAll<HTMLElement>("[data-node]")]
.filter((b) => !b.closest("[hidden]"))
@@ -491,6 +563,25 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success");
}
/** 「〇〇로 복사」 — 그 층 맨 위에 바로(새 키 · 같은 이름 「(2)」 · 이 나무는 그대로) */
async function copyTo(
list: Target[],
to: { layer: Layer; owner: string | null; label: string },
): Promise<void> {
if (!api.paste || !list.length) return;
const clip = { kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) };
try {
await api.paste(to.layer, to.owner, clip, []);
const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사$/, "");
showToast(
L("M02_TreeCopiedTo").replace("{n}", String(list.length)).replace("{layer}", where),
"success",
);
} catch (error) {
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
}
}
async function paste(dest: string[]): Promise<void> {
const got = clipOf(clipKind);
if (!got || !opt.canEdit || !api.paste || !(await ready())) return;
@@ -32,6 +32,8 @@ export interface LayerBarOptions {
go: (root: Root, owner: string | null) => void;
/** 단추 옆 「+」(고른 층 나무 맨 위 폴더 · 항목 더하기 · PLAN 29-6) */
add?: HTMLElement;
/** 켜진 단추를 다시 누름 — 그 층 목록 화면으로(열어 둔 항목 · 조합에서 · PLAN 30-4) */
reselect?: () => void;
}
/** 회사 단추의 주인 — 볼 회사가 자기 회사면 null(자기 층) */
@@ -71,6 +73,7 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement {
});
b.addEventListener("click", () => {
if (!current) opt.go(root, owner);
else opt.reselect?.();
});
return b;
}
@@ -64,9 +64,17 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
// 자기 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사]
const readOnlyBar = layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user) : null;
// 고른 층의 나무 — 고르기 모달과 같은 폴더 나무 · 좌측 「구조물 조합」 줄 아래
// 「〇〇로 복사」(30-2) — 이 사람이 쓸 수 있는 자기 층(지금 보는 층은 뺌) 맨 위로 바로
const copyTo = (["master", "company", "personal"] as Root[]).flatMap((one) => {
const to = layerOfRoot(one);
if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];
const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal";
return [{ layer: to, owner: null, label: L(`M02_TreeCopyTo${key}`) }];
});
const tree = createTreePanel({
layer,
owner,
copyTo,
canEdit: canEdit(layer, isAdmin),
onPick: (item) => void main.openTable(layer, item.열),
settle: () => main.settle(),
@@ -90,13 +98,15 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
};
// 층 단추 셋(PLAN 29-2) — 「일위대가 라이브러리」 · 「구조물 조합」 줄 아래 한 벌씩
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
const layerBar = (open: OpenTarget, add: HTMLElement): HTMLElement =>
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 구조물 = 집계표 · 일위대가 = 조합 목록)
const layerBar = (open: OpenTarget, add: HTMLElement, reselect: () => void): HTMLElement =>
createLayerBar({
user,
active: { root: entry.root, owner },
company,
go: (next, nextOwner) => void go(next, nextOwner, open),
add,
reselect,
});
// 남의 회사 「개인」 — 고른 사람 줄 아래에 그 사람 나무(사람 줄 → 라이브러리 · 29-6)
const peopleTree = (open: OpenTarget, panel: HTMLElement): HTMLElement =>
@@ -139,7 +149,10 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
getOpen: main.current,
confirmLeave: main.confirmLeave,
showView: main.showView,
tree: [layerBar("구조물조합", tree.add), peopleTree("구조물조합", tree.root)],
tree: [
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
peopleTree("구조물조합", tree.root),
],
labor: ({ onOpen }) =>
buildLaborSection({
show: main.showView,
@@ -148,7 +161,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
onOpen();
mark("일위대가");
},
bar: (add) => layerBar("일위대가", add),
bar: (add, back) => layerBar("일위대가", add, back),
wrap: (panel) => peopleTree("일위대가", panel),
make: (reveal) => buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal }),
}),
+1 -1
View File
@@ -96,7 +96,7 @@ def test_나무는_좌측_패널_찾기_칸은_상세_위쪽():
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert "createTreePanel({" in page and "tree.select(column)" in page
assert (
'tree: [layerBar("구조물조합", tree.add), peopleTree("구조물조합", tree.root)],' in page
'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
and "search: tree.search," in page
)
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
+2 -2
View File
@@ -83,8 +83,8 @@ def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤():
page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8")
assert "go: (next, nextOwner) => void go(next, nextOwner, open)," in page # 두 벌 같은 길
assert (
'bar: (add) => layerBar("일위대가", add),' in page
and 'tree: [layerBar("구조물조합", tree.add), peopleTree("구조물조합", tree.root)],' in page
'bar: (add, back) => layerBar("일위대가", add, back),' in page
and 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
)
labor = page.split("buildLaborSection({")[1].split("make:")[0]
assert 'mark("일위대가")' in labor # 여는 순간 해시 open
+18 -1
View File
@@ -129,7 +129,7 @@ def test_층_단추_셋_남의_회사_사람_나무():
assert "side.reveal(entry.open)" in page
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
assert 'fetchOwners("company")' not in roots # 회사 목록 펼침 없앰
assert "select" not in roots # 사람 고르기 없앰
assert 'el("select"' not in roots and "createSelect" not in roots # 사람 고르기 없앰
assert 'fetchOwners("personal", opt.company)' in roots # 사람 나무
assert 'button("personal", null, true)' in roots # 남의 회사 「개인」 = 읽기
assert 'user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user)' in roots
@@ -216,3 +216,20 @@ def test_읽기만_표는_단추_고치기_모두_막음():
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert re.search(r"readOnly,\s*lockHead: !!struct,\s*onChange", main)
assert ".ui-sheet__toolbar[hidden]" in _text(SHEET / "ui_template_sheet.css")
def test_켜진_층_단추는_목록으로_복사_끌기():
"""30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 ·
저장 안 한 고침은 물음) · 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) ·
끌어다 옮기기는 쓸 수 있는 층만."""
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page
assert 'bar: (add, back) => layerBar("일위대가", add, back)' in page
assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page
labor = _text(M02 / "M02_MasterTemplete_Labor.ts")
assert 'if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;' in labor
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert "if (opt.canEdit) {\n button.draggable = true;" in panel
assert 'dragging?.some((t) => t.kind === "folder" && dest && isUnder(dest, t.path))' in panel
+4
View File
@@ -131,6 +131,10 @@ export const ui_locales_m2 = {
"Copied {n} — right-click a folder in a writable layer or press Ctrl+V",
],
M02_TreePicked: ["{n}개 고름", "{n} selected"],
M02_TreeCopyToMaster: ["마스터로 복사 ({n}개)", "Copy to master ({n})"],
M02_TreeCopyToCompany: ["회사로 복사 ({n}개)", "Copy to company ({n})"],
M02_TreeCopyToPersonal: ["개인으로 복사 ({n}개)", "Copy to personal ({n})"],
M02_TreeCopiedTo: ["{n}개를 {layer} 맨 위에 복사함", "Copied {n} to the top of {layer}"],
M02_TreeFolderName: ["폴더 이름", "Folder name"],
M02_TreeItemName: ["항목 이름", "Item name"],
M02_TreeUnit: ["단위", "Unit"],