diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 0ef688765..6ac97e28e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -510,9 +510,11 @@ export const pasteTree = ( export interface LayerOwner { owner: string; name: string; + /** 개인 = 집계표가 아직 없음(사람 나무 「비어 있음」 · 39-6) */ + 비어있음?: boolean; } -/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사 사람(본인 뺌 · 층 자리 있는 것만) */ +/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사를 맡은 사람 · 회사 사람 전부(나 맨 위) */ export const fetchOwners = ( layer: "company" | "personal", company: string | null = null, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 3b94c655c..c1860e584 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -79,7 +79,8 @@ export interface LayerAccess { needCompany: boolean; } -/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`)은 늘 읽기만(읽을 수 있나는 서버가 가름) */ +/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`) = 그 회사를 맡았으면 고침(39-6) · + * 읽을 수 있나는 서버가 가름 */ export function accessFor( layer: ManageLayer, owner: string | null, @@ -87,10 +88,14 @@ export function accessFor( ): LayerAccess { if (layer === "system") return { canWrite: user?.role === "SYSTEM_ADMIN", needCompany: false }; const own = ownOwner(layer, user); - if (owner && owner !== own) return { canWrite: false, needCompany: false }; + if (owner && owner !== own) { + return { canWrite: manages(owner.split("/")[0], user), needCompany: false }; + } if (!own) return { canWrite: false, needCompany: true }; - const companyAdmin = user?.role === "ADMIN" || user?.role === "SYSTEM_ADMIN" || !!user?.is_master; - return { canWrite: layer === "personal" || companyAdmin, needCompany: false }; + return { + canWrite: layer === "personal" || manages(ownOwner("company", user), user), + needCompany: false, + }; } /** 회사 C 를 맡음 — 시스템 관리자 · 또는 C 의 회사 관리자(서버 `M02_Layer_Access.manages` 짝 · 39-6) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 014e9c9b1..13c61f1db 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -35,6 +35,7 @@ import { type TreeRef, type TreeView, } from "./M02_MasterTemplete_Api_Fetch"; +import { askCopyFolder, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo"; import { askFolder, askNewItem, askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; import { @@ -83,8 +84,8 @@ export interface TreePanelOptions { }; /** 항목 추가 창의 일위대가 고르기(PLAN 31-2 · 세 층 조합 · 찾기 AND) — 없으면 그 줄 없음 */ pickCombo?: () => Promise<{ key: string; label: string } | null>; - /** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */ - copyTo?: { layer: Layer; owner: string | null; label: string }[]; + /** 「〇〇로 복사…」 — 그 층 폴더를 골라 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로 · 39-6) */ + copyTo?: CopyTarget[]; /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; /** 항목을 누름 — 표에서 그 열을 고름 */ @@ -533,18 +534,20 @@ 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 { + /** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 이 나무는 그대로 */ + async function copyTo(list: Target[], to: CopyTarget): 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(/(으로|로) 복사$/, ""); + const dest = await askCopyFolder(api.fetch, to, list.length); + if (!dest) return; + await api.paste(to.layer, to.owner, clip, dest); + const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, ""); showToast( - L("M02_TreeCopiedTo").replace("{n}", String(list.length)).replace("{layer}", where), + L("M02_TreeCopiedTo") + .replace("{n}", String(list.length)) + .replace("{layer}", where) + .replace("{folder}", dest.join(" / ") || L("M02_TreeTop")), "success", ); } catch (error) { diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts new file mode 100644 index 000000000..38b03c5f6 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts @@ -0,0 +1,26 @@ +/* ============================================================================= + * M02_MasterTemplete_TreePanel_CopyTo.ts + * 「〇〇로 복사…」(39-6) — 붙일 층의 나무를 읽어 대상 폴더를 고름(맨 위 · 폴더) → 경로 · 취소 = null. + * 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움. + * ========================================================================== */ + +import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch"; +import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs"; +import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model"; + +export interface CopyTarget { + layer: Layer; + owner: string | null; + /** 메뉴 글 — `{n}` = 개수 */ + label: string; +} + +export async function askCopyFolder( + fetchTree: (layer: Layer, owner: string | null) => Promise, + to: CopyTarget, + n: number, +): Promise { + const view = await fetchTree(to.layer, to.owner).catch(() => null); + const folders = view ? listFolders(buildFolders(view)) : []; + return askFolder(to.label.replace("{n}", String(n)), folders, () => false, []); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts index bd748d131..4c4ba094e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts @@ -4,8 +4,8 @@ * 「구조물 조합」 아래 한 줄 · 나무 종류와 무관한 공용 부품. 고른 층의 나무는 부르는 쪽이 이 줄 아래에 붙임. * 권한 없는 층 = 단추 글 끝에 「읽기」(권한 = `accessFor` · 서버 `M02_Layer_Access` 와 짝) * 회사 = 볼 회사(`viewCompany` — 시스템 관리자는 해시 `company` · `aislo.adminCompany` · 자기 회사) - * 시스템 관리자가 남의 회사를 보면 그 회사 관리자처럼(PLAN 29-6) — 「개인」 = 그 회사 사람 나무 - * (`createPeopleTree` · 사람 줄 → 그 사람 라이브러리 · 읽기) · 자기 개인 층은 자기 회사를 고를 때만 + * 볼 회사를 맡으면(`manages` · 39-6 — 시스템 관리자 · 그 회사 관리자) 「개인」 = 그 회사 사람 나무 + * (`createPeopleTree` · 나 맨 위 · 사람 줄 → 그 사람 라이브러리 · 고침) · 일반 = 내 개인 그대로 * 누르면 `go`(부르는 쪽이 해시로 다시 엶). * ========================================================================== */ @@ -18,6 +18,7 @@ import { accessFor, buildNeedCompany, layerOfRoot, + manages, ownOwner, type Root, } from "./M02_MasterTemplete_Layer"; @@ -40,9 +41,9 @@ export interface LayerBarOptions { export const companyOwner = (company: string | null, user: SessionUser | null): string | null => company && company !== ownOwner("company", user) ? company : null; -/** 시스템 관리자가 남의 회사를 봄 — 「개인」 단추 = 그 회사 사람 나무(읽기) */ +/** 볼 회사를 맡음(39-6) — 「개인」 단추 = 그 회사 사람 나무 */ export const seesOthers = (company: string | null, user: SessionUser | null): boolean => - user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user); + manages(company, user); export function createLayerBar(opt: LayerBarOptions): HTMLElement { const { user, active } = opt; @@ -57,7 +58,7 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement { } function button(root: Root, owner: string | null, people = false): HTMLButtonElement { - const readOnly = people || !accessFor(layerOfRoot(root), owner, user).canWrite; + const readOnly = !people && !accessFor(layerOfRoot(root), owner, user).canWrite; const name = L( root === "master" ? "M02_PickerSystem" @@ -113,7 +114,8 @@ export function createPeopleTree(opt: PeopleTreeOptions): HTMLElement { box.replaceChildren( ...rows.flatMap((one) => { const current = one.owner === opt.active; - const node = { id: one.owner, label: one.name, count: null, folder: true }; + const label = one.비어있음 ? `${one.name} · ${L("M02_PeopleEmpty")}` : one.name; + const node = { id: one.owner, label, count: null, folder: true }; const row = createTreeRow(node, current ? "▾" : "▸", 0, () => { if (!current) return opt.go(one.owner); wrap.hidden = !wrap.hidden; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 9b792a9ce..63227afb3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -76,12 +76,15 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { const to = layerOfRoot(one); - if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return []; + const toOwner = one === "company" ? companyOwner(company, user) : null; + if (!accessFor(to, toOwner, user).canWrite || (to === layer && toOwner === here)) return []; const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal"; - return [{ layer: to, owner: null, label: L(`M02_TreeCopyTo${key}`) }]; + return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }]; }); const tree = createTreePanel({ layer, diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 6ffa334e9..721a75faa 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -26,11 +26,12 @@ def _text(path: Path) -> str: @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_층별_고칠_권한(): - """서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c) — 남의 층(owner)은 읽기만.""" + """서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c · 39-6) — 남의 층(owner) = 맡은 회사면 고침.""" text = _text(LAYER) own = re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M) found = re.search(r"^export function accessFor[\s\S]*?^}", text, re.M) - assert own and found, "ownOwner · accessFor 를 못 찾음" + manages = re.search(r"^export function manages[\s\S]*?^}", text, re.M) + assert own and found and manages, "ownOwner · accessFor · manages 를 못 찾음" script = ( 'type ManageLayer = "system" | "company" | "personal";\n' "interface SessionUser { id: string; role?: string; company_id?: number | null;" @@ -38,6 +39,8 @@ def test_층별_고칠_권한(): "interface LayerAccess { canWrite: boolean; needCompany: boolean }\n" + own.group(0).replace("export ", "") + found.group(0).replace("export ", "") + + "\n" + + manages.group(0).replace("export ", "") + "\nconst cases = JSON.parse(process.argv[1]);" + "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1], c[2]))));" ) @@ -54,7 +57,8 @@ def test_층별_고칠_권한(): ["personal", None, user], ["personal", None, admin], ["personal", None, orphan], ["personal", None, sysadmin], ["personal", "3/7", user], ["personal", "3/7", admin], ["company", "9", sysadmin], - ["personal", "9/90", sysadmin], + ["personal", "9/90", sysadmin], ["personal", "3/5", user], ["personal", "9/90", admin], + ["company", "9", admin], ] # fmt: skip result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", @@ -69,7 +73,8 @@ def test_층별_고칠_권한(): ok, ro, ro, # 시스템 = SYSTEM_ADMIN 만 ok, ok, ro, no_company, ok, # 회사 = ADMIN · is_master · SYSTEM_ADMIN · 일반 읽기만 ok, ok, no_company, no_company, # 개인 = 본인 · 회사 없으면 안내 - ok, ro, ro, ro, # 자기 층 owner 는 자기 층 · 남의 층은 읽기만 + ok, ok, ok, ok, # 자기 층 owner 는 자기 층 · 맡은 회사(39-6)의 남 층 = 고침 + ro, ro, ro, # 일반의 남 개인 · 회사 관리자의 남의 회사 = 못 고침(읽기는 서버가 403) ] # fmt: skip @@ -181,7 +186,9 @@ def test_층_단추_셋_남의_회사_사람_나무(): 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 + assert "manages(company, user);" in roots.split("export const seesOthers")[1] # 맡은 회사(39-6) + assert "const readOnly = !people && !accessFor(" in roots # 사람 나무 = 고침 + assert 'L("M02_PeopleEmpty")' in roots assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page assert 'peopleTree("구조물조합", tree.root)' in page @@ -269,12 +276,19 @@ def test_읽기만_표는_단추_고치기_모두_막음(): def test_켜진_층_단추는_목록으로_복사_끌기(): """30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) · - 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) · 끌어다 옮기기는 쓸 수 있는 층만.""" + 30-2 · 39-6 「〇〇로 복사…」 = 쓸 수 있는 층(지금 층 뺌 · 폴더 고름) · 끌어다 옮기기는 쓸 수 있는 층만.""" 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 "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page + # 39-6 — 쓸 수 있는 층 전부(볼 회사 주인까지 · 지금 층 뺌) · 대상 폴더 고르는 창 + assert 'const toOwner = one === "company" ? companyOwner(company, user) : null;' in page + assert "(to === layer && toOwner === here)) return [];" in page + copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts") + assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to + assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text( + M02 / "M02_MasterTemplete_TreePanel.ts" + ) panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert "if (opt.canEdit) drag.arm(button, one.id);" in panel drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index c0c5e4e46..be85441fb 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -105,6 +105,7 @@ export const ui_locales_m2 = { M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"], M02_SourcePeople: ["다른 사람", "Others"], M02_SourcePractice: ["실무문서", "Practice documents"], + M02_PeopleEmpty: ["비어 있음", "empty"], M02_TreeSearch: [ "찾기 — 이름 · 키 · 폴더 · 단위 (낱말 여럿 = 모두 담은 것)", "Find — name, key, folder, unit (all words)", @@ -144,10 +145,10 @@ export const ui_locales_m2 = { M02_NewItemPickCombo: ["고르기", "Pick"], M02_TreePickCombo: ["일위대가 고르기", "Pick unit price"], M02_TableFold: ["표 접기 · 펴기", "Fold or unfold the table"], - 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_TreeCopyToMaster: ["마스터로 복사… ({n}개)", "Copy to master… ({n})"], + M02_TreeCopyToCompany: ["회사로 복사… ({n}개)", "Copy to company… ({n})"], + M02_TreeCopyToPersonal: ["개인으로 복사… ({n}개)", "Copy to personal… ({n})"], + M02_TreeCopiedTo: ["{n}개를 {layer} 「{folder}」에 복사함", "Copied {n} to {layer} “{folder}”"], M02_TreeFolderName: ["폴더 이름", "Folder name"], M02_TreeItemName: ["항목 이름", "Item name"], M02_TreeUnit: ["단위", "Unit"],