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:
@@ -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 }),
|
||||
}),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user