feat(M02): 구조물 라이브러리 한 화면 · 뿌리 셋 · 층 권한 한 벌 · 남의 층 목록 · 해시 진입 (PLAN 27-1)

- 층 권한 한 벌 M02_Layer_Access — 나무 · 항목(Router_Items) · 양식(Router_Layers) 같은 규칙 · owner 인자 · 남의 층은 읽기만 · 읽기로 남의 층에 뼈대 안 씀
- 남의 층 목록 GET /api/m02/layers/{company|personal}/owners — 시스템 관리자 회사 목록 · 회사 관리자 같은 회사 사람
- ?layer= 페이지 분리 폐기 → 한 화면 뿌리 셋(마스터 · 회사 · 개인) · 뿌리 부품 TreePanel_Roots(펼칠 때 불러옴) · TreePanel 은 owner · api 를 받는 공용 부품
- 진입 해시 ?open=구조물조합|일위대가|옵션|도면&root=&owner= · 항목 고르기 [관리 화면에서 고치기]도 이 해시
- 「항목 나무」 → 「구조물 라이브러리」
- 시험 test_m02_layer_access 새로 · layer_screen · template_layers 새 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 13:19:29 +09:00
co-authored by Claude Opus 5.5
parent 6c09be87f8
commit 59cb28ce58
21 changed files with 754 additions and 194 deletions
+107
View File
@@ -0,0 +1,107 @@
"""M02 층 권한 한 벌 — 나무 · 항목(`Router_Items`) · 양식 · 남의 층 목록(`Router_Layers`)이 함께 씀
(PLAN 27-1c). 화면 쪽 짝 = `M02_MasterTemplete_Layer.ts` 의 `accessFor`.
system 읽기 = 로그인 누구나 · 수정 = SYSTEM_ADMIN
company {C} 자기 회사 = 읽기 누구나 · 수정 = 회사 관리자(ADMIN · 마스터 · SYSTEM_ADMIN) ·
남의 회사 = SYSTEM_ADMIN 읽기만
personal {C}/{U} 본인 = 읽기 · 수정 · 같은 회사 남 = 회사 관리자 읽기만 ·
다른 회사 = SYSTEM_ADMIN 읽기만
owner 글 없음 = 자기 층 · 남의 층은 그 층 폴더가 있을 때만(없으면 404) ·
읽기로 남의 층에 뼈대를 안 만듦.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from fastapi import HTTPException
from M02_MasterTemplete import M02_Template_Layers as layers
NO_COMPANY = "회사부터 등록 — 회사 없이는 회사 · 개인 구조물 집계표를 만들 수 없음"
_SHAPE = {"company": re.compile(r"^\d+$"), "personal": re.compile(r"^\d+/\d+$")}
def is_system_admin(session: dict[str, Any]) -> bool:
return session.get("role") == "SYSTEM_ADMIN"
def is_company_admin(session: dict[str, Any]) -> bool:
return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master"))
@dataclass(frozen=True)
class Grant:
"""층 한 칸의 권한 — `owner` = 층 주인 글(시스템 None) · `mine` = 자기 층(뼈대 만들어도 됨)."""
layer: str
owner: str | None
write: bool
mine: bool
def own_owner(session: dict[str, Any], layer: str, company: Any = None) -> str | None:
"""자기 층 주인 글 — 회사 없으면 None(`company` = 프로젝트 회사로 바꿔 끼울 때)."""
company = session.get("company_id") if company is None else company
if company is None:
return None
return str(company) if layer == "company" else f"{company}/{session.get('user_id')}"
def grant(session: dict[str, Any], layer: str, owner: str | None = None) -> Grant:
"""읽기 권한이 없으면 403 · 회사 없는 사람의 자기 층은 409 · 모양이 틀리면 400."""
if layer == "system":
return Grant(layer, None, is_system_admin(session), True)
if layer not in _SHAPE:
raise HTTPException(status_code=404, detail="없는 층입니다.")
own = own_owner(session, layer)
who = own if owner in (None, "") else str(owner)
if who is None:
raise HTTPException(status_code=409, detail=NO_COMPANY)
if not _SHAPE[layer].match(who):
raise HTTPException(status_code=400, detail=f"층 주인 모양이 틀림 「{who}」")
if who == own:
return Grant(layer, who, layer == "personal" or is_company_admin(session), True)
same_company = who.split("/")[0] == str(session.get("company_id"))
colleague = layer == "personal" and same_company and is_company_admin(session)
if not (is_system_admin(session) or colleague):
raise HTTPException(
status_code=403, detail="다른 회사 · 다른 사람의 구조물 집계표는 볼 수 없음"
)
return Grant(layer, who, False, False)
def require(
session: dict[str, Any], layer: str, owner: str | None = None, *, write: bool = False
) -> Grant:
"""`grant` + 쓰기 권한까지 — 까닭은 층마다."""
got = grant(session, layer, owner)
if write and not got.write:
if layer == "system":
detail = "마스터 템플릿은 시스템 관리자만 고침."
elif got.mine:
detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀."
else:
detail = "남의 층은 읽기만."
raise HTTPException(status_code=403, detail=detail)
return got
def folder(got: Grant) -> Path:
"""회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함(부르는 쪽은 자기 층만 뼈대를 만듦)."""
if got.owner is None:
raise HTTPException(status_code=409, detail=NO_COMPANY)
try:
if got.layer == "company":
path = layers.company_dir(got.owner)
else:
company, user = got.owner.split("/", 1)
path = layers.personal_dir(company, user)
except ValueError as error:
raise HTTPException(status_code=400, detail=f"층 주인 모양 — {error}") from error
if not got.mine and not path.is_dir():
raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음")
return path
@@ -53,21 +53,35 @@ function reason(detail: unknown, status: number): string {
return lines.filter(Boolean).join(" · ") || `HTTP ${status}`;
}
/** 지금 화면의 층 — 시스템 = 옛 길 그대로 · 회사 · 개인 = `/layers/{층}` 아래(구조물집계표 · 조합 길 모두) */
/** 지금 화면의 층 — 시스템 = 옛 길 그대로 · 회사 · 개인 = `/layers/{층}` 아래(구조물집계표 · 조합 길 모두) ·
* `owner` = 남의 층을 볼 때 주인(회사 `{C}` · 개인 `{C}/{U}` · 자기 층 = null · PLAN 27-1c) —
* 지금 층 길에 `?owner=` 로 붙음(M02 화면을 떠나면 페이지가 시스템 · null 로 되돌림) */
let activeLayer: Layer = "system";
export const setActiveLayer = (layer: Layer): void => {
let activeOwner: string | null = null;
export const setActiveLayer = (layer: Layer, owner: string | null = null): void => {
activeLayer = layer;
activeOwner = owner;
};
export const getActiveLayer = (): Layer => activeLayer;
const scoped = <T>(path: string, init: RequestInit = {}): Promise<T> =>
call<T>(`${activeLayer === "system" ? "" : `/layers/${activeLayer}`}${path}`, init);
export const getActiveOwner = (): string | null => activeOwner;
const enc = encodeURIComponent;
/** 지금 층 길이면 `owner` 인자를 붙임 */
const owned = (layer: Layer, path: string): string =>
layer === activeLayer && activeOwner && layer !== "system" && layer !== "project"
? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(activeOwner)}`
: path;
const scoped = <T>(path: string, init: RequestInit = {}): Promise<T> =>
call<T>(
owned(activeLayer, `${activeLayer === "system" ? "" : `/layers/${activeLayer}`}${path}`),
init,
);
const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : "");
const head = (layer: Layer): string =>
layer === "system" ? "/templates" : `/layers/${layer}/templates`;
const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string =>
`${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`;
owned(layer, `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`);
/** 시스템 길은 배열 · 층 길은 `{층, 양식: [...]}` — 둘 다 목록으로 */
export const listTemplates = async (
@@ -75,7 +89,7 @@ export const listTemplates = async (
projectId: string | null,
): Promise<TemplateInfo[]> => {
const got = await call<TemplateInfo[] | { 양식: TemplateInfo[] }>(
layer === "system" ? head(layer) : `${head(layer)}${q(projectId)}`,
layer === "system" ? head(layer) : owned(layer, `${head(layer)}${q(projectId)}`),
);
return Array.isArray(got) ? got : got.양식;
};
@@ -108,7 +122,7 @@ export const deleteLayerTemplate = (
name: string,
판: string,
): Promise<{ ok: boolean }> =>
call(`/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`, {
call(owned(layer, `/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`), {
method: "DELETE",
});
@@ -384,10 +398,10 @@ export interface InputLinks {
}
const itemPath = (layer: Layer, key: string, tail: string): string =>
`/layers/${layer}/items/${enc(key)}/${tail}`;
owned(layer, `/layers/${layer}/items/${enc(key)}/${tail}`);
export const fetchItemRows = (layer: Layer): Promise<ItemRow[]> =>
call<ItemRow[]>(`/items?layer=${layer}`);
call<ItemRow[]>(owned(layer, `/items?layer=${layer}`));
export const fetchItemInputs = (layer: Layer, key: string): Promise<ItemInputs> =>
call(itemPath(layer, key, "inputs"));
@@ -404,7 +418,7 @@ export const saveInputLinks = (
): Promise<{ 판: string }> =>
call(itemPath(layer, key, "input-links"), { method: "PUT", body: JSON.stringify({ 판, 줄 }) });
/* --- 항목 나무(PLAN 26-1 · 26-3a) — 폴더 경로 깊이 자유 · 고침은 층 쓰기 권한 --- */
/* --- 구조물 라이브러리(PLAN 26-1 · 26-3a) — 폴더 경로 깊이 자유 · 고침은 층 쓰기 권한 --- */
export interface TreeItem {
key: string;
열: string;
@@ -434,8 +448,31 @@ export interface TreeEdit {
단위?: string;
}
export const fetchTree = (layer: Layer): Promise<TreeView> => call(`/layers/${layer}/tree`);
/** `owner` = 남의 층(없으면 자기 층) — 지금 층과 따로 받음(나무 부품이 여러 뿌리를 그림) */
const treePath = (layer: Layer, owner: string | null): string =>
`/layers/${layer}/tree${owner ? `?owner=${enc(owner)}` : ""}`;
export const fetchTree = (layer: Layer, owner: string | null = null): Promise<TreeView> =>
call(treePath(layer, owner));
/** 판이 다르면 409(`StaleError`) — 부르는 쪽이 다시 읽음 */
export const editTree = (layer: Layer, edit: TreeEdit, 판: string): Promise<TreeView> =>
call(`/layers/${layer}/tree`, { method: "POST", body: JSON.stringify({ ...edit, 판 }) });
export const editTree = (
layer: Layer,
edit: TreeEdit,
판: string,
owner: string | null = null,
): Promise<TreeView> =>
call(treePath(layer, owner), { method: "POST", body: JSON.stringify({ ...edit, 판 }) });
/** 남의 층 목록 한 줄(PLAN 27-1c) — 회사 `{C}` · 개인 `{C}/{U}` */
export interface LayerOwner {
owner: string;
name: string;
}
/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사 사람(본인 뺌 · 층 자리 있는 것만) */
export const fetchOwners = (
layer: "company" | "personal",
company: string | null = null,
): Promise<LayerOwner[]> =>
call(`/layers/${layer}/owners${company ? `?company=${enc(company)}` : ""}`);
+49 -16
View File
@@ -1,7 +1,8 @@
/* =============================================================================
* M02_MasterTemplete_Layer.ts
* M02 화면의 층 — 주소 인자(`#/m02-master-template?layer=personal|company` · 없으면 system) ·
* 층별 제목 · 고칠 권한(시스템 = SYSTEM_ADMIN · 회사 = role ADMIN 또는 is_master · 개인 = 본인) ·
* M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시
* `#/m02-master-template?open=구조물조합|일위대가|옵션|도면&root=master|company|personal&owner=…`
* (옛 `?layer=` 도 root 로 읽음) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) ·
* 회사 없음 안내 · 회사용 읽기만일 때 [개인용으로 복사] 막대.
* ========================================================================== */
@@ -13,31 +14,63 @@ import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_Api_Fetch";
export type ManageLayer = "system" | "company" | "personal";
/** 해시 · 화면의 뿌리 이름 — 서버 층 이름은 `layerOfRoot` */
export type Root = "master" | "company" | "personal";
export type OpenTarget = "구조물조합" | "일위대가" | "옵션" | "도면";
/** 주소 인자에서 층 — 모르는 값 · 없음 = 시스템 */
export function layerFromHash(): ManageLayer {
const value = currentQuery().get("layer");
return value === "company" || value === "personal" ? value : "system";
export interface Entry {
root: Root;
/** 남의 층 주인(회사 `{C}` · 개인 `{C}/{U}`) — 자기 층 = null */
owner: string | null;
open: OpenTarget;
}
export const titleOf = (layer: ManageLayer): string =>
layer === "company"
? L("M02_TitleCompany")
: layer === "personal"
? L("M02_TitlePersonal")
: L("M02_Title");
const OPENS: OpenTarget[] = ["구조물조합", "일위대가", "옵션", "도면"];
export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root);
/** 주소 인자에서 진입 — 모르는 값 · 없음 = 마스터 · 구조물 조합 */
export function entryFromHash(): Entry {
const query = currentQuery();
const raw = query.get("root") ?? query.get("layer");
const root: Root = raw === "company" || raw === "personal" ? raw : "master";
const open = OPENS.find((one) => one === query.get("open")) ?? "구조물조합";
return { root, owner: root === "master" ? null : query.get("owner") || null, open };
}
/** 진입 해시 인자 — `navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery(…))` */
export const entryQuery = (entry: Entry): Record<string, string> => ({
open: entry.open,
root: entry.root,
...(entry.owner ? { owner: entry.owner } : {}),
});
/** 자기 층 주인 글 — 회사 없으면 null */
export const ownOwner = (layer: ManageLayer, user: SessionUser | null): string | null =>
!user?.company_id || layer === "system"
? null
: layer === "company"
? String(user.company_id)
: `${user.company_id}/${user.id}`;
export interface LayerAccess {
/** 이 층을 고칠 수 있나 — 서버도 같은 규칙으로 막음 */
canWrite: boolean;
/** 회사 · 개인 층인데 회사가 없음 — 「회사부터 등록」 안내 */
/** 회사 · 개인 자기 층인데 회사가 없음 — 「회사부터 등록」 안내 */
needCompany: boolean;
}
export function accessFor(layer: ManageLayer, user: SessionUser | null): LayerAccess {
/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`)은 늘 읽기만(읽을 수 있나는 서버가 가름) */
export function accessFor(
layer: ManageLayer,
owner: string | null,
user: SessionUser | null,
): LayerAccess {
if (layer === "system") return { canWrite: user?.role === "SYSTEM_ADMIN", needCompany: false };
if (!user?.company_id) return { canWrite: false, needCompany: true };
const companyAdmin = user.role === "ADMIN" || !!user.is_master;
const own = ownOwner(layer, user);
if (owner && owner !== own) return { canWrite: false, 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 };
}
@@ -1,9 +1,7 @@
"""M02 층 집계표 API — `/api/m02/layers/{layer}/…` · 항목 목록 · 항목 복사(PLAN 21-1 · 21-2).
⚠ 등록은 `main.py`(로그인만) · 층 권한은 여기서(`Router_Layers._layer_dir` 와 같은 규칙):
system 읽기 = 로그인 누구나(항목 고르기) · 쓰기 = SYSTEM_ADMIN
company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(ADMIN · 마스터 · SYSTEM_ADMIN)
personal 읽기 · 쓰기 = 본인(SYSTEM_ADMIN 은 누구나) · 회사 없음 = 쓰기 409 「회사부터 등록」
⚠ 등록은 `main.py`(로그인만) · 층 권한은 `M02_Layer_Access`(양식 길 `Router_Layers` 와 한 벌 ·
PLAN 27-1c) · `owner` = 회사 `{C}` · 개인 `{C}/{U}`(없으면 자기 층) · 회사 없음 = 409.
옛 길(`/api/m02/structures/…` 등 · `M02_MasterTemplete_Router.py`)은 시스템 층 별칭으로 그대로.
회사 · 개인 층은 처음 부를 때 빈 뼈대를 만듦(`Items.ensure`).
결정(PLAN 21-9): 일위대가(UA) 갈라 내기는 새 UA 가 M01 시스템 마스터에 생기므로 층 길에서도
@@ -19,13 +17,13 @@ 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_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Branch as branch
from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_MasterTemplete_Router import (
BranchBody,
MaterialBranchBody,
@@ -37,7 +35,7 @@ router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"])
Layer = Literal["system", "company", "personal"]
Session = dict[str, Any]
NO_COMPANY = "회사부터 등록 — 회사 없이는 회사 · 개인 구조물 집계표를 만들 수 없음"
NO_COMPANY = access.NO_COMPANY
class InputLinksBody(BaseModel):
@@ -63,55 +61,16 @@ class CopyItemBody(BaseModel):
key: str
def _admin(session: Session) -> bool:
return session.get("role") == "SYSTEM_ADMIN"
def _company_admin(session: Session) -> bool:
return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master"))
def _owner(session: Session, layer: str, owner: str | None) -> str | None:
"""층 주인 글 — 회사 `{회사}` · 개인 `{회사}/{사용자}` · 시스템 None ·
남의 것은 SYSTEM_ADMIN 만."""
if layer == "system":
return None
company, user = session.get("company_id"), session.get("user_id")
mine = str(company) if layer == "company" else f"{company}/{user}"
if owner in (None, "", mine):
return None if company is None else mine
if not _admin(session):
raise HTTPException(status_code=403, detail="다른 회사 · 다른 사람의 구조물 집계표입니다.")
return owner
def _folder(layer: str, owner: str | None) -> Path:
if layer == "system":
return store.FOLDER
try:
if layer == "company":
return layers.company_dir(owner)
company, user = str(owner).split("/", 1)
return layers.personal_dir(company, user)
except ValueError as error:
raise HTTPException(status_code=400, detail=f"층 주인 모양 — {error}") from error
def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path:
"""층 폴더(권한까지) — 회사 · 개인은 없으면 빈 뼈대를 만듦. 회사 없는 사람: 쓰기 409."""
"""층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦."""
got = access.require(session, layer, owner, write=write)
if layer == "system":
if write and not _admin(session):
raise HTTPException(status_code=403, detail="마스터 템플릿은 시스템 관리자만 고침.")
return store.FOLDER
who = _owner(session, layer, owner)
if who is None:
raise HTTPException(status_code=409, detail=NO_COMPANY)
if write and layer == "company" and not _company_admin(session):
raise HTTPException(
status_code=403, detail="회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀."
)
folder = _folder(layer, who)
items_store.ensure(folder)
folder = access.folder(got)
if got.mine:
items_store.ensure(folder)
elif not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file():
raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음")
return folder
@@ -127,9 +86,9 @@ def _call(folder: Path, fn, *args):
def _listing(session: Session, layer: str, owner: str | None) -> dict[str, Any]:
who = None if layer == "system" else _owner(session, layer, owner)
if layer != "system" and who is None:
if layer != "system" and not owner and access.own_owner(session, layer) is None:
return {"layer": layer, "owner": None, "items": []} # 회사 없음 = 빈 그룹
who = access.grant(session, layer, owner).owner
folder = _root(session, layer, who)
return {"layer": layer, "owner": who, "items": items_store.items(folder)}
@@ -154,9 +113,7 @@ def copy_item(body: CopyItemBody, session: Session = Depends(verify_session)) ->
"""회사 · 마스터 항목 → 내 개인 층 새 항목(열 · 세 파일 · 자재 조합 줄 · 새 SS 키 · `본뜬`)."""
target = _root(session, "personal", None, write=True)
source = _root(session, body.from_layer, body.from_owner)
owner = (
None if body.from_layer == "system" else _owner(session, body.from_layer, body.from_owner)
)
owner = access.grant(session, body.from_layer, body.from_owner).owner
origin = {"layer": body.from_layer, "owner": owner, "key": body.key}
try:
return items_store.copy_item(source, target, body.key, origin)
@@ -285,7 +242,7 @@ def post_combo(
):
"""일위대가 조합 갈라 내기 — 새 UA 는 M01 시스템 마스터에 생기므로 SYSTEM_ADMIN 만."""
folder = _root(session, layer, owner, write=True)
if not _admin(session):
if not access.is_system_admin(session):
raise HTTPException(status_code=403, detail="일위대가 조합은 시스템 관리자만 새로 만듦.")
return _call(
folder, branch.branch_combo, column, body.본뜬키, body.담은로직, body.이름, body.판
@@ -1,10 +1,9 @@
"""M02 양식 층 API — 계약 `tmp/M02_분석/6_계약.md` 「층 (sub4)」.
⚠ 등록은 `main.py`(로그인만) · 층마다 권한은 여기서:
system 읽기 · 쓰기는 시스템 관리자(옛 `/api/m02/templates` 길과 같은 저장소)
company 같은 회사 읽기 · 쓰기는 회사 관리자(ADMIN · 마스터 · 시스템 관리자)
personal 본인 읽기·쓰기 · 같은 회사 사람 것은 읽기만(가져오기)
project 같은 회사 프로젝트 · 작업본 쓰기 · `_initial/` 은 안 씀
⚠ 등록은 `main.py`(로그인만) · 시스템 · 회사 · 개인 층 권한은 `M02_Layer_Access`(나무 · 항목 길과
한 벌 · PLAN 27-1c · `owner` = 회사 `{C}` · 개인 `{C}/{U}`) · 남의 층 목록 = `/layers/{층}/owners`:
project 같은 회사 프로젝트 · 작업본 쓰기 · `_initial/` 은 안 씀 ·
가져오기(apply · sources)는 같은 회사 사람 개인 층도 읽음
"""
from __future__ import annotations
@@ -26,6 +25,7 @@ from common_util import common_util_spreadsheet as spreadsheet
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool, run_with_connection
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Table_Fill as fill
@@ -115,6 +115,14 @@ async def _company_projects(company_id: int) -> list[dict[str, Any]]:
return [{"project_id": str(row[0]), "name": row[1], "storage_path": row[2]} for row in rows]
async def _companies() -> list[dict[str, Any]]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute("SELECT id, name FROM companies WHERE deleted_at IS NULL ORDER BY id")
rows = await cursor.fetchall()
return [{"company_id": row[0], "name": row[1]} for row in rows]
async def _cross_designs(project_id: str) -> list[dict[str, Any]]:
"""최신 노선의 B06 횡단 설계 — 못 읽으면 빈 목록(관 연장이 빈칸으로 섬 · 0 아님)."""
try:
@@ -129,12 +137,8 @@ async def _cross_designs(project_id: str) -> list[dict[str, Any]]:
# ── 권한 · 자리 ───────────────────────────────────────
def _is_system_admin(session: dict[str, Any]) -> bool:
return session.get("role") == "SYSTEM_ADMIN"
def _is_company_admin(session: dict[str, Any]) -> bool:
return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master"))
_is_system_admin = access.is_system_admin
_is_company_admin = access.is_company_admin
async def _project(session: dict[str, Any], project_id: str | None) -> dict[str, Any]:
@@ -172,28 +176,23 @@ async def _layer_dir(
*,
project_id: str | None,
user_id: int | None = None,
owner: str | None = None,
write: bool = False,
) -> Path:
"""층 폴더 — 읽기·쓰기 권한까지 여기서 가름."""
if layer == "system":
if write and not _is_system_admin(session):
raise HTTPException(status_code=403, detail="시스템 양식은 시스템 관리자만.")
return layers.system_dir()
"""층 폴더 — 권한은 `M02_Layer_Access` 한 벌(나무 · 항목 길과 같음 · PLAN 27-1c) ·
`owner` 없으면 프로젝트(없으면 세션) 회사의 회사 층 · 개인 층(`user_id` 또는 본인)."""
if layer == "project":
return layers.project_dir((await _project(session, project_id))["root"])
project = await _project(session, project_id) if project_id else None
company_id = _company_of(session, project)
if layer == "company":
if write and not _is_company_admin(session):
raise HTTPException(status_code=403, detail="회사 공식 양식은 회사 관리자만.")
return _ensured(layers.company_dir(company_id))
if layer == "personal":
owner = int(user_id) if user_id is not None else int(session["user_id"])
if write and owner != session.get("user_id"):
raise HTTPException(status_code=403, detail="개인 양식은 본인만 고칩니다.")
await _same_company_user(session, owner, company_id)
return _ensured(layers.personal_dir(company_id, owner))
raise HTTPException(status_code=404, detail="없는 층입니다.")
if layer == "system":
access.require(session, layer, write=write)
return layers.system_dir()
if not owner:
project = await _project(session, project_id) if project_id else None
company_id = _company_of(session, project)
who = session["user_id"] if user_id is None else user_id
owner = str(company_id) if layer == "company" else f"{company_id}/{who}"
got = access.require(session, layer, owner, write=write)
return _ensured(access.folder(got)) if got.mine else access.folder(got)
def _ensured(folder: Path) -> Path:
@@ -209,14 +208,49 @@ def _bad(error: ValueError) -> HTTPException:
# ── 층 길 ─────────────────────────────────────────────
@router.get("/layers/{layer}/owners")
async def list_owners(
layer: Literal["company", "personal"],
company: int | None = Query(None),
session: dict[str, Any] = Depends(verify_session),
) -> list[dict[str, Any]]:
"""남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]` · 층 자리가 있는 것만(본인 뺌).
회사 = SYSTEM_ADMIN 만 회사 목록 · 개인 = 그 회사 사람(SYSTEM_ADMIN 은 어느 회사나 ·
회사 관리자는 자기 회사만)."""
if layer == "company":
if not access.is_system_admin(session):
raise HTTPException(status_code=403, detail="회사 목록은 시스템 관리자만 봄.")
rows = [(str(r["company_id"]), r["name"]) for r in await _companies()]
else:
target = session.get("company_id") if company is None else company
if target is None:
raise HTTPException(status_code=409, detail=access.NO_COMPANY)
mine = str(target) == str(session.get("company_id"))
if not (access.is_system_admin(session) or (mine and access.is_company_admin(session))):
raise HTTPException(status_code=403, detail="같은 회사 사람 목록은 회사 관리자만 봄.")
me = session.get("user_id")
users = await _company_users(int(target))
rows = [(f"{target}/{u['user_id']}", u["name"]) for u in users if u["user_id"] != me]
found = []
for owner, name in rows:
got = access.Grant(layer, owner, False, owner == access.own_owner(session, layer))
try:
access.folder(got)
except HTTPException:
continue
found.append({"owner": owner, "name": name})
return found
@router.get("/layers/{layer}/templates")
async def list_layer(
layer: Layer,
project_id: str | None = Query(None),
user_id: int | None = Query(None),
owner: str | None = Query(None),
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id)
folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id, owner=owner)
rows = await asyncio.to_thread(layers.list_templates, folder)
manifest = await asyncio.to_thread(layers.read_manifest, folder)
for row in rows:
@@ -231,9 +265,10 @@ async def read_layer_template(
name: str,
project_id: str | None = Query(None),
user_id: int | None = Query(None),
owner: str | None = Query(None),
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id)
folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id, owner=owner)
try:
found = await asyncio.to_thread(layers.read_template, folder, kind, name)
except ValueError as error:
@@ -258,9 +293,13 @@ async def save_layer_template(
name: str,
body: SaveBody,
project_id: str | None = Query(None),
user_id: int | None = Query(None),
owner: str | None = Query(None),
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
folder = await _layer_dir(session, layer, project_id=project_id, write=True)
folder = await _layer_dir(
session, layer, project_id=project_id, user_id=user_id, owner=owner, write=True
)
document = body.문서
try:
layers.check_skeleton(kind, document) # 빈 `{}` · 뼈대 없는 문서는 파일 안 씀
@@ -318,6 +357,7 @@ async def delete_layer_template(
name: str,
판: str | None = Query(None),
project_id: str | None = Query(None),
owner: str | None = Query(None),
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
"""개인 = 본인만 · 회사 = 회사 관리자만 · 시스템 = 시스템 관리자 · 프로젝트 층은 거절 ·
@@ -326,7 +366,7 @@ async def delete_layer_template(
raise HTTPException(
status_code=403, detail="개인 · 회사 · 시스템 층 양식만 지울 수 있습니다."
)
folder = await _layer_dir(session, layer, project_id=project_id, write=True)
folder = await _layer_dir(session, layer, project_id=project_id, owner=owner, write=True)
if (kind, name) in store.IN_USE:
raise HTTPException(status_code=409, detail=f"쓰는 양식이라 못 지움 「{name}」")
try:
@@ -1,4 +1,4 @@
/* 관리 화면 항목 나무 — 줄 모양은 공용 ui-tree · ui-sidebar-row */
/* 관리 화면 구조물 라이브러리 — 줄 모양은 공용 ui-tree · ui-sidebar-row */
.m02-tree {
padding-top: var(--spacing-8);
display: flex;
@@ -1,6 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_TreePanel.ts
* 관리 화면(마스터 · 회사 · 개인 공용) 좌측 「항목 나무」 — 폴더 → … → 항목(깊이 자유) · 찾기.
* 관리 화면(마스터 · 회사 · 개인 공용) 「구조물 라이브러리」 한 층의 나무 — 폴더 → … → 항목(깊이 자유) · 찾기.
* 층 + `owner`(남의 층 · PLAN 27-1c) 한 벌을 그림 — 뿌리 셋 배치는 `_Roots` · 자료 길은 `api` 로 바꿔 끼움.
* 항목을 누르면 표에서 그 열이 골라짐(카드 넷은 표가 엶) · 표에서 열을 고르면 `select` 로 나무도 그 항목.
* 오른쪽 클릭 · 키보드(F2 · Delete)로 폴더 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 —
* `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 메뉴 · 키 없음. 나무 자료는 `_Model`.
@@ -44,6 +45,15 @@ import {
export interface TreePanelOptions {
layer: Layer;
/** 남의 층 주인(회사 `{C}` · 개인 `{C}/{U}`) — 없으면 자기 층 */
owner?: string | null;
/** 맨 위 줄 글 — 없으면 층 이름 */
label?: string;
/** 나무 읽기 · 고치기 길 — 없으면 구조물 조합 나무(`/layers/{층}/tree`) */
api?: {
fetch: (layer: Layer, owner: string | null) => Promise<TreeView>;
edit: (layer: Layer, edit: TreeEdit, 판: string, owner: string | null) => Promise<TreeView>;
};
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
canEdit: boolean;
/** 항목을 누름 — 표에서 그 열을 고름 */
@@ -67,13 +77,17 @@ 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 layerLabel = L(
opt.layer === "company"
? "M02_PickerCompany"
: opt.layer === "personal"
? "M02_PickerPersonal"
: "M02_PickerSystem",
);
const layerLabel =
opt.label ??
L(
opt.layer === "company"
? "M02_PickerCompany"
: opt.layer === "personal"
? "M02_PickerPersonal"
: "M02_PickerSystem",
);
const owner = opt.owner ?? null;
const api = opt.api ?? { fetch: fetchTree, edit: editTree };
let view: TreeView | null = null;
let text = "";
let activeCol: string | null = null;
@@ -175,7 +189,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
async function refresh(): Promise<void> {
const mine = ++seq;
try {
const got = await fetchTree(opt.layer);
const got = await api.fetch(opt.layer, owner);
if (mine !== seq) return;
view = got;
} catch (error) {
@@ -217,7 +231,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
if (!view || busy) return;
busy = true;
try {
const got = await editTree(opt.layer, body_, view.판);
const got = await api.edit(opt.layer, body_, view.판, owner);
view = got;
const note: TreeNote | undefined = got.알림;
// 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로
@@ -1,6 +1,6 @@
/* =============================================================================
* M02_MasterTemplete_TreePanel_Dialogs.ts
* 항목 나무 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음.
* 구조물 라이브러리 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음.
* 모두 약속(Promise)으로 답함 — 닫기 · Esc · 바깥 누르기 = 취소(null / "cancel").
* ========================================================================== */
@@ -1,6 +1,6 @@
/* =============================================================================
* M02_MasterTemplete_TreePanel_Model.ts
* 관리 화면 항목 나무의 자료 쪽(화면 없음) — 서버 나무(`폴더` + `items`)를 폴더 나무로 · 찾기 ·
* 관리 화면 구조물 라이브러리의 자료 쪽(화면 없음) — 서버 나무(`폴더` + `items`)를 폴더 나무로 · 찾기 ·
* 폴더 목록. 항목 경로는 폴더 이름 배열 + 마지막 마디가 항목 이름(PLAN 26-1 · 깊이 자유).
* ========================================================================== */
@@ -0,0 +1,141 @@
/* =============================================================================
* M02_MasterTemplete_TreePanel_Roots.ts
* 라이브러리 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1b · c) — 나무 종류와 무관한 공용 부품
* (구조물 라이브러리 · 일위대가 라이브러리가 함께 씀). 고른 뿌리(`active`) 아래에 부르는 쪽 나무(`panel`)를 붙임.
* 시스템 관리자 「회사」 → 회사 목록 → (회사 공용 · 그 회사 사람)
* 회사 관리자 「개인」 → (내 것 · 같은 회사 사람)
* 그 밖 마스터 · 회사 · 개인 한 줄씩
* 목록은 펼칠 때 불러옴(`fetchOwners`) · 다른 뿌리를 누르면 `go`(부르는 쪽이 해시로 다시 엶) ·
* 읽기만 뿌리는 줄 끝에 「읽기」(권한 = `accessFor` · 서버 `M02_Layer_Access` 와 짝).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createTreeRow } from "@ui/ui_template_tree";
import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { fetchOwners, type LayerOwner } from "./M02_MasterTemplete_Api_Fetch";
import {
accessFor,
buildNeedCompany,
layerOfRoot,
ownOwner,
type Root,
} from "./M02_MasterTemplete_Layer";
export interface RootsOptions {
user: SessionUser | null;
/** 지금 고른 뿌리 — `owner` null = 자기 층 */
active: { root: Root; owner: string | null };
/** 고른 뿌리 아래에 붙일 나무 */
panel: HTMLElement;
/** 다른 뿌리를 누름 — `owner` null = 자기 층 */
go: (root: Root, owner: string | null) => void;
}
const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint", text });
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
export function createLibraryRoots(opt: RootsOptions): HTMLElement {
const { user, active } = opt;
const isSystem = user?.role === "SYSTEM_ADMIN";
const companyAdmin = isSystem || user?.role === "ADMIN" || !!user?.is_master;
const ownCompany = ownOwner("company", user);
const activeCompany = active.owner?.split("/")[0] ?? ownCompany;
/** 층 하나 — 누르면 그 뿌리로 · 고른 뿌리면 아래에 나무(누르면 접기/펴기) */
function leaf(label: string, root: Root, owner: string | null, depth: number): HTMLElement[] {
const current = root === active.root && owner === active.owner;
const readOnly = !accessFor(layerOfRoot(root), owner, user).canWrite;
const text = readOnly ? `${label} · ${L("M02_RootReadOnly")}` : label;
const wrap = el("div", { className: "m02-roots__panel", children: current ? [opt.panel] : [] });
wrap.style.paddingLeft = `calc(${depth + 1} * var(--spacing-16))`;
const row = createTreeRow(
{ id: `${root}|${owner}`, label: text, count: null },
"",
depth,
() => {
if (!current) return opt.go(root, owner);
wrap.hidden = !wrap.hidden;
},
);
row.classList.toggle("is-active", current);
return current ? [row, wrap] : [row];
}
/** 묶음 — 펼칠 때 한 번 불러옴(`load`) · `open` = 고른 뿌리가 안에 있어 처음부터 펼침 */
function group(
label: string,
depth: number,
open: boolean,
load: () => Promise<HTMLElement[]>,
): HTMLElement {
const kids = el("div", { className: "ui-tree__kids" });
kids.hidden = !open;
let loaded = false;
const fill = async (): Promise<void> => {
loaded = true;
kids.replaceChildren(hint(L("M02_PickerLoading")));
try {
const nodes = await load();
kids.replaceChildren(...(nodes.length ? nodes : [hint(L("M02_RootNone"))]));
} catch (error) {
loaded = false;
kids.replaceChildren(hint(L("M02_PickerLoadFailed").replace("{value}", why(error))));
}
};
const row = createTreeRow({ id: label, label, count: null }, open ? "▾" : "▸", depth, () => {
kids.hidden = !kids.hidden;
const caret = row.querySelector(".ui-tree__caret");
if (caret) caret.textContent = kids.hidden ? "▸" : "▾";
if (!kids.hidden && !loaded) void fill();
});
if (open) void fill();
return el("div", { className: "ui-tree__branch", children: [row, kids] });
}
/** 목록에 없는 고른 층(해시로 바로 들어옴 · 탈퇴한 사람 등)도 줄을 세움 — 나무를 붙일 자리 */
const withActive = (list: LayerOwner[], owner: string | null, fits: boolean): LayerOwner[] =>
owner && fits && !list.some((one) => one.owner === owner)
? [...list, { owner, name: owner }]
: list;
/** 그 회사 사람들의 개인 층(본인 뺌) */
const people = async (company: string | null, depth: number): Promise<HTMLElement[]> => {
const fits = active.root === "personal" && activeCompany === (company ?? ownCompany);
return withActive(await fetchOwners("personal", company), active.owner, fits).flatMap((one) =>
leaf(one.name, "personal", one.owner, depth),
);
};
const master = leaf(L("M02_PickerSystem"), "master", null, 0);
if (!ownCompany && !isSystem) {
return el("div", { className: "m02-roots", children: [...master, buildNeedCompany()] });
}
// 시스템 관리자 — 회사 목록 → 회사 공용 · 그 회사 사람
const companies = (): HTMLElement =>
group(L("M02_PickerCompany"), 0, active.root === "company" || !!active.owner, async () =>
withActive(await fetchOwners("company"), activeCompany, !!active.owner).map((one) => {
const mine = one.owner === ownCompany;
const here = !!active.owner || active.root === "company";
return group(one.name, 1, here && activeCompany === one.owner, async () => [
...leaf(L("M02_RootShared"), "company", mine ? null : one.owner, 2),
...(await people(one.owner, 2)),
]);
}),
);
const company = isSystem ? [companies()] : leaf(L("M02_PickerCompany"), "company", null, 0);
// 회사 관리자 — 개인 → 내 것 · 같은 회사 사람 · 시스템 관리자는 남의 개인을 회사 아래에서 봄
const personal = !ownCompany
? []
: companyAdmin && !isSystem
? [
group(L("M02_PickerPersonal"), 0, active.root === "personal", async () => [
...leaf(L("M02_RootMine"), "personal", null, 1),
...(await people(null, 1)),
]),
]
: leaf(L("M02_PickerPersonal"), "personal", null, 0);
return el("div", { className: "m02-roots", children: [...master, ...company, ...personal] });
}
@@ -106,7 +106,7 @@ export function canEdit(layer: Layer, isAdmin: boolean): boolean {
}
/** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 ·
* `aside` = 「구조물 조합」을 열 때만 편집 칸 왼쪽에 보임(항목 나무 · PLAN 26-7c) */
* `aside` = 「구조물 조합」을 열 때만 편집 칸 왼쪽에 보임(구조물 라이브러리 · PLAN 26-7c) */
export interface MainExtras {
top?: HTMLElement;
aside?: HTMLElement;
@@ -1,54 +1,74 @@
/* =============================================================================
* M02_MasterTemplete_UI_Page.ts
* 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록) / 우측 편집 칸(표·도면 부품) ·
* 「구조물 조합」을 열면 편집 칸 왼쪽에 항목 나무(주 · 고치는 자리) · 오른쪽 참조표(PLAN 26-7c)
* 「구조물 조합」을 열면 편집 칸 왼쪽에 구조물 라이브러리(뿌리 셋 · 주 · 고치는 자리) · 오른쪽 참조표(PLAN 26-7c)
*
* 층 = 주소 인자 `?layer=personal|company`(없으면 시스템) — 같은 화면 한 벌.
* 화면은 로그인만 확인 — 고치기는 층별 권한일 때만 켬(서버도 막음 · `M02_MasterTemplete_Layer.ts`).
* 한 화면에 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 고른 뿌리 = 진입 해시 `?open=&root=&owner=`
* (`M02_MasterTemplete_Layer.ts`) · 다른 뿌리를 누르면 해시를 바꿔 이 화면을 다시 세움.
* 화면은 로그인만 확인 — 고치기는 뿌리별 권한일 때만 켬(서버도 막음 · `accessFor`).
* ========================================================================== */
import "@ui/ui_template_workflow_layout.css";
import { ROUTES } from "@config/config_frontend";
import { el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { navigateTo, type MountedPage } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
import {
accessFor,
buildNeedCompany,
buildReadOnlyBar,
layerFromHash,
titleOf,
entryFromHash,
entryQuery,
layerOfRoot,
ownOwner,
type Root,
} from "./M02_MasterTemplete_Layer";
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
import { createLibraryRoots } from "./M02_MasterTemplete_TreePanel_Roots";
import { canEdit, createMain, type Selection } from "./M02_MasterTemplete_UI_Main";
import { buildSide } from "./M02_MasterTemplete_UI_Side";
import "./M02_MasterTemplete_UI_Style.css";
export async function renderM02MasterTemplate(root: HTMLElement): Promise<void> {
export async function renderM02MasterTemplate(root: HTMLElement): Promise<MountedPage> {
const user = await fetchSessionUser().catch(() => null);
const layer = layerFromHash();
setActiveLayer(layer);
const access = accessFor(layer, user);
if (access.needCompany) {
root.replaceChildren(buildNeedCompany());
return;
const entry = entryFromHash();
let layer = layerOfRoot(entry.root);
// 자기 층 주인을 적어도 자기 층 · 회사 없는 사람의 회사 · 개인 뿌리 = 마스터(뿌리 자리에 안내)
let owner = entry.owner && entry.owner !== ownOwner(layer, user) ? entry.owner : null;
if (accessFor(layer, owner, user).needCompany) {
[entry.root, layer, owner] = ["master", "system", null];
}
const isAdmin = access.canWrite; // 이 층을 고칠 수 있나(하위 부품의 「관리자」 = 같은 뜻)
// 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사]
const readOnlyBar = layer === "company" && !isAdmin ? buildReadOnlyBar(user) : null;
// 항목 나무 — 고르기 모달과 같은 폴더 나무(세 층 공용) · 「구조물 조합」 편집 칸 왼쪽
setActiveLayer(layer, owner);
const isAdmin = accessFor(layer, owner, user).canWrite; // 이 뿌리를 고칠 수 있나(하위 부품의 「관리자」)
// 자기 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사]
const readOnlyBar = layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user) : null;
// 고른 뿌리의 나무 — 고르기 모달과 같은 폴더 나무 · 「구조물 조합」 편집 칸 왼쪽
const tree = createTreePanel({
layer,
owner,
label: L("M02_KindTable"),
canEdit: canEdit(layer, isAdmin),
onPick: (item) => void main.openTable(layer, item.열),
settle: () => main.settle(),
onChanged: (colId) => main.openTable(layer, colId, true),
});
const go = async (next: Root, nextOwner: string | null): Promise<void> => {
// 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서
if (!(await main.confirmLeave())) return;
navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery({ ...entry, root: next, owner: nextOwner }));
};
const roots = createLibraryRoots({
user,
active: { root: entry.root, owner },
panel: tree.root,
go: (next, nextOwner) => void go(next, nextOwner),
});
const aside = el("aside", {
className: "m02-shell__tree",
children: [el("h3", { className: "m02-shell__title", text: L("M02_TreeTitle") }), tree.root],
children: [el("h3", { className: "m02-shell__title", text: L("M02_TreeTitle") }), roots],
});
const main = createMain(
isAdmin,
@@ -81,7 +101,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
const layout = el("div", { className: "ui-workflow-layout m02-master" });
const overlays = createWorkflowOverlays({
title: titleOf(layer),
title: L("M02_Title"),
optionsContent: el("div", { className: "m02-master__side", children: [side.root] }),
showProjectName: false,
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
@@ -96,5 +116,10 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
root.innerHTML = "";
root.append(layout);
void main.open(home);
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만(일위대가는 27-2 컨테이너가 생기면) ·
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
if (entry.open === "옵션" || entry.open === "도면") side.reveal(entry.open);
void tree.refresh();
// 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게)
return { covers: () => false, show: () => {}, dispose: () => setActiveLayer("system") };
}
@@ -6,7 +6,7 @@
* 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) ·
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`.
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
* 항목 나무는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
* 구조물 라이브러리는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
* ========================================================================== */
import {
@@ -80,6 +80,8 @@ export interface SideHandle {
refresh: () => Promise<void>;
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
paint: () => void;
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 시스템 층에만 있음 */
reveal: (target: "옵션" | "도면") => void;
}
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
@@ -152,18 +154,17 @@ export function buildSide(opt: SideOptions): SideHandle {
);
summary.classList.remove("is-collapsed");
summary.classList.add("m02-side__summary");
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만
const extras =
layer === "system"
? {
옵션: section(options.title, options.body),
도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
}
: null;
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [
summary,
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만
...(layer === "system"
? [
section(options.title, options.body),
section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
]
: []),
],
children: [summary, ...(extras ? [extras.옵션, extras.도면] : [])],
});
attachCollapsible(listHost);
@@ -362,6 +363,11 @@ export function buildSide(opt: SideOptions): SideHandle {
}
}
const reveal = (target: "옵션" | "도면"): void => {
extras?.[target].classList.remove("is-collapsed");
if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();
};
void refresh();
return { root, refresh, paint };
return { root, refresh, paint, reveal };
}
@@ -216,7 +216,7 @@
font-size: 11px;
}
/* 「구조물 조합」(PLAN 26-7c) — 왼쪽 항목 나무(주 · 고치는 자리) · 오른쪽 편집 칸(참조표 · 카드) */
/* 「구조물 조합」(PLAN 26-7c) — 왼쪽 구조물 라이브러리(주 · 고치는 자리) · 오른쪽 편집 칸(참조표 · 카드) */
.m02-shell {
display: flex;
align-items: stretch;
+1 -1
View File
@@ -1,4 +1,4 @@
/* M02 항목 나무 자료(`M02_MasterTemplete_TreePanel_Model.ts`) 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고 JSON 으로 냄. */
/* M02 구조물 라이브러리 자료(`M02_MasterTemplete_TreePanel_Model.ts`) 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고 JSON 으로 냄. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
+142
View File
@@ -0,0 +1,142 @@
"""M02 층 권한 한 벌(PLAN 27-1c · `M02_Layer_Access`) — 나무 · 항목 · 양식 · 남의 층 목록 같은 규칙.
① `grant` 표 — 역할(일반 · 회사 관리자 · 시스템 관리자) × 층(마스터 · 자기 회사 · 남의 회사 ·
자기 개인 · 같은 회사 남 · 다른 회사 남) = 읽기 · 수정 · 막힘.
② 임시 storage — 남의 층은 자리가 있을 때만(없으면 404 · 폴더 안 만듦) · 나무 · 양식 길 같은 답 ·
남의 층 목록(회사 = 시스템 관리자 · 개인 = 회사 관리자 자기 회사 · 본인 뺌 · 자리 있는 것만).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from fastapi import HTTPException
from config import config_system
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_structure import write_sample_table
from resources.tester.test_m02_tree import _app
USER = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
ADMIN = {**USER, "role": "ADMIN"}
MASTER = {**USER, "is_master": True}
SYSADMIN = {**USER, "role": "SYSTEM_ADMIN"}
def _answer(session: dict[str, Any], layer: str, owner: str | None) -> str:
try:
return "수정" if access.grant(session, layer, owner).write else "읽기"
except HTTPException as error:
return str(error.status_code)
def test_권한_표():
cases = [
("system", None),
("company", None),
("company", "9"),
("personal", None),
("personal", "7/43"),
("personal", "9/90"),
]
got = {
name: [_answer(s, *c) for c in cases]
for name, s in (
("일반", USER),
("회사관리", ADMIN),
("마스터", MASTER),
("시스템", SYSADMIN),
)
}
assert got == {
"일반": ["읽기", "읽기", "403", "수정", "403", "403"],
"회사관리": ["읽기", "수정", "403", "수정", "읽기", "403"],
"마스터": ["읽기", "수정", "403", "수정", "읽기", "403"],
"시스템": ["수정", "수정", "읽기", "수정", "읽기", "읽기"],
}
# 자기 층 owner 를 적어도 같음 · 모양 틀림 400 · 회사 없음 409
assert access.grant(USER, "personal", "7/42").write is True
assert _answer(SYSADMIN, "personal", "9") == "400"
assert _answer({**USER, "company_id": None}, "company", None) == "409"
with pytest.raises(HTTPException) as caught:
access.require(ADMIN, "personal", "7/43", write=True)
assert caught.value.status_code == 403
@pytest.fixture()
def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
store.renumber()
storage = tmp_path / "storage"
storage.mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
users = {42: 7, 43: 7, 44: 7, 90: 9}
async def company_users(company_id: int) -> list[dict[str, Any]]:
return [{"user_id": u, "name": f"u{u}"} for u, c in users.items() if c == company_id]
async def companies() -> list[dict[str, Any]]:
return [{"company_id": c, "name": f"c{c}"} for c in (7, 8, 9)]
monkeypatch.setattr(layers_router, "_company_users", company_users)
monkeypatch.setattr(layers_router, "_companies", companies)
session: dict[str, Any] = dict(USER)
return {"client": _app(session), "session": session, "storage": storage}
def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]):
client, session, storage = world["client"], world["session"], world["storage"]
session.update(ADMIN)
tree = "/api/m02/layers/personal/tree?owner=7/43"
table = "/api/m02/layers/personal/templates/table/구조물집계표?owner=7/43"
assert client.get(tree).status_code == 404 # 43 은 아직 아무것도 없음
assert not (storage / "7/43").exists() # 읽기로 남의 폴더를 만들지 않음
(storage / "7/43/templates").mkdir(parents=True) # 폴더만 있고 집계표 없음
assert client.get(tree).status_code == 404
assert client.get(table).status_code == 404
assert client.get("/api/m02/items?layer=personal&owner=7/43").status_code == 404
assert not any((storage / "7/43/templates").iterdir()) # 읽기로 남의 층에 뼈대를 안 씀
session.update(user_id=43)
assert client.get("/api/m02/layers/personal/tree").status_code == 200 # 43 첫 진입 = 뼈대
session.update(user_id=42)
assert client.get(tree).status_code == 200
assert client.get(table).status_code == 200 # 양식 길도 같은 규칙
body = {"op": "add-folder", "경로": [], "이름": "새", "판": client.get(tree).json()["판"]}
assert client.post(tree, json=body).status_code == 403 # 읽기만
assert client.get("/api/m02/items?layer=personal&owner=7/43").status_code == 200
session.update(USER)
assert client.get(tree).status_code == 403
assert client.get(table).status_code == 403
session.update(SYSADMIN)
assert client.get(tree).status_code == 200
assert client.post(tree, json=body).status_code == 403 # 시스템 관리자도 남의 층은 읽기만
def test_남의_층_목록(world: dict[str, Any]):
client, session, storage = world["client"], world["session"], world["storage"]
for owner in ("7/43", "9/90"): # 층 폴더가 있는 사람만 목록에
(storage / owner / "templates").mkdir(parents=True)
(storage / "9/templates").mkdir()
people = "/api/m02/layers/personal/owners"
assert client.get(people).status_code == 403 # 일반 사용자
assert client.get("/api/m02/layers/company/owners").status_code == 403
session.update(ADMIN)
assert client.get(people).json() == [
{"owner": "7/43", "name": "u43"}
] # 본인 · 44(자리 없음) 뺌
assert client.get(f"{people}?company=9").status_code == 403 # 남의 회사 사람
session.update(SYSADMIN)
assert client.get("/api/m02/layers/company/owners").json() == [
{"owner": "7", "name": "c7"}, # 자기 회사는 자리 없어도
{"owner": "9", "name": "c9"},
]
assert client.get(f"{people}?company=9").json() == [{"owner": "9/90", "name": "u90"}]
+63 -21
View File
@@ -1,8 +1,8 @@
"""M02 층 화면(`?layer=personal|company`) 붙는 자리 시험 — 화면 조작은 ORCA.
"""M02 한 화면 뿌리 셋(PLAN 27-1 · 진입 해시 `?open=&root=&owner=`) 붙는 자리 시험 — 조작은 ORCA.
· 권한 규칙(`accessFor`)을 Node 로 바로 돌림 — 시스템 SYSTEM_ADMIN ·
회사 ADMIN/is_master · 개인 본인 · 회사 없음
· 주소 인자를 라우트에서 떼는지 · 대시보드 카드 역할별 단추 · 층 길 접두어
회사 ADMIN/is_master/SYSTEM_ADMIN · 개인 본인 · 회사 없음 · 남의 층 읽기만
· 주소 인자를 라우트에서 떼는지 · 대시보드 카드 역할별 단추 · 층 길 접두어 · 뿌리 셋
"""
from __future__ import annotations
@@ -26,25 +26,35 @@ def _text(path: Path) -> str:
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_층별_고칠_권한():
found = re.search(r"^export function accessFor[\s\S]*?^}", _text(LAYER), re.M)
assert found, "accessFor 를 못 찾음"
"""서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c) — 남의 층(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 를 못 찾음"
script = (
'type ManageLayer = "system" | "company" | "personal";\n'
"interface SessionUser { role?: string; company_id?: number | null; is_master?: boolean }\n"
"interface SessionUser { id: string; role?: string; company_id?: number | null;"
" is_master?: boolean }\n"
"interface LayerAccess { canWrite: boolean; needCompany: boolean }\n"
+ own.group(0).replace("export ", "")
+ found.group(0).replace("export ", "")
+ "\nconst cases = JSON.parse(process.argv[1]);"
+ "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1]))));"
+ "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1], c[2]))));"
)
sysadmin = {"role": "SYSTEM_ADMIN", "company_id": None}
admin = {"role": "ADMIN", "company_id": 3}
master = {"role": "USER", "company_id": 3, "is_master": True}
user = {"role": "USER", "company_id": 3}
orphan = {"role": "USER", "company_id": None}
sysadmin = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": None}
sys3 = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": 3}
admin = {"id": "5", "role": "ADMIN", "company_id": 3}
master = {"id": "6", "role": "USER", "company_id": 3, "is_master": True}
user = {"id": "7", "role": "USER", "company_id": 3}
orphan = {"id": "8", "role": "USER", "company_id": None}
cases = [
["system", sysadmin], ["system", admin], ["system", None],
["company", admin], ["company", master], ["company", user], ["company", orphan],
["personal", user], ["personal", admin], ["personal", orphan], ["personal", sysadmin],
["system", None, sysadmin], ["system", None, admin], ["system", None, None],
["company", None, admin], ["company", None, master], ["company", None, user],
["company", None, orphan], ["company", None, sys3],
["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],
] # fmt: skip
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
@@ -57,8 +67,9 @@ def test_층별_고칠_권한():
no_company = {"canWrite": False, "needCompany": True}
assert json.loads(result.stdout) == [
ok, ro, ro, # 시스템 = SYSTEM_ADMIN 만
ok, ok, ro, no_company, # 회사 = ADMIN · is_master · 일반은 읽기만 · 회사 없음 안내
ok, ok, ro, no_company, ok, # 회사 = ADMIN · is_master · SYSTEM_ADMIN · 일반 읽기만
ok, ok, no_company, no_company, # 개인 = 본인 · 회사 없으면 안내
ok, ro, ro, ro, # 자기 층 owner 는 자기 층 · 남의 층은 읽기만
] # fmt: skip
@@ -68,7 +79,35 @@ def test_주소_인자를_라우트에서_뗌():
assert "export function currentQuery()" in router
assert "query?: Record<string, string>" in router
layer = _text(LAYER)
assert 'value === "company" || value === "personal" ? value : "system"' in layer
# 진입 해시 `?open=&root=&owner=`(PLAN 27-1d) · 옛 `?layer=` 도 root 로
assert 'query.get("root") ?? query.get("layer")' in layer
assert 'raw === "company" || raw === "personal" ? raw : "master"' in layer
assert '["구조물조합", "일위대가", "옵션", "도면"]' in layer
picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts")
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
def test_한_화면_뿌리_셋():
"""27-1b · c — 뿌리 셋 한 화면 · 다른 뿌리 = 해시로 다시 세움(저장 안 한 고침은 먼저 물음) ·
떠나면 층 길을 시스템으로 되돌림 · 목록은 펼칠 때 · 나무 부품은 owner · 자료 길을 받음."""
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert "createLibraryRoots(" in page and "setActiveLayer(layer, owner)" in page
assert "await main.confirmLeave()" in page.split("const go =")[1].split("navigateTo(")[0]
assert 'dispose: () => setActiveLayer("system")' in page
assert "side.reveal(entry.open)" in page
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
assert 'fetchOwners("company")' in roots and 'fetchOwners("personal", company)' in roots
assert "if (!kids.hidden && !loaded) void fill();" in roots # 펼칠 때 불러옴
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
)
assert (
"...(opt.canEdit ? { menu: () => menuOf(target) } : {})" in panel
) # 읽기 뿌리 = 메뉴 없음
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
assert "owner=${enc(activeOwner)}" in api and "/owners" in api
def test_대시보드_카드_역할별_단추():
@@ -103,10 +142,13 @@ def test_층_길_접두어와_고정_풀림():
def test_읽기만_회사용_개인용으로_복사_막대():
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert 'layer === "company" && !isAdmin ? buildReadOnlyBar(user)' in page
assert "readOnlyBar?.setColumn(column)" in page # 열 고름 → 막대 · 항목 나무가 함께 받음
assert "access.needCompany" in page and "buildNeedCompany()" in page
assert "titleOf(layer)" in page
assert 'layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user)' in page
assert (
"readOnlyBar?.setColumn(column)" in page
) # 열 고름 → 막대 · 구조물 라이브러리가 함께 받음
assert "accessFor(layer, owner, user).needCompany" in page # 회사 없음 = 마스터로
assert "buildNeedCompany()" in _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
assert 'title: L("M02_Title")' in page # 한 화면 한 제목
layer = _text(LAYER)
assert 'fromLayer: "company"' in layer and "M02_CopyToPersonal" in layer
+13 -2
View File
@@ -143,13 +143,24 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor
assert client.get(mine).status_code == 200
session.update(user_id=43)
theirs = mine + "&user_id=42"
# 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만 읽기 · 쓰기는 본인만
assert client.get(theirs).status_code == 403
session["role"] = "ADMIN"
assert client.get(theirs).status_code == 200
# 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀
assert client.get(mine + "&owner=7/42").status_code == 200
put = client.put(
f"/api/m02/layers/personal/templates/table/내표?project_id={P1}&user_id=42",
json={"판": None, "문서": {"열": []}},
)
assert put.status_code == 200 # user_id 는 쓰기에서 무시 — 본인(43) 자리에 새로 씀
assert put.status_code == 403
assert not (world["storage"] / "7/42/templates/table/내표.json").exists()
session["role"] = "USER"
# 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀
put = client.put(
f"/api/m02/layers/personal/templates/table/내표?project_id={P1}",
json={"판": None, "문서": {"열": []}},
)
assert put.status_code == 200
assert (world["storage"] / "7/43/templates/table/내표.json").is_file()
got = client.post(
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42}
+1 -1
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""M02 관리 화면 항목 나무(PLAN 26-3a) 자료 셈 회귀검증 — 폴더 나무 · 빈 폴더 · 깊이 5 · 찾기 · 폴더 목록.
"""M02 관리 화면 구조물 라이브러리(PLAN 26-3a) 자료 셈 회귀검증 — 폴더 나무 · 빈 폴더 · 깊이 5 · 찾기 · 폴더 목록.
항목 경로 = 폴더 이름 배열 + 마지막 마디가 항목 이름 · 찾는 글이 있으면 맞는 항목과 위 폴더만.
Node 헬퍼(`helper_m02_treepanel.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
+2 -1
View File
@@ -52,7 +52,8 @@ export interface ItemPickerOptions {
export type ItemPickerResult =
{ action: "apply"; item: SummaryItem } | { action: "clear" } | { action: "cancel" };
const MANAGE_HASH = "#/m02-master-template?layer=personal";
/** 관리 화면 진입 해시(PLAN 27-1d) — 구조물 조합 · 개인 뿌리가 열린 채 */
const MANAGE_HASH = `#/m02-master-template?${new URLSearchParams({ open: "구조물조합", root: "personal" })}`;
const labels = (): TreeLabels => ({
layers: {
+6 -2
View File
@@ -68,7 +68,7 @@ export const ui_locales_m2 = {
],
M02_CopyToPersonal: ["개인용으로 복사", "Copy to personal"],
M02_CopyPickFirst: ["복사할 열을 구조물 조합에서 먼저 고름", "Pick a column in the table first"],
M02_CopyDone: ["개인용으로 복사함 — 구조물 관리(개인)에서 고침", "Copied — edit it in your own"],
M02_CopyDone: ["개인용으로 복사함 — 「개인」 뿌리에서 고침", "Copied — edit it in your own"],
M02_CopyFailed: ["복사 안 됨 — {value}", "Copy failed — {value}"],
M02_NeedCompany: [
"회사부터 등록해야 구조물 조합을 만들 수 있음",
@@ -98,7 +98,11 @@ export const ui_locales_m2 = {
M02_PickerManage: ["관리 화면에서 고치기", "Edit in manager"],
M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"],
M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"],
M02_TreeTitle: ["항목 나무", "Item tree"],
M02_TreeTitle: ["구조물 라이브러리", "Structure library"],
M02_RootMine: ["내 것", "Mine"],
M02_RootShared: ["회사 공용", "Company shared"],
M02_RootReadOnly: ["읽기", "read-only"],
M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"],
M02_TreeSearch: ["항목 찾기", "Find item"],
M02_TreeEmptyEdit: [
"항목이 없음 — 맨 위 줄을 오른쪽 클릭해 폴더 · 항목을 더함",