Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 구조물 라이브러리 나무의 [복사] → [붙여넣기].
|
||||
|
||||
붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) ·
|
||||
같은 이름은 「이름 (2)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더 (2)」).
|
||||
파일(도면 · 면적 · 재료 · 입력 연결 · 자재 조합)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
|
||||
|
||||
def _under(path: list[str], folder: list[str]) -> bool:
|
||||
return path[: len(folder)] == folder
|
||||
|
||||
|
||||
def outer(targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""고른 것 가운데 다른 고른 폴더 안에 든 것은 뺌(폴더가 안의 것까지 다룸) —
|
||||
옮기기 · 지우기 · 붙여넣기."""
|
||||
folders = [list(t.get("경로") or []) for t in targets if not t.get("키")]
|
||||
|
||||
def inside(t: dict[str, Any]) -> bool:
|
||||
path = list(t.get("경로") or [])
|
||||
return any(_under(path, f) and (t.get("키") or path != f) for f in folders if f)
|
||||
|
||||
return [t for t in targets if not inside(t)]
|
||||
|
||||
|
||||
def _table(folder: Path) -> dict[str, Any]:
|
||||
with store.at(folder):
|
||||
return tree.normalize_table(store.read("table", store.STRUCTURE_TABLE)["문서"])
|
||||
|
||||
|
||||
def _items(doc: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [c for c in doc.get("열") or [] if tree.is_item(c)]
|
||||
|
||||
|
||||
def _unique(path: list[str], taken: list[list[str]]) -> list[str]:
|
||||
n, out = 2, path
|
||||
while out in taken:
|
||||
out, n = [*path[:-1], f"{path[-1]} ({n})"], n + 1
|
||||
return out
|
||||
|
||||
|
||||
def paste(
|
||||
source: Path,
|
||||
target: Path,
|
||||
targets: list[dict[str, Any]],
|
||||
dest: list[str],
|
||||
origin: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""`targets` = `[{경로, 키?}]`(키 = 항목 · 없으면 폴더 경로) → `target` 층 `dest` 아래 ·
|
||||
돌려줌 알림 `{op: "paste", 경로, 열: [새 열 id]}`. `origin` = `{layer, owner}`(본뜬)."""
|
||||
src = _table(source)
|
||||
with store.at(source):
|
||||
keys = {str(r.get("열")): str(r.get("키")) for r in store.read_combos().get("줄", [])}
|
||||
src_items = [(tree.path_of(c), keys.get(c["id"])) for c in _items(src)]
|
||||
src_folders = tree.folders_of(src)
|
||||
items_store.ensure(target)
|
||||
dst = _table(target)
|
||||
taken = [tree.path_of(c) for c in _items(dst)]
|
||||
taken_folders = tree.folders_of(dst)
|
||||
plan: list[tuple[str, list[str]]] = [] # (원본 키, 새 경로)
|
||||
new_folders: list[list[str]] = []
|
||||
|
||||
def busy(path: list[str]) -> bool:
|
||||
paths = [*taken, *(p for _, p in plan)]
|
||||
return path in [*taken_folders, *new_folders] or any(_under(p, path) for p in paths)
|
||||
|
||||
for one in outer(targets):
|
||||
if one.get("키"):
|
||||
key = str(one["키"])
|
||||
found = next((p for p, k in src_items if k == key), None)
|
||||
if found is None:
|
||||
raise store.StoreError(404, f"없는 항목 「{key}」")
|
||||
plan.append((key, _unique([*dest, found[-1]], [*taken, *(p for _, p in plan)])))
|
||||
continue
|
||||
folder = list(one.get("경로") or [])
|
||||
if not folder or (
|
||||
folder not in src_folders and not any(_under(p, folder) for p, _ in src_items)
|
||||
):
|
||||
raise store.StoreError(404, f"없는 폴더 「{' '.join(folder)}」")
|
||||
top, n = [*dest, folder[-1]], 2
|
||||
while busy(top):
|
||||
top, n = [*dest, f"{folder[-1]} ({n})"], n + 1
|
||||
new_folders.append(top)
|
||||
new_folders += [[*top, *f[len(folder) :]] for f in src_folders if _under(f, folder)]
|
||||
plan += [(k, [*top, *p[len(folder) :]]) for p, k in src_items if _under(p, folder) and k]
|
||||
|
||||
made = [
|
||||
items_store.copy_item(source, target, key, {**origin, "key": key}, path)["열"]
|
||||
for key, path in plan
|
||||
]
|
||||
if new_folders: # 빈 폴더도 남게 — 집계표 `폴더`
|
||||
with store.at(target):
|
||||
got = store.read("table", store.STRUCTURE_TABLE)
|
||||
doc = got["문서"]
|
||||
doc["폴더"] = [*(doc.get("폴더") or []), *new_folders]
|
||||
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
|
||||
return {"op": "paste", "경로": dest, "열": made}
|
||||
@@ -430,7 +430,8 @@ export interface TreeItem {
|
||||
export interface TreeNote {
|
||||
op: string;
|
||||
경로?: string[];
|
||||
열?: string;
|
||||
/** 새 · 고친 항목 열 — 붙여넣기는 새 열 목록(PLAN 30-2) */
|
||||
열?: string | string[];
|
||||
지운항목?: string[];
|
||||
}
|
||||
export interface TreeView {
|
||||
@@ -464,6 +465,45 @@ export const editTree = (
|
||||
): Promise<TreeView> =>
|
||||
call(treePath(layer, owner), { method: "POST", body: JSON.stringify({ ...edit, 판 }) });
|
||||
|
||||
/** 나무의 고른 것 하나(PLAN 30-2) — 항목 = `키` + 그 폴더 경로 · 폴더 = 경로만 */
|
||||
export interface TreeRef {
|
||||
경로: string[];
|
||||
키?: string;
|
||||
}
|
||||
|
||||
/** 복사해 둔 것 — 나무 종류(`kind` · 구조물 · 일위대가) · 복사한 층 · 고른 것들 */
|
||||
export interface TreeClip {
|
||||
kind: string;
|
||||
from: { layer: Layer; owner: string | null };
|
||||
refs: TreeRef[];
|
||||
}
|
||||
|
||||
/** 여러 개 옮기기 · 지우기 한 번에 — 판이 다르면 409 · 하나라도 막히면 아무것도 안 바뀜 */
|
||||
export const editTreeMany = (
|
||||
layer: Layer,
|
||||
body: { op: "move" | "delete"; 대상: TreeRef[]; 새경로?: string[] },
|
||||
판: string,
|
||||
owner: string | null = null,
|
||||
): Promise<TreeView> =>
|
||||
call(treePath(layer, owner), { method: "POST", body: JSON.stringify({ ...body, 판 }) });
|
||||
|
||||
/** 층 사이 붙여넣기 — 복사한 층 읽기 · 이 층 쓰기 권한 · 새 키 · 같은 이름 「이름 (2)」 · `dest` 폴더 아래 */
|
||||
export const pasteTree = (
|
||||
layer: Layer,
|
||||
owner: string | null,
|
||||
clip: TreeClip,
|
||||
dest: string[],
|
||||
): Promise<TreeView> =>
|
||||
call(`/layers/${layer}/items/paste${owner ? `?owner=${enc(owner)}` : ""}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
from_layer: clip.from.layer,
|
||||
from_owner: clip.from.owner,
|
||||
대상: clip.refs,
|
||||
경로: dest,
|
||||
}),
|
||||
});
|
||||
|
||||
/** 남의 층 목록 한 줄(PLAN 27-1c) — 회사 `{C}` · 개인 `{C}/{U}` */
|
||||
export interface LayerOwner {
|
||||
owner: string;
|
||||
|
||||
@@ -96,11 +96,18 @@ def _fresh_id(want: str, used: set[str]) -> str:
|
||||
return out
|
||||
|
||||
|
||||
def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> dict[str, Any]:
|
||||
def copy_item(
|
||||
source: Path,
|
||||
target: Path,
|
||||
key: str,
|
||||
origin: dict[str, Any],
|
||||
path: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 일위대가 · 바인딩 그대로 ·
|
||||
도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 항목 연결 · 자재 조합 줄(새 UM) 복사 ·
|
||||
새 번호 · 새 SS 키 ·
|
||||
열 `본뜬` = `origin`(원래 층 · 주인 · 키). 돌려줌 `{key, 열, 이름}`."""
|
||||
열 `본뜬` = `origin`(원래 층 · 주인 · 키) · `path` = 새 자리(폴더 + 이름 · 없으면 원래 경로 ·
|
||||
붙여넣기 PLAN 30-2). 돌려줌 `{key, 열, 이름}`."""
|
||||
with store.at(source):
|
||||
row = store.find_combo(key) # 옛 키(회사 · 개인 층 SS)로도
|
||||
if row is None:
|
||||
@@ -135,6 +142,8 @@ def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> d
|
||||
doc = table["문서"]
|
||||
new_id = _fresh_id(col_id, {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)})
|
||||
made = {**json.loads(json.dumps(col)), "id": new_id, "자재조합": new_um, "본뜬": origin}
|
||||
if path:
|
||||
made.update({"경로": list(path), "머리": list(path)}) # 머리 줄 수는 저장 맞춤이 채움
|
||||
doc["열"] = [*doc.get("열", []), made]
|
||||
store.write("table", store.STRUCTURE_TABLE, table["판"], doc) # 빈 세 파일 · 번호 · SS
|
||||
for kind, got in docs.items():
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Item_Paste as paste_store
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Branch as branch
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
|
||||
@@ -53,6 +54,17 @@ class TreeBody(BaseModel):
|
||||
키: str | None = None
|
||||
단위: str | None = None
|
||||
판: str
|
||||
# 여러 개 옮기기 · 지우기(PLAN 30-2) — `[{경로, 키?}]` · 있으면 이것마다 차례로 · 저장 한 번
|
||||
대상: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class PasteBody(BaseModel):
|
||||
"""30-2 층 사이 붙여넣기 — 복사한 층(`from_layer` · `from_owner`) `대상` → 이 층 `경로` 아래."""
|
||||
|
||||
from_layer: Layer
|
||||
from_owner: str | None = None
|
||||
대상: list[dict[str, Any]]
|
||||
경로: list[str] = []
|
||||
|
||||
|
||||
class CopyItemBody(BaseModel):
|
||||
@@ -192,7 +204,17 @@ def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]:
|
||||
for key in [r.get("키"), *(r.get("옛키") or [])]:
|
||||
ids[str(key)] = str(r.get("열"))
|
||||
try:
|
||||
doc, note = tree.apply(got["문서"], body, ids)
|
||||
targets = body.get("대상")
|
||||
if targets is None:
|
||||
doc, note = tree.apply(got["문서"], body, ids)
|
||||
else: # 여러 개 — 차례로 고쳐 저장은 한 번(하나라도 막히면 아무것도 안 씀)
|
||||
if body.get("op") not in ("move", "delete") or not targets:
|
||||
raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함")
|
||||
doc, note = got["문서"], {"op": body["op"], "지운항목": []}
|
||||
for one in paste_store.outer(targets):
|
||||
step = {**body, "경로": one.get("경로"), "키": one.get("키")}
|
||||
doc, done = tree.apply(doc, step, ids)
|
||||
note["지운항목"] += done.get("지운항목", [])
|
||||
except tree.TreeError as e:
|
||||
raise store.StoreError(e.status, e.detail) from e
|
||||
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
|
||||
@@ -212,6 +234,27 @@ def post_tree(
|
||||
return _call(folder, _edit_tree, folder, body.model_dump())
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/items/paste")
|
||||
def post_paste(
|
||||
layer: Layer,
|
||||
body: PasteBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
"""여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 복사한 층은 읽기 권한 · 이 층은 쓰기 권한 ·
|
||||
새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름 (2)」 · 폴더는 안의 것까지 → 새 나무 + 알림."""
|
||||
target = _root(session, layer, owner, write=True)
|
||||
source = _root(session, body.from_layer, body.from_owner)
|
||||
who = access.grant(session, body.from_layer, body.from_owner).owner
|
||||
try:
|
||||
note = paste_store.paste(
|
||||
source, target, body.대상, body.경로, {"layer": body.from_layer, "owner": who}
|
||||
)
|
||||
except store.StoreError as e:
|
||||
raise HTTPException(status_code=e.status, detail=e.detail) from e
|
||||
return {**_call(target, _tree_view, target), "알림": note}
|
||||
|
||||
|
||||
# ── 층 길 — 옛 시스템 길과 같은 모양 ─────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -64,6 +64,36 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 층 단추 줄 오른쪽 끝 — 「N개 고름」 · 「+」(PLAN 30-2) */
|
||||
.m02-tree__tools {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m02-tree__picked {
|
||||
color: var(--color-accent);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-tree__picked[hidden] {
|
||||
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);
|
||||
}
|
||||
|
||||
/* 층 단추 옆 「+」 — 나무 맨 위 폴더 · 항목 더하기(PLAN 29-6) */
|
||||
.m02-tree__add {
|
||||
min-width: 1.75rem;
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* 층 + `owner`(남의 층 · PLAN 27-1c) 한 벌을 그림 — 층 단추는 `_Roots` · 찾기 칸은 부르는 쪽이 상세 위쪽에 · 자료 길은 `api`.
|
||||
* 항목을 누르면 표에서 그 열이 골라짐(카드 넷은 표가 엶) · 표에서 열을 고르면 `select` 로 나무도 그 항목.
|
||||
* 오른쪽 클릭 · 키보드(F2 · Delete)로 폴더 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 —
|
||||
* `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 메뉴 · 키 없음. 나무 자료는 `_Model`.
|
||||
* `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 [복사]만. 나무 자료는 `_Model`.
|
||||
* 여러 개(PLAN 30-2) — Ctrl · Shift 클릭으로 고름(`_Pick`) · 오른쪽 클릭 메뉴 · Ctrl+C · Ctrl+V · Delete 가
|
||||
* 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(`api.many` · `api.paste`).
|
||||
* ========================================================================== */
|
||||
|
||||
import "./M02_MasterTemplete_TreePanel.css";
|
||||
@@ -21,14 +23,25 @@ import {
|
||||
import {
|
||||
StaleError,
|
||||
editTree,
|
||||
editTreeMany,
|
||||
fetchTree,
|
||||
pasteTree,
|
||||
type Layer,
|
||||
type TreeClip,
|
||||
type TreeEdit,
|
||||
type TreeItem,
|
||||
type TreeNote,
|
||||
type TreeRef,
|
||||
type TreeView,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import {
|
||||
clipOf,
|
||||
keepClip,
|
||||
pickClick,
|
||||
refOf,
|
||||
type Target,
|
||||
} from "./M02_MasterTemplete_TreePanel_Pick";
|
||||
import {
|
||||
ancestorsOf,
|
||||
buildFolders,
|
||||
@@ -51,7 +64,25 @@ export interface TreePanelOptions {
|
||||
api?: {
|
||||
fetch: (layer: Layer, owner: string | null) => Promise<TreeView>;
|
||||
edit: (layer: Layer, edit: TreeEdit, 판: string, owner: string | null) => Promise<TreeView>;
|
||||
/** 여러 개 옮기기 · 지우기 한 번에 — 없으면 여러 개 메뉴 없음 */
|
||||
many?: (
|
||||
layer: Layer,
|
||||
body: { op: "move" | "delete"; 대상: TreeRef[]; 새경로?: string[] },
|
||||
판: string,
|
||||
owner: string | null,
|
||||
) => Promise<TreeView>;
|
||||
/** 붙여넣기(복사해 둔 것 → 이 층 `dest` 아래) — 없으면 복사 · 붙여넣기 메뉴 없음 */
|
||||
paste?: (
|
||||
layer: Layer,
|
||||
owner: string | null,
|
||||
clip: TreeClip,
|
||||
dest: string[],
|
||||
) => Promise<TreeView>;
|
||||
};
|
||||
/** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */
|
||||
clipKind?: string;
|
||||
/** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */
|
||||
copyTo?: { layer: Layer; owner: string | null; label: string }[];
|
||||
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
|
||||
canEdit: boolean;
|
||||
/** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */
|
||||
@@ -71,21 +102,20 @@ export interface TreePanelHandle {
|
||||
root: HTMLElement;
|
||||
/** 찾기 칸 — 부르는 쪽이 상세 위쪽에 붙임(PLAN 29-2) */
|
||||
search: HTMLElement;
|
||||
/** 「+」 — 맨 위 폴더 · 항목 더하기 · 부르는 쪽이 층 단추 옆에 붙임(읽기만 층 = 숨김 · 29-6) */
|
||||
/** 층 단추 줄 오른쪽 도구 — 「N개 고름」(30-2) · 「+」(맨 위 폴더 · 항목 더하기 · 읽기만 층 = 숨김 · 29-6) */
|
||||
add: 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 owner = opt.owner ?? null;
|
||||
const api = opt.api ?? { fetch: fetchTree, edit: editTree };
|
||||
const api = opt.api ?? { fetch: fetchTree, edit: editTree, many: editTreeMany, paste: pasteTree };
|
||||
const clipKind = opt.clipKind ?? "구조물";
|
||||
let view: TreeView | null = null;
|
||||
let text = "";
|
||||
let activeCol: string | null = null;
|
||||
@@ -93,6 +123,9 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
let busy = false;
|
||||
const open = new Set<string>([folderIdOf([])]);
|
||||
const targets = new Map<string, Target>();
|
||||
// 여러 개 고름(PLAN 30-2) — 줄 id(`f|경로` · `i|열`) · Shift 범위의 기준
|
||||
let picked = new Set<string>();
|
||||
let anchor: string | null = null;
|
||||
|
||||
const search = createInputField({ placeholder: L("M02_TreeSearch"), compact: true });
|
||||
const body = el("div", { className: "m02-tree__body", attrs: { tabindex: "-1" } });
|
||||
@@ -105,11 +138,68 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
attrs: { type: "button", title: L("M02_TreeAddTop"), "aria-label": L("M02_TreeAddTop") },
|
||||
});
|
||||
add.hidden = !opt.canEdit;
|
||||
add.addEventListener("click", (event) =>
|
||||
openTreeMenu(event, menuOf({ kind: "folder", path: [] })),
|
||||
);
|
||||
add.addEventListener("click", (event) => openTreeMenu(event, topMenu()));
|
||||
// 「N개 고름」 · 「+」 — 층 단추 줄 오른쪽 끝(30-2 · 부르는 쪽이 붙임)
|
||||
const status = el("span", { className: "m02-tree__picked", attrs: { hidden: "" } });
|
||||
const tools = el("span", { className: "m02-tree__tools", children: [status, add] });
|
||||
|
||||
/* --- 그리기 --- */
|
||||
const canCopy = !!api.paste;
|
||||
const pickedTargets = (): Target[] =>
|
||||
[...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t);
|
||||
/** 붙여넣기 줄 — 같은 종류를 복사해 뒀고 이 층에 쓸 수 있을 때만 */
|
||||
const pasteItem = (dest: string[]): TreeMenuItem[] => {
|
||||
const got = opt.canEdit && api.paste ? clipOf(clipKind) : null;
|
||||
return got
|
||||
? [
|
||||
{
|
||||
label: L("M02_TreePaste").replace("{n}", String(got.refs.length)),
|
||||
run: () => void paste(dest),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
};
|
||||
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: [] }) : []),
|
||||
...pasteItem([]),
|
||||
];
|
||||
/** 오른쪽 클릭 — 고른 것 안의 줄이면 고른 것 전부(옮기기 · 지우기 · 복사) · 아니면 그 줄 하나 */
|
||||
const menuFor = (target: Target, id: string): TreeMenuItem[] => {
|
||||
if (!picked.has(id)) setPicked(new Set([id]), id);
|
||||
const list = pickedTargets();
|
||||
if (list.length > 1) {
|
||||
const n = String(list.length);
|
||||
return [
|
||||
...copyItem(list),
|
||||
...(opt.canEdit && api.many
|
||||
? [
|
||||
{ label: L("M02_TreeMoveMany").replace("{n}", n), run: () => void moveMany(list) },
|
||||
{
|
||||
label: L("M02_TreeDeleteMany").replace("{n}", n),
|
||||
run: () => void removeMany(list),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
return [
|
||||
...(opt.canEdit ? menuOf(target) : []),
|
||||
...copyItem([target]),
|
||||
...(target.kind === "folder" ? pasteItem(target.path) : []),
|
||||
];
|
||||
};
|
||||
const menuOf = (target: Target): TreeMenuItem[] => {
|
||||
const isTop = target.kind === "folder" && !target.path.length;
|
||||
return [
|
||||
@@ -136,7 +226,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
|
||||
function node(target: Target, base: Omit<TreeNode, "id" | "menu">, id: string): TreeNode {
|
||||
targets.set(id, target);
|
||||
return { ...base, id, ...(opt.canEdit ? { menu: () => menuOf(target) } : {}) };
|
||||
return { ...base, id, ...(opt.canEdit || canCopy ? { menu: () => menuFor(target, id) } : {}) };
|
||||
}
|
||||
|
||||
function folderNode(folder: FolderNode, searching: boolean): TreeNode {
|
||||
@@ -174,9 +264,105 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
const button = createTreeRow(one, caret, depth, onClick);
|
||||
button.dataset.node = one.id;
|
||||
button.classList.toggle("is-active", !!activeCol && one.id === `i|${activeCol}`);
|
||||
button.classList.toggle("is-picked", picked.size > 1 && picked.has(one.id));
|
||||
// Ctrl · Shift 클릭 = 고르기만(열기 · 접기 안 함) — 줄 본디 click 보다 먼저(capture)
|
||||
button.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const got = pickClick(
|
||||
picked,
|
||||
anchor,
|
||||
one.id,
|
||||
{ ctrl: event.ctrlKey || event.metaKey, shift: event.shiftKey },
|
||||
visibleIds(),
|
||||
);
|
||||
if (got.handled) event.stopImmediatePropagation();
|
||||
setPicked(got.picked, got.anchor);
|
||||
},
|
||||
{ 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]"))
|
||||
.map((b) => b.dataset.node ?? "");
|
||||
|
||||
/** 고름을 바꾸고 줄 배경 · 「N개 고름」을 맞춤 */
|
||||
function setPicked(next: Set<string>, nextAnchor: string | null): void {
|
||||
picked = new Set([...next].filter((id) => targets.has(id)));
|
||||
anchor = nextAnchor;
|
||||
body.querySelectorAll<HTMLElement>("[data-node]").forEach((b) => {
|
||||
b.classList.toggle("is-picked", picked.size > 1 && picked.has(b.dataset.node ?? ""));
|
||||
});
|
||||
status.hidden = picked.size < 2;
|
||||
status.textContent = L("M02_TreePicked").replace("{n}", String(picked.size));
|
||||
}
|
||||
|
||||
function paint(): void {
|
||||
const focused = (document.activeElement as HTMLElement | null)?.dataset?.node;
|
||||
targets.clear();
|
||||
@@ -196,6 +382,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
: []),
|
||||
);
|
||||
if (focused) rowOf(focused)?.focus({ preventScroll: true });
|
||||
setPicked(picked, anchor); // 없어진 줄은 고름에서 뺌
|
||||
}
|
||||
|
||||
const rowOf = (id: string): HTMLElement | null =>
|
||||
@@ -243,22 +430,32 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function edit(body_: TreeEdit, keepCol: string | null = activeCol): Promise<void> {
|
||||
const edit = (body_: TreeEdit, keepCol: string | null = activeCol): Promise<void> =>
|
||||
apply((판) => api.edit(opt.layer, body_, 판, owner), keepCol);
|
||||
|
||||
/** 나무 고침 한 번(하나 · 여러 개 · 붙여넣기) — 판 다름 · 막힘 까닭 · 뒤 처리를 한 곳에서 */
|
||||
async function apply(
|
||||
run: (판: string) => Promise<TreeView>,
|
||||
keepCol: string | null = activeCol,
|
||||
): Promise<void> {
|
||||
if (!view || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const got = await api.edit(opt.layer, body_, view.판, owner);
|
||||
const got = await run(view.판);
|
||||
view = got;
|
||||
const note: TreeNote | undefined = got.알림;
|
||||
// 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로
|
||||
const gone = note?.지운항목 ?? [];
|
||||
const col = note?.열 ?? (keepCol && !gone.includes(keepCol) ? keepCol : null);
|
||||
const made = note?.열;
|
||||
const col =
|
||||
typeof made === "string" ? made : 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.경로));
|
||||
if (["add-folder", "rename", "move", "paste"].includes(note?.op ?? "")) {
|
||||
if (note?.경로?.length) open.add(folderIdOf(note.경로));
|
||||
}
|
||||
picked = new Set();
|
||||
paint();
|
||||
showToast(L("M02_TreeDone"), "success");
|
||||
await opt.onChanged(col);
|
||||
@@ -358,6 +555,64 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
);
|
||||
}
|
||||
|
||||
/* --- 여러 개 · 복사 붙여넣기(PLAN 30-2) --- */
|
||||
/** 복사 — 읽기만 층에서도 · 층을 바꿔도 남음(붙여넣기는 쓸 수 있는 층에서) */
|
||||
function copy(list: Target[]): void {
|
||||
if (!canCopy || !list.length) return;
|
||||
keepClip({ kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) });
|
||||
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;
|
||||
const run = api.paste;
|
||||
await apply(() => run(opt.layer, owner, got, dest));
|
||||
}
|
||||
|
||||
async function moveMany(list: Target[]): Promise<void> {
|
||||
if (!api.many || !(await ready()) || !view) return;
|
||||
const folders = list.flatMap((t) => (t.kind === "folder" ? [t.path] : []));
|
||||
const to = await askFolder(
|
||||
L("M02_TreeMove"),
|
||||
listFolders(buildFolders(view)),
|
||||
(path) => folders.some((f) => isUnder(path, f)),
|
||||
[],
|
||||
);
|
||||
if (!to) return;
|
||||
const run = api.many;
|
||||
await apply((판) =>
|
||||
run(opt.layer, { op: "move", 대상: list.map(refOf), 새경로: to }, 판, owner),
|
||||
);
|
||||
}
|
||||
|
||||
async function removeMany(list: Target[]): Promise<void> {
|
||||
if (!api.many || !(await ready())) return;
|
||||
const ask = L("M02_TreeDeleteManyAsk").replace("{n}", String(list.length));
|
||||
if (!(await showConfirmDialog(ask, L("M02_Delete")))) return;
|
||||
const run = api.many;
|
||||
await apply((판) => run(opt.layer, { op: "delete", 대상: list.map(refOf) }, 판, owner));
|
||||
}
|
||||
|
||||
const findFolder = (from: FolderNode, path: string[]): FolderNode | undefined =>
|
||||
samePath(from.path, path)
|
||||
? from
|
||||
@@ -369,15 +624,21 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
paint();
|
||||
});
|
||||
|
||||
// 빈 자리 오른쪽 클릭 = 맨 위 메뉴(폴더 · 항목 더하기)
|
||||
// 빈 자리 오른쪽 클릭 = 맨 위 메뉴(폴더 · 항목 더하기 · 붙여넣기)
|
||||
body.addEventListener("contextmenu", (event) => {
|
||||
if (!opt.canEdit || (event.target as HTMLElement).closest("[data-node]")) return;
|
||||
if ((event.target as HTMLElement).closest("[data-node]")) return;
|
||||
const items = topMenu();
|
||||
if (!items.length) return;
|
||||
event.preventDefault();
|
||||
openTreeMenu(event, menuOf({ kind: "folder", path: [] }).slice(0, 2));
|
||||
openTreeMenu(event, items);
|
||||
});
|
||||
|
||||
body.addEventListener("keydown", (event) => {
|
||||
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-node]");
|
||||
if (!row && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") {
|
||||
event.preventDefault(); // 빈 자리에 초점 = 맨 위에 붙여넣기
|
||||
return void paste([]);
|
||||
}
|
||||
if (!row) return;
|
||||
const rows = [...body.querySelectorAll<HTMLElement>("[data-node]")].filter(
|
||||
(b) => !b.closest("[hidden]"),
|
||||
@@ -399,9 +660,17 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
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);
|
||||
const many = picked.size > 1 && picked.has(row.dataset.node ?? "");
|
||||
if (many && api.many) void removeMany(pickedTargets());
|
||||
else if (!(target.kind === "folder" && !target.path.length)) void remove(target);
|
||||
} else if (target && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") {
|
||||
event.preventDefault();
|
||||
copy(picked.has(row.dataset.node ?? "") ? pickedTargets() : [target]);
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") {
|
||||
event.preventDefault(); // 붙일 자리 = 초점 줄이 폴더면 그 안 · 아니면 맨 위
|
||||
void paste(target?.kind === "folder" ? target.path : []);
|
||||
}
|
||||
});
|
||||
|
||||
return { root, search: search.root, add, refresh, select };
|
||||
return { root, search: search.root, add: tools, refresh, select };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_TreePanel_Pick.ts
|
||||
* 공용 나무 여러 개 고르기 · 복사 붙여넣기 자료(PLAN 30-2 · 화면 없음) — 고름(Ctrl · Shift 클릭) ·
|
||||
* 고른 것 → 서버 모양(`TreeRef`) · 복사해 둔 것(나무 종류마다 하나 · 층을 바꿔 화면을 다시 세워도 남음).
|
||||
* 그리기 · 메뉴는 `M02_MasterTemplete_TreePanel.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { TreeClip, TreeItem, TreeRef } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { folderOf } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
|
||||
/** 나무 줄 하나가 가리키는 것 — 폴더(경로) · 항목 */
|
||||
export type Target = { kind: "folder"; path: string[] } | { kind: "item"; item: TreeItem };
|
||||
|
||||
export const refOf = (target: Target): TreeRef =>
|
||||
target.kind === "item"
|
||||
? { 경로: folderOf(target.item), 키: target.item.key }
|
||||
: { 경로: target.path };
|
||||
|
||||
let clip: TreeClip | null = null;
|
||||
/** 이 나무 종류로 복사해 둔 것 — 없으면 null */
|
||||
export const clipOf = (kind: string): TreeClip | null => (clip?.kind === kind ? clip : null);
|
||||
export const keepClip = (next: TreeClip): void => {
|
||||
clip = next;
|
||||
};
|
||||
|
||||
/** 클릭 한 번 뒤 고름 — Ctrl(⌘) = 하나 넣고 빼기 · Shift = 기준에서 여기까지(보이는 줄 차례) ·
|
||||
* 그냥 = 이것 하나. `handled` = 줄 본디 동작(항목 열기 · 폴더 접기)을 막음 */
|
||||
export function pickClick(
|
||||
picked: Set<string>,
|
||||
anchor: string | null,
|
||||
id: string,
|
||||
mod: { ctrl: boolean; shift: boolean },
|
||||
order: string[],
|
||||
): { picked: Set<string>; anchor: string | null; handled: boolean } {
|
||||
if (mod.ctrl) {
|
||||
const next = new Set(picked);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return { picked: next, anchor: id, handled: true };
|
||||
}
|
||||
if (mod.shift && anchor && order.includes(anchor) && order.includes(id)) {
|
||||
const [a, b] = [order.indexOf(anchor), order.indexOf(id)].sort((x, y) => x - y);
|
||||
return { picked: new Set(order.slice(a, b + 1)), anchor, handled: true };
|
||||
}
|
||||
return { picked: new Set([id]), anchor: id, handled: false };
|
||||
}
|
||||
@@ -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
|
||||
@@ -139,12 +139,11 @@ def test_층_단추_셋_남의_회사_사람_나무():
|
||||
assert 'wrap: (panel) => peopleTree("일위대가", panel)' in page
|
||||
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert (
|
||||
"api.fetch(opt.layer, owner)" in panel
|
||||
and "api.edit(opt.layer, body_, view.판, owner)" in panel
|
||||
"api.fetch(opt.layer, owner)" in panel and "api.edit(opt.layer, body_, 판, owner)" in panel
|
||||
)
|
||||
assert (
|
||||
"...(opt.canEdit ? { menu: () => menuOf(target) } : {})" in panel
|
||||
) # 읽기 뿌리 = 메뉴 없음
|
||||
# 읽기 층 = 고치기 메뉴 없음 · [복사]만(30-2) · 붙여넣기는 쓸 수 있는 층만
|
||||
assert "...(opt.canEdit || canCopy ? { menu: () => menuFor(target, id) } : {})" in panel
|
||||
assert "const got = opt.canEdit && api.paste ? clipOf(clipKind) : null;" in panel
|
||||
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
|
||||
assert "owner=${enc(activeOwner)}" in api and "/owners" in api
|
||||
|
||||
@@ -217,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
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""M02 구조물 라이브러리 여러 개 · 층 사이 붙여넣기(PLAN 30-2) — 임시 storage.
|
||||
|
||||
① 회사 항목 · 폴더를 일반 사용자 개인 층에 붙임 — 새 SP 키 · 본뜬 = 원본 · 세 파일 복사 ·
|
||||
폴더는 안의 것 · 빈 폴더까지 · 다시 붙이면 「이름 (2)」 · 고른 폴더 안의 것은 한 번만.
|
||||
② 권한 — 쓰기 층만(일반 사용자 회사 층 403 · 남의 층 403) · 복사한 층은 읽기 권한(남의 회사 403).
|
||||
③ 여러 개 옮기기 · 지우기 한 번에(하나라도 막히면 아무것도 안 바뀜).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from resources.tester import test_m02_tree as base
|
||||
from resources.tester.test_m02_tree import Tree, _as
|
||||
|
||||
world = base.world # 같은 임시 storage 판
|
||||
|
||||
PASTE = "/api/m02/layers/{}/items/paste"
|
||||
|
||||
|
||||
def _company(world: dict[str, Any]) -> Tree:
|
||||
t = Tree(_as(world, "ADMIN"), "company")
|
||||
t.do(op="add-folder", 경로=[], 이름="배수")
|
||||
t.do(op="add-folder", 경로=["배수"], 이름="빈")
|
||||
t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m")
|
||||
t.do(op="add-item", 경로=[], 이름="옹벽", 단위="m")
|
||||
return t
|
||||
|
||||
|
||||
def test_회사_항목_폴더를_개인에_붙임_새_키_파일_이름2(world):
|
||||
company = _company(world)
|
||||
wall = company.item(["옹벽"])["key"]
|
||||
client = _as(world) # 일반 사용자 42
|
||||
ditch = company.item(["배수", "측구"])["key"]
|
||||
body = {
|
||||
"from_layer": "company",
|
||||
"대상": [{"경로": ["배수"]}, {"경로": ["배수"], "키": ditch}, {"경로": [], "키": wall}],
|
||||
"경로": [],
|
||||
}
|
||||
res = client.post(PASTE.format("personal"), json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
got = res.json()
|
||||
paths = [r["경로"] for r in got["items"]]
|
||||
assert paths == [["배수", "측구"], ["옹벽"]] # 폴더 안 측구는 한 번만
|
||||
assert ["배수", "빈"] in got["폴더"] # 빈 폴더까지
|
||||
assert all(r["key"].startswith("SP42-") for r in got["items"])
|
||||
assert len(got["알림"]["열"]) == 2
|
||||
root = world["storage"] / "7" / "42" / "templates"
|
||||
table = json.loads((root / "table/구조물집계표.json").read_bytes())
|
||||
made = next(c for c in table["열"] if c.get("경로") == ["옹벽"])
|
||||
assert made["본뜬"] == {"layer": "company", "owner": "7", "key": wall}
|
||||
for kind in ("structure", "area", "material"):
|
||||
assert (root / kind / f"{made['id']}.json").is_file()
|
||||
# 다시 붙이면 같은 이름 「(2)」 · 붙일 폴더 안으로
|
||||
again = client.post(PASTE.format("personal"), json={**body, "경로": []}).json()
|
||||
assert ["배수 (2)", "측구"] in [r["경로"] for r in again["items"]]
|
||||
assert ["옹벽 (2)"] in [r["경로"] for r in again["items"]]
|
||||
inner = client.post(PASTE.format("personal"), json={**body, "대상": [body["대상"][2]],
|
||||
"경로": ["배수"]}).json() # fmt: skip
|
||||
assert ["배수", "옹벽"] in [r["경로"] for r in inner["items"]]
|
||||
|
||||
|
||||
def test_붙여넣기_권한(world):
|
||||
company = _company(world)
|
||||
body = {"from_layer": "company", "대상": [{"경로": ["배수"]}], "경로": []}
|
||||
client = _as(world)
|
||||
assert client.post(PASTE.format("company"), json=body).status_code == 403 # 일반 = 회사 읽기만
|
||||
assert client.post(PASTE.format("personal") + "?owner=7/43", json=body).status_code == 403
|
||||
far = {**body, "from_owner": "9"}
|
||||
assert client.post(PASTE.format("personal"), json=far).status_code == 403 # 남의 회사 읽기 막힘
|
||||
missing = {**body, "대상": [{"경로": [], "키": "SC7-999999"}]}
|
||||
assert client.post(PASTE.format("personal"), json=missing).status_code == 404
|
||||
# 회사 관리자 = 개인 → 회사 붙여넣기
|
||||
admin = _as(world, "ADMIN")
|
||||
mine = Tree(admin, "personal")
|
||||
mine.do(op="add-item", 경로=[], 이름="내것", 단위="m")
|
||||
up = {"from_layer": "personal", "대상": [{"경로": [], "키": mine.item(["내것"])["key"]}]}
|
||||
res = admin.post(PASTE.format("company"), json={**up, "경로": ["배수"]})
|
||||
assert res.status_code == 200, res.text
|
||||
assert ["배수", "내것"] in [r["경로"] for r in res.json()["items"]]
|
||||
assert company.client is admin
|
||||
|
||||
|
||||
def test_여러_개_옮기기_지우기_한_번에(world):
|
||||
t = Tree(_as(world), "personal")
|
||||
t.do(op="add-folder", 경로=[], 이름="가")
|
||||
t.do(op="add-folder", 경로=["가"], 이름="안")
|
||||
t.do(op="add-item", 경로=["가", "안"], 이름="깊은")
|
||||
t.do(op="add-item", 경로=[], 이름="하나")
|
||||
t.do(op="add-item", 경로=[], 이름="둘")
|
||||
t.do(op="add-folder", 경로=[], 이름="나")
|
||||
one, two = t.item(["하나"])["key"], t.item(["둘"])["key"]
|
||||
deep = t.item(["가", "안", "깊은"])["key"]
|
||||
moves = [
|
||||
{"경로": [], "키": one},
|
||||
{"경로": [], "키": two},
|
||||
{"경로": ["가"]},
|
||||
{"경로": ["가", "안"], "키": deep},
|
||||
] # fmt: skip — 가 안의 깊은 것은 폴더와 함께
|
||||
t.do(op="move", 대상=moves, 새경로=["나"])
|
||||
assert sorted(t.paths()) == [["나", "가", "안", "깊은"], ["나", "둘"], ["나", "하나"]]
|
||||
before = t.now["판"]
|
||||
t.do(422, op="move", 대상=[{"경로": ["나"], "키": one}, {"경로": ["나"]}], 새경로=["나"])
|
||||
assert t.client.get(t.url).json()["판"] == before # 막히면 아무것도 안 씀
|
||||
t.do(422, op="rename", 대상=[{"경로": ["나"]}], 이름="x")
|
||||
note = t.do(op="delete", 대상=[{"경로": ["나"], "키": one}, {"경로": ["나", "가"]}])
|
||||
assert len(note["알림"]["지운항목"]) == 2
|
||||
assert t.paths() == [["나", "둘"]]
|
||||
@@ -85,3 +85,40 @@ def test_안_또는_같음_판정(results):
|
||||
def test_잎_이름과_폴더(results):
|
||||
assert results["leaf"] == "1.2m"
|
||||
assert results["dir"] == ["돌기슭막이", "메쌓기"]
|
||||
|
||||
|
||||
def test_여러_개_고르기_Ctrl_Shift():
|
||||
"""PLAN 30-2 — Ctrl = 하나 넣고 빼기(줄 동작 막음) · Shift = 기준에서 여기까지(보이는 차례) ·
|
||||
그냥 클릭 = 그것 하나(줄 동작 그대로)."""
|
||||
import re
|
||||
|
||||
pick = os.path.join(
|
||||
HERE, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_TreePanel_Pick.ts"
|
||||
)
|
||||
src = open(pick, encoding="utf-8").read()
|
||||
found = re.search(r"^export function pickClick[\s\S]*?^}", src, re.M)
|
||||
assert found, "pickClick 을 못 찾음"
|
||||
script = (
|
||||
found.group(0).replace("export ", "")
|
||||
+ "\nconst order = ['a', 'b', 'c', 'd', 'e'];"
|
||||
+ "\nconst show = (r) => [[...r.picked].sort(), r.anchor, r.handled];"
|
||||
+ "\nconst one = pickClick(new Set(), null, 'b', {ctrl: false, shift: false}, order);"
|
||||
+ "\nconst two = pickClick(one.picked, one.anchor, 'd', {ctrl: true, shift: false}, order);"
|
||||
+ "\nconst off = pickClick(two.picked, two.anchor, 'b', {ctrl: true, shift: false}, order);"
|
||||
+ "\nconst range = pickClick(one.picked, 'b', 'e', {ctrl: false, shift: true}, order);"
|
||||
+ "\nconst back = pickClick(one.picked, 'd', 'a', {ctrl: false, shift: true}, order);"
|
||||
+ "\nconsole.log(JSON.stringify([one, two, off, range, back].map(show)));"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert json.loads(proc.stdout) == [
|
||||
[["b"], "b", False],
|
||||
[["b", "d"], "d", True],
|
||||
[["d"], "b", True],
|
||||
[["b", "c", "d", "e"], "b", True],
|
||||
[["a", "b", "c", "d"], "d", True],
|
||||
]
|
||||
|
||||
@@ -118,6 +118,23 @@ export const ui_locales_m2 = {
|
||||
M02_TreeAddItem: ["항목 추가", "Add item"],
|
||||
M02_TreeRename: ["이름 바꾸기", "Rename"],
|
||||
M02_TreeMove: ["옮기기", "Move"],
|
||||
M02_TreeMoveMany: ["옮기기 ({n}개)", "Move ({n})"],
|
||||
M02_TreeDeleteMany: ["지우기 ({n}개)", "Delete ({n})"],
|
||||
M02_TreeDeleteManyAsk: [
|
||||
"고른 {n}개를 지움 — 폴더는 안의 것까지",
|
||||
"Delete {n} selected — folders with their contents",
|
||||
],
|
||||
M02_TreeCopy: ["복사 ({n}개)", "Copy ({n})"],
|
||||
M02_TreePaste: ["붙여넣기 ({n}개)", "Paste ({n})"],
|
||||
M02_TreeCopied: [
|
||||
"{n}개 복사함 — 쓸 수 있는 층의 폴더 · 빈 자리를 오른쪽 클릭하거나 Ctrl+V",
|
||||
"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