diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts index d27f5c85..b4df0763 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts @@ -44,6 +44,8 @@ export interface LaborLibrary { tree: HTMLElement; /** 나무 맨 위 더하기 「+」(층 단추 옆) */ add: HTMLElement; + /** 켜진 층 단추를 다시 누름 — 조합 목록으로(저장 안 한 고침은 물음 · PLAN 30-4) */ + back: () => Promise; refresh: () => Promise; /** 메인 칸 「저장 안 한 고침」 손잡이 */ 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) => 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, ], }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css index 1c90945f..9d656bd9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css @@ -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); diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 521d6a36..6955b284 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -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("[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("[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 { + 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("[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 { + 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 { const got = clipOf(clipKind); if (!got || !opt.canEdit || !api.paste || !(await ready())) return; diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts index 4926c608..bd748d13 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts @@ -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; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 55984ca8..2bc95b0c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -64,9 +64,17 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { + 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 + // 켜진 단추를 다시 누르면 그 층 목록 화면(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 void main.openTable(layer)), + peopleTree("구조물조합", tree.root), + ], labor: ({ onOpen }) => buildLaborSection({ show: main.showView, @@ -148,7 +161,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise layerBar("일위대가", add), + bar: (add, back) => layerBar("일위대가", add, back), wrap: (panel) => peopleTree("일위대가", panel), make: (reveal) => buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal }), }), diff --git a/resources/tester/test_m02_combo_screen.py b/resources/tester/test_m02_combo_screen.py index 69b2c416..a56bb554 100644 --- a/resources/tester/test_m02_combo_screen.py +++ b/resources/tester/test_m02_combo_screen.py @@ -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") diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 9aec6dff..dec0d577 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -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 diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index c69f9c19..e32af4dc 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -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 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 7358c725..5872e65d 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -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"],