feat(M02): 관리 화면 「구조물 조합」 — 나무가 주 · 표는 참조표 · 비고 · 전구간 · 합계 숨김(PLAN 26-7a~e)
- 화면 이름 「구조물집계표」 · 「구조물 집계 양식」 → 「구조물 조합」(좌측 줄 · 상세 제목 · 안내글) · 파일 이름 · 양식 키 · API 길 그대로 - 좌측 패널 「항목 나무」 컨테이너 없앰 — 페이지가 나무를 만들어 편집 칸 왼쪽(aside)에 붙임 · 「구조물 조합」일 때만 보임 · 오른쪽 클릭 · 찾기 · 표와 서로 고름 · 고치기 전 저장 물음 그대로 - 표 부품 lockHead — 열 더하기 · 분류N 더하기 · 열 지우기 단추 없음 · 머리 · 층 이름 고치기 없음(단위 · 일위대가 · 조회 후 선택은 그대로) - 비고 열 · 전구간 줄 · 합계 줄은 보일 문서에서만 뺌(comboView) · [저장] 때 저장본 것을 다시 끼움(comboMerge) · 양식 파일 · M02_Table_Fill 그대로 - 서버 — 화면 표 [저장](store.write screen=True)은 있던 항목의 경로 · 층 이름 · 빈 폴더를 저장본 그대로(tree.keep_paths · 브라우저 머리 안 받음) · 머리는 경로에서 다시 만듦 · 나무 고침 줄기 그대로 - 나무 같은 이름 409 는 「이미 있는 항목」 까닭 그대로 보임(판 다름 안내와 가름) - 시험 test_m02_combo_screen 3(숨김 · 되끼움 Node · 잠금 · 나무 자리) · test_m02_tree 표 저장 약속 새로 · 이름 · 잠금 기대값 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
This commit is contained in:
@@ -38,7 +38,7 @@ const TEXT = {
|
||||
empty: "맞는 조합 없음",
|
||||
none: "조합 없음",
|
||||
use: "이 열에 쓰기",
|
||||
useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물집계표에 바로 적힘 · 조합은 그대로",
|
||||
useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물 조합에 바로 적힘 · 조합은 그대로",
|
||||
useOk: "바꿈",
|
||||
used: "이 열이 {value} 을 씀",
|
||||
loadFailed: "조합 목록을 못 받음 — {value}",
|
||||
|
||||
@@ -48,9 +48,9 @@ const TEXT = {
|
||||
count: "로직 {logics} · 입력 {inputs}",
|
||||
chosen: "면적산출 {area} · 재료산출 {material} · 선택 {pick}",
|
||||
auto: "자동값",
|
||||
noItem: "집계표를 먼저 저장하면 입력 목록이 나옴",
|
||||
noCombo: "일위대가를 먼저 고름 — 집계표 일위대가 줄에서 고름",
|
||||
unsavedCombo: "고른 일위대가는 집계표를 저장하면 입력 목록이 나옴",
|
||||
noItem: "구조물 조합을 먼저 저장하면 입력 목록이 나옴",
|
||||
noCombo: "일위대가를 먼저 고름 — 구조물 조합 일위대가 줄에서 고름",
|
||||
unsavedCombo: "고른 일위대가는 구조물 조합을 저장하면 입력 목록이 나옴",
|
||||
noLogic: "이 일위대가에 담긴 로직이 없음",
|
||||
missing: "빠진 로직 {value}",
|
||||
failed: "입력 목록을 못 받음 — {value}",
|
||||
|
||||
@@ -169,7 +169,7 @@ def get_template(kind: str, name: str) -> dict:
|
||||
|
||||
@router.put("/templates/{kind}/{name}")
|
||||
def put_template(kind: str, name: str, body: SaveBody) -> dict:
|
||||
return _call(store.write, kind, name, body.판, body.문서)
|
||||
return _call(store.write, kind, name, body.판, body.문서, True) # 화면 [저장](26-7e)
|
||||
|
||||
|
||||
@router.delete("/templates/{kind}/{name}")
|
||||
|
||||
@@ -305,7 +305,7 @@ async def save_layer_template(
|
||||
def _store_save(folder: Path, kind: str, name: str, version: str, doc: Any, layer: str) -> dict:
|
||||
with store.at(folder):
|
||||
try:
|
||||
info = store.write(kind, name, version, doc)
|
||||
info = store.write(kind, name, version, doc, screen=True) # 화면 [저장](26-7e)
|
||||
except store.StoreError as error:
|
||||
raise HTTPException(status_code=error.status, detail=error.detail) from error
|
||||
return {"종류": kind, "이름": name, "판": info["판"], "층": layer}
|
||||
|
||||
@@ -150,7 +150,7 @@ def _check_skeleton(kind: str, doc: Any) -> None:
|
||||
raise StoreError(400, str(e)) from e
|
||||
|
||||
|
||||
def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
|
||||
def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> dict[str, Any]:
|
||||
"""`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀.
|
||||
구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출 · 재료산출을 번호와 같이
|
||||
만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음).
|
||||
@@ -158,7 +158,9 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
|
||||
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘.
|
||||
번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
|
||||
표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이
|
||||
원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로)."""
|
||||
원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
|
||||
`screen` = 화면 표 [저장](PLAN 26-7e) — 있던 항목의 경로 · 층 이름 · 빈 폴더는 저장본 그대로
|
||||
(정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`)."""
|
||||
path = _path(kind, name)
|
||||
_check_skeleton(kind, doc)
|
||||
origins = _take_origins(doc) if kind == "table" else {}
|
||||
@@ -170,6 +172,8 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
|
||||
have = version_of(path.read_bytes()) if path.is_file() else ""
|
||||
if have != (version or ""):
|
||||
raise StoreError(409, {"stale": [name], "판": have})
|
||||
if screen and have and kind == "table" and name == STRUCTURE_TABLE:
|
||||
doc = tree.normalize_table(tree.keep_paths(doc, json.loads(path.read_bytes())))
|
||||
if have and kind in _KEY:
|
||||
number = json.loads(path.read_bytes()).get(_KEY[kind])
|
||||
doc = {**doc, _KEY[kind]: number} if number else doc
|
||||
|
||||
@@ -57,12 +57,12 @@ const TEXT = {
|
||||
none: "고를 것이 없음",
|
||||
pickPrice: "일위대가 고르기",
|
||||
combosFailed: "일위대가 목록을 못 받음 — {value}",
|
||||
saveFirst: "새 열은 집계표를 먼저 저장해야 번호가 생김",
|
||||
saveFirst: "새 열은 구조물 조합을 먼저 저장해야 번호가 생김",
|
||||
overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로",
|
||||
pickCopy: "{value} 복사해 가져오기",
|
||||
copied: "복사해 가져옴",
|
||||
copyFailed: "복사 실패 — {value}",
|
||||
noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물집계표 일위대가 줄에서 고름",
|
||||
noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물 조합 일위대가 줄에서 고름",
|
||||
noInputs: "일위대가 {value} 에 입력값이 없어 알약 없음",
|
||||
inputsFailed: "일위대가 입력값을 못 받음 — {value}",
|
||||
};
|
||||
@@ -180,6 +180,30 @@ export interface StructureColumn {
|
||||
/** 구조물 열 = 꼴=수 — NO · 측점 · 비고 같은 글 열은 도면 · 면적산출 · 재료산출 · 번호가 없음(서버와 같은 규칙) */
|
||||
export const isStructureColumn = (col: StructureColumn): boolean => col.꼴 === "수";
|
||||
|
||||
/** 관리 화면 「구조물 조합」에서 숨기는 것(PLAN 26-7b) — 비고 열 · 전구간 줄(본문 줄) · 합계 줄.
|
||||
* 양식 파일은 그대로 — 보일 문서에서만 빼고 [저장] 때 저장본 것을 다시 끼움(`comboMerge`). */
|
||||
const HIDDEN_COLUMNS = ["memo"];
|
||||
type ComboDoc = { 열: StructureColumn[]; 줄?: unknown[]; 합계줄?: unknown[] };
|
||||
|
||||
export function comboView<T>(doc: T): T {
|
||||
const d = doc as unknown as ComboDoc;
|
||||
const 열 = (d.열 ?? []).filter((c) => !HIDDEN_COLUMNS.includes(c.id));
|
||||
return { ...d, 열, 줄: [], 합계줄: [] } as unknown as T;
|
||||
}
|
||||
|
||||
/** 보인 표 고침 + 저장본의 숨긴 것 — 열 차례는 저장본(표에서 열을 더하거나 지우지 않음 · 26-7d) */
|
||||
export function comboMerge<T>(stored: unknown, edited: T): T {
|
||||
const s = stored as ComboDoc;
|
||||
const e = edited as unknown as ComboDoc;
|
||||
const byId = new Map(e.열.map((c) => [c.id, c]));
|
||||
const kept = (s.열 ?? []).flatMap((c) =>
|
||||
HIDDEN_COLUMNS.includes(c.id) ? [c] : byId.has(c.id) ? [byId.get(c.id)!] : [],
|
||||
);
|
||||
const known = new Set(kept.map((c) => c.id));
|
||||
const 열 = [...kept, ...e.열.filter((c) => !known.has(c.id))];
|
||||
return { ...e, 열, 줄: s.줄 ?? [], 합계줄: s.합계줄 ?? [] } as unknown as T;
|
||||
}
|
||||
|
||||
/** 구조물 이름 + 변수값 — 제목행 층을 빈칸으로 이음(빈 층은 건너뜀 · 다 비면 열 id) */
|
||||
export const columnHead = (col: StructureColumn): string =>
|
||||
col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" ") || col.id;
|
||||
|
||||
@@ -108,6 +108,20 @@ def normalize_table(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def keep_paths(doc: dict[str, Any], stored: dict[str, Any]) -> dict[str, Any]:
|
||||
"""화면 표 [저장](PLAN 26-7e) — 정본은 나무: 있던 항목의 경로(머리) · 층 이름 · 빈 폴더는
|
||||
저장본 그대로 · 브라우저가 보낸 머리는 안 받음. 머리는 저장 맞춤(`normalize_table`)이 경로에서
|
||||
다시 만듦."""
|
||||
out = copy.deepcopy(doc)
|
||||
was = {c["id"]: path_of(c) for c in _items(stored)}
|
||||
for col in _items(out):
|
||||
if col["id"] in was:
|
||||
_set_path(col, was[col["id"]])
|
||||
out["층"] = copy.deepcopy(stored.get("층") or [])
|
||||
out["폴더"] = copy.deepcopy(stored.get("폴더") or [])
|
||||
return out
|
||||
|
||||
|
||||
def folders_of(doc: dict[str, Any]) -> list[list[str]]:
|
||||
"""나무의 폴더 전부 — 적어 둔 빈 폴더 + 항목 경로의 앞마디(차례 = 표 차례)."""
|
||||
out: list[list[str]] = []
|
||||
|
||||
@@ -233,7 +233,8 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
showToast(L("M02_TreeDone"), "success");
|
||||
await opt.onChanged(col);
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError) {
|
||||
// 409 = 판 다름(서버 detail 이 객체 → 「HTTP 409」) 또는 같은 이름(글 까닭 — 그대로 보임)
|
||||
if (error instanceof StaleError && error.message === "HTTP 409") {
|
||||
await refresh();
|
||||
showToast(L("M02_TreeStale"), "error");
|
||||
} else {
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
type StructureDoc,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
comboMerge,
|
||||
comboView,
|
||||
createStructurePanel,
|
||||
pillsFor,
|
||||
STRUCTURE_TABLE,
|
||||
@@ -103,9 +105,11 @@ export function canEdit(layer: Layer, isAdmin: boolean): boolean {
|
||||
return layer === "project" || isAdmin;
|
||||
}
|
||||
|
||||
/** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 */
|
||||
/** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 ·
|
||||
* `aside` = 「구조물 조합」을 열 때만 편집 칸 왼쪽에 보임(항목 나무 · PLAN 26-7c) */
|
||||
export interface MainExtras {
|
||||
top?: HTMLElement;
|
||||
aside?: HTMLElement;
|
||||
onColumn?: (column: string | null) => void;
|
||||
}
|
||||
|
||||
@@ -125,7 +129,7 @@ export function createMain(
|
||||
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
|
||||
const host = el("div", { className: "m02-main__host" });
|
||||
const extra = el("div", { className: "m02-main__extra" }); // 표 밖 형제 자리 — 구조물 설계 컨테이너
|
||||
const root = el("div", {
|
||||
const column = el("div", {
|
||||
className: "m02-main",
|
||||
children: [
|
||||
...(extras.top ? [extras.top] : []),
|
||||
@@ -135,6 +139,15 @@ export function createMain(
|
||||
extra,
|
||||
],
|
||||
});
|
||||
// 왼쪽 나무(주) · 오른쪽 편집 칸 — 나무는 「구조물 조합」일 때만(`showAside`)
|
||||
const root = extras.aside
|
||||
? el("div", { className: "m02-shell", children: [extras.aside, column] })
|
||||
: column;
|
||||
const isCombo = (at: Selection | null): boolean =>
|
||||
!!at && at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project";
|
||||
const showAside = (): void => {
|
||||
if (extras.aside) extras.aside.hidden = !isCombo(sel);
|
||||
};
|
||||
|
||||
let sel: Selection | null = null;
|
||||
let version = "";
|
||||
@@ -360,9 +373,11 @@ export function createMain(
|
||||
// 그 뒤 .열 을 그대로 읽으면 죽는다(2026-09-28 사용자 보고 — 열 고른 뒤 CAD
|
||||
// 불러오기 오류). getDoc() 뒤에도 옵셔널 체이닝을 이어야 한다.
|
||||
const columns = (): StructureColumn[] => sheet?.getDoc()?.열 ?? [];
|
||||
editor = create(host, doc, {
|
||||
// 「구조물 조합」 = 나무를 따라오는 참조표(26-7b · d) — 비고 · 전구간 · 합계 숨김 · 열 · 머리는 나무만
|
||||
editor = create(host, struct ? comboView(doc) : doc, {
|
||||
mode: at.layer === "project" ? "project" : "master",
|
||||
readOnly,
|
||||
lockHead: !!struct,
|
||||
onChange: () => (dirty = !readOnly),
|
||||
...(struct
|
||||
? {
|
||||
@@ -382,7 +397,11 @@ export function createMain(
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (struct) sheet = editor as SheetEditorHandle;
|
||||
if (!struct) return;
|
||||
const shown = editor as SheetEditorHandle;
|
||||
sheet = shown;
|
||||
// [저장]은 저장본의 숨긴 것을 다시 끼운 문서(서버가 머리를 경로에서 다시 만듦 · 26-7e)
|
||||
editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy };
|
||||
return;
|
||||
}
|
||||
if (at.kind === "area" || at.kind === "material") {
|
||||
@@ -421,11 +440,12 @@ export function createMain(
|
||||
sel = next;
|
||||
onOpened?.();
|
||||
bar();
|
||||
showAside();
|
||||
if (!next) {
|
||||
title.textContent = L("M02_PickTemplate");
|
||||
return empty("");
|
||||
}
|
||||
title.textContent = next.label ?? next.name;
|
||||
title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a
|
||||
try {
|
||||
const got = await readTemplate(next.layer, next.kind, next.name, next.projectId);
|
||||
if (mine !== seq) return;
|
||||
@@ -501,6 +521,7 @@ export function createMain(
|
||||
sel = null;
|
||||
onOpened?.(); // 좌측 양식 · 열 줄 고름 지움
|
||||
bar();
|
||||
showAside();
|
||||
title.textContent = label;
|
||||
host.replaceChildren(node);
|
||||
return (d) => (dirty = d);
|
||||
@@ -544,6 +565,7 @@ export function createMain(
|
||||
if (sel && (await confirmLeave())) void open(sel);
|
||||
});
|
||||
bar();
|
||||
showAside();
|
||||
empty("");
|
||||
return { root, open, current: () => sel, confirmLeave, showView, openTable, settle };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Page.ts
|
||||
* 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록) / 우측 편집 칸(표·도면 부품)
|
||||
* 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록) / 우측 편집 칸(표·도면 부품) ·
|
||||
* 「구조물 조합」을 열면 편집 칸 왼쪽에 항목 나무(주 · 고치는 자리) · 오른쪽 참조표(PLAN 26-7c)
|
||||
*
|
||||
* 층 = 주소 인자 `?layer=personal|company`(없으면 시스템) — 같은 화면 한 벌.
|
||||
* 화면은 로그인만 확인 — 고치기는 층별 권한일 때만 켬(서버도 막음 · `M02_MasterTemplete_Layer.ts`).
|
||||
@@ -8,6 +9,7 @@
|
||||
|
||||
import "@ui/ui_template_workflow_layout.css";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import { setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
@@ -19,7 +21,8 @@ import {
|
||||
titleOf,
|
||||
} from "./M02_MasterTemplete_Layer";
|
||||
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
|
||||
import { createMain, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
|
||||
import { canEdit, createMain, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildSide } from "./M02_MasterTemplete_UI_Side";
|
||||
import "./M02_MasterTemplete_UI_Style.css";
|
||||
|
||||
@@ -35,15 +38,28 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
|
||||
const isAdmin = access.canWrite; // 이 층을 고칠 수 있나(하위 부품의 「관리자」 = 같은 뜻)
|
||||
// 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사]
|
||||
const readOnlyBar = layer === "company" && !isAdmin ? buildReadOnlyBar(user) : null;
|
||||
// 항목 나무 — 고르기 모달과 같은 폴더 나무(세 층 공용) · 「구조물 조합」 편집 칸 왼쪽
|
||||
const tree = createTreePanel({
|
||||
layer,
|
||||
canEdit: canEdit(layer, isAdmin),
|
||||
onPick: (item) => void main.openTable(layer, item.열),
|
||||
settle: () => main.settle(),
|
||||
onChanged: (colId) => main.openTable(layer, colId, true),
|
||||
});
|
||||
const aside = el("aside", {
|
||||
className: "m02-shell__tree",
|
||||
children: [el("h3", { className: "m02-shell__title", text: L("M02_TreeTitle") }), tree.root],
|
||||
});
|
||||
const main = createMain(
|
||||
isAdmin,
|
||||
() => void side.refresh(),
|
||||
() => void Promise.all([side.refresh(), tree.refresh()]),
|
||||
() => side.paint(),
|
||||
{
|
||||
top: readOnlyBar?.root,
|
||||
aside,
|
||||
onColumn: (column) => {
|
||||
readOnlyBar?.setColumn(column);
|
||||
side.selectItem(column);
|
||||
tree.select(column);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -61,11 +77,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
|
||||
getOpen: main.current,
|
||||
confirmLeave: main.confirmLeave,
|
||||
showView: main.showView,
|
||||
tree: {
|
||||
onPick: (item) => void main.openTable(layer, item.열),
|
||||
settle: main.settle,
|
||||
onChanged: (colId) => main.openTable(layer, colId, true),
|
||||
},
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
@@ -85,4 +96,5 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
|
||||
root.innerHTML = "";
|
||||
root.append(layout);
|
||||
void main.open(home);
|
||||
void tree.refresh();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 집계 양식」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 ·
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 ·
|
||||
* 재료산출 · 자재 조합) · 「도면 양식」(새로 · 본떠 · 지우기). 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
|
||||
* 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) ·
|
||||
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`.
|
||||
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
|
||||
* 항목 나무는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -38,7 +40,6 @@ import {
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
|
||||
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
|
||||
import { createTreePanel, type TreePanelOptions } from "./M02_MasterTemplete_TreePanel";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
@@ -72,8 +73,6 @@ export interface SideOptions {
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 항목 나무(26-3a) — 항목 누름 · 고치기 전 저장 처리 · 고친 뒤 표 다시 읽기 */
|
||||
tree: Pick<TreePanelOptions, "onPick" | "settle" | "onChanged">;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -81,8 +80,6 @@ export interface SideHandle {
|
||||
refresh: () => Promise<void>;
|
||||
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
|
||||
paint: () => void;
|
||||
/** 표에서 고른 열을 항목 나무에 표시 */
|
||||
selectItem: (colId: string | null) => void;
|
||||
}
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
@@ -155,15 +152,10 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
);
|
||||
summary.classList.remove("is-collapsed");
|
||||
summary.classList.add("m02-side__summary");
|
||||
// 항목 나무 — 구조물 집계 양식 아래 · 고르기 모달과 같은 폴더 나무(세 층 공용)
|
||||
const tree = createTreePanel({ layer, canEdit: canEdit(layer, opt.isAdmin), ...opt.tree });
|
||||
const treeSection = section(L("M02_TreeTitle"), tree.root);
|
||||
treeSection.classList.remove("is-collapsed");
|
||||
const listHost = el("div", {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
summary,
|
||||
treeSection,
|
||||
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만
|
||||
...(layer === "system"
|
||||
? [
|
||||
@@ -201,7 +193,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
? mine.map((info) => {
|
||||
const b = el("button", {
|
||||
className: `m02-side__item ui-sidebar-row${isOpen(info) ? " is-active" : ""}`,
|
||||
text: info.이름,
|
||||
// 구조물집계표 = 화면 이름 「구조물 조합」(26-7a)
|
||||
text: info.이름 === STRUCTURE_TABLE ? L("M02_KindTable") : info.이름,
|
||||
attrs: { type: "button", title: info.수정일 },
|
||||
});
|
||||
b.addEventListener("click", () => void select(info));
|
||||
@@ -289,7 +282,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
await Promise.all([refreshStructure(), matset.refresh(), tree.refresh()]);
|
||||
await Promise.all([refreshStructure(), matset.refresh()]);
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
@@ -370,5 +363,5 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
|
||||
void refresh();
|
||||
return { root, refresh, paint, selectItem: tree.select };
|
||||
return { root, refresh, paint };
|
||||
}
|
||||
|
||||
@@ -215,3 +215,53 @@
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 「구조물 조합」(PLAN 26-7c) — 왼쪽 항목 나무(주 · 고치는 자리) · 오른쪽 편집 칸(참조표 · 카드) */
|
||||
.m02-shell {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-shell > .m02-main {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.m02-shell__tree {
|
||||
display: flex;
|
||||
flex: 0 0 16rem;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
max-height: calc(100vh - var(--spacing-64));
|
||||
padding: var(--spacing-16) var(--spacing-12);
|
||||
border-right: 1px solid var(--color-border);
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m02-shell__tree[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-shell__title {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m02-shell__tree .m02-tree__body {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.m02-shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.m02-shell__tree {
|
||||
flex-basis: auto;
|
||||
max-height: 40vh;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""관리 화면 「구조물 조합」(PLAN 26-7a~d) — 이름 · 숨김(비고 · 전구간 · 합계) · 참조표 잠금 ·
|
||||
나무 자리.
|
||||
|
||||
① `comboView` · `comboMerge`(Node) — 보일 문서에서 비고 열 · 줄 · 합계 줄을 빼고, [저장] 때
|
||||
저장본 것을 다시 끼움(열 차례 = 저장본 · 보인 고침은 받음).
|
||||
② 표 부품 `lockHead` — 열 더하기 · 지우기 · 머리 · 층 이름 고치기 없음(단위 · 조회 후 선택은
|
||||
그대로).
|
||||
③ 좌측 패널에 나무 없음 · 페이지가 나무를 편집 칸 왼쪽(`aside`)에 · 「구조물 조합」일 때만 보임.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
SHEET = ROOT / "ui_template" / "sheet"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_숨긴_것은_저장_때_저장본에서_다시_끼움():
|
||||
src = _text(M02 / "M02_MasterTemplete_Structure.ts")
|
||||
part = re.search(
|
||||
r"^const HIDDEN_COLUMNS[\s\S]*?^export function comboMerge[\s\S]*?^}", src, re.M
|
||||
)
|
||||
assert part, "comboView · comboMerge 를 못 찾음"
|
||||
script = (
|
||||
"interface StructureColumn { id: string; 머리: (string | null)[]; 꼴?: string | null;"
|
||||
" 단위?: string }\n"
|
||||
+ part.group(0).replace("export ", "")
|
||||
+ """
|
||||
const stored = {
|
||||
열: [{ id: "no", 머리: ["NO"] }, { id: "a", 머리: ["가"], 꼴: "수" },
|
||||
{ id: "b", 머리: ["나"], 꼴: "수" }, { id: "memo", 머리: ["비고"] }],
|
||||
줄: [{ id: "all", 값: { memo: "x" } }], 합계줄: [{ id: "sum" }], 변수: { k: 1 },
|
||||
};
|
||||
const view = comboView(stored);
|
||||
const edited = structuredClone(view);
|
||||
edited.열[2].단위 = "m";
|
||||
const saved = comboMerge(stored, edited);
|
||||
console.log(JSON.stringify([
|
||||
view.열.map((c) => c.id), view.줄, view.합계줄, stored.열.length,
|
||||
saved.열.map((c) => c.id), saved.열[2].단위, saved.줄, saved.합계줄, saved.변수,
|
||||
]));
|
||||
"""
|
||||
)
|
||||
result = 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 result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [
|
||||
["no", "a", "b"],
|
||||
[],
|
||||
[],
|
||||
4, # 저장본은 안 바뀜
|
||||
["no", "a", "b", "memo"],
|
||||
"m",
|
||||
[{"id": "all", "값": {"memo": "x"}}],
|
||||
[{"id": "sum"}],
|
||||
{"k": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_참조표는_열_머리를_못_고침():
|
||||
sheet = _text(SHEET / "ui_template_sheet.ts")
|
||||
assert 'const editCols = opts.mode === "master" && !ro && !opts.lockHead;' in sheet
|
||||
assert "if (editCols) toolbar.append(colDelete);" in sheet
|
||||
assert "if (editCols) syncLevels(!!col);" in sheet
|
||||
assert "if (th && editCols) editHead(th);" in sheet
|
||||
assert "else if (layer && editCols) editLayer(layer);" in sheet
|
||||
assert 'ro || opts.lockHead ? { hidden: "" }' in sheet
|
||||
render = _text(SHEET / "ui_template_sheet_render.ts")
|
||||
assert 'const heads = mode === "master" && !state.headLocked;' in render
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert "struct ? comboView(doc) : doc" in main
|
||||
assert "getDoc: () => comboMerge(loaded, shown.getDoc())" in main
|
||||
|
||||
|
||||
def test_나무는_편집_칸_왼쪽_좌측_패널엔_없음():
|
||||
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
|
||||
assert "createTreePanel" not in side and "M02_TreeTitle" not in side
|
||||
assert 'info.이름 === STRUCTURE_TABLE ? L("M02_KindTable")' in side # 좌측 줄 이름
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert "createTreePanel({" in page and "aside," in page and "tree.select(column)" in page
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert 'at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project"' in main
|
||||
assert 'isCombo(next) ? L("M02_KindTable")' in main # 상세 제목
|
||||
assert main.count("showAside();") == 3 # 열 때 · 양식 밖 화면 · 처음
|
||||
locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts")
|
||||
assert 'M02_KindTable: ["구조물 조합"' in locale
|
||||
assert ".m02-shell__tree[hidden]" in _text(M02 / "M02_MasterTemplete_UI_Style.css")
|
||||
tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert (
|
||||
'error instanceof StaleError && error.message === "HTTP 409"' in tree
|
||||
) # 같은 이름 = 까닭 글
|
||||
@@ -147,5 +147,6 @@ def test_읽기만_표는_단추_고치기_모두_막음():
|
||||
assert sheet.count("if (ro") + sheet.count("!ro") + sheet.count("ro ||") >= 7
|
||||
# 열 0 = 「열 더하기」 하나만 켜짐 · 첫 열
|
||||
assert "b.disabled = !hasCol && !empty" in sheet and "addFirstColumn(" in sheet
|
||||
assert re.search(r"readOnly,\s*onChange", _text(M02 / "M02_MasterTemplete_UI_Main.ts"))
|
||||
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")
|
||||
|
||||
@@ -153,7 +153,7 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우
|
||||
# 구조물 도면 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘)
|
||||
summary = side.split("const summary = section(")[1].split(");")[0]
|
||||
assert "COLUMN_KINDS.map" in summary and "matsetSection" in summary
|
||||
assert 'M02_KindTable: ["구조물 집계 양식"' in _text(
|
||||
assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a)
|
||||
ROOT / "ui_template" / "ui_template_locale_m2.ts"
|
||||
)
|
||||
|
||||
|
||||
@@ -220,27 +220,32 @@ def test_판_409_권한_403(world):
|
||||
|
||||
|
||||
def test_표_저장과_나무가_한_줄기(world):
|
||||
"""집계표 표에서 머리를 고치면 경로가 따라오고, 나무 고침 뒤 표에도 같은 자료가 보임."""
|
||||
"""정본 = 나무(PLAN 26-7e) — 표 [저장]이 보낸 머리 · 층 이름 · 빈 폴더는 안 받음(저장본 경로
|
||||
그대로) · 나무 고침 뒤 표 머리가 경로에서 다시 만들어짐."""
|
||||
admin = _as(world, "SYSTEM_ADMIN")
|
||||
t = Tree(admin, "system")
|
||||
rv = next(r for r in t.now["items"] if r["열"] == "rv")
|
||||
path = rv["경로"]
|
||||
url = f"/api/m02/layers/system/templates/{TABLE}"
|
||||
got = admin.get(url).json()
|
||||
col = next(c for c in got["문서"]["열"] if c["id"] == "rv")
|
||||
col["머리"] = ["돌기슭막이", None, "찰쌓기", None] # 빈 칸은 건너뜀
|
||||
col["머리"] = ["돌기슭막이", None, "찰쌓기", None]
|
||||
col["단위"] = "㎡" # 머리 밖 칸은 받음
|
||||
got["문서"]["층"] = ["가", "나", "다", "라"]
|
||||
got["문서"]["폴더"] = [["표에서 만든 폴더"]]
|
||||
assert admin.put(url, json=got).status_code == 200
|
||||
table = admin.get(url).json()["문서"]
|
||||
col = next(c for c in table["열"] if c["id"] == "rv")
|
||||
assert col["경로"] == path and col["단위"] == "㎡"
|
||||
assert table["층"] == ["종류", "분류1", "분류2", "분류3"] and table["폴더"] == []
|
||||
t = Tree(admin, "system")
|
||||
assert t.item(["돌기슭막이", "찰쌓기"])["key"] == rv["key"]
|
||||
assert t.item(path)["key"] == rv["key"]
|
||||
t.do(op="rename", 키=rv["key"], 이름="메쌓기")
|
||||
table = admin.get(url).json()["문서"]
|
||||
col = next(c for c in table["열"] if c["id"] == "rv")
|
||||
assert col["경로"] == ["돌기슭막이", "메쌓기"] and col["머리"] == [
|
||||
"돌기슭막이",
|
||||
"메쌓기",
|
||||
None,
|
||||
None,
|
||||
]
|
||||
# 표에서 열을 지우면 나무에서도 빠지고 파일도 같이
|
||||
assert col["경로"] == [*path[:-1], "메쌓기"]
|
||||
assert col["머리"] == [*path[:-1], "메쌓기", *[None] * (4 - len(path))]
|
||||
# (화면엔 단추 없음 · API 호환) 표 문서에서 열을 빼고 저장하면 나무에서도 빠지고 파일도 같이
|
||||
table["열"] = [c for c in table["열"] if c["id"] != "rv"]
|
||||
assert (
|
||||
admin.put(url, json={"판": admin.get(url).json()["판"], "문서": table}).status_code == 200
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* 치지 않고 조회 후 선택.
|
||||
* `readOnly` = 볼 수만 있음 — 단추 줄 · 안내 글 숨김 · 칸 고치기 · 머리 고치기 · 열 너비 끌기 막음 ·
|
||||
* 열이 하나도 없으면 [열 더하기] 가 켜져 첫 열을 만듦(머리 글 바로 고치기).
|
||||
* `lockHead` = 열 · 머리는 밖(나무)이 정함 — 열 더하기 · 지우기 단추 없음 · 머리 · 층 이름
|
||||
* 못 고침(단위 · 일위대가 · 조회 후 선택은 그대로 · PLAN 26-7d).
|
||||
* project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽.
|
||||
* 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음.
|
||||
* 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
|
||||
@@ -49,6 +51,8 @@ export interface SheetOptions {
|
||||
mode: SheetMode;
|
||||
/** 볼 수만 있음(단추 · 고치기 없음) */
|
||||
readOnly?: boolean;
|
||||
/** master — 열 더하기 · 지우기 · 머리 · 층 이름 고치기 없음(열 · 머리는 나무가 정함) */
|
||||
lockHead?: boolean;
|
||||
onChange?: (doc: SheetDoc) => void;
|
||||
/** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */
|
||||
drawingLabel?: (colId: string) => string;
|
||||
@@ -84,10 +88,15 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
areaLabel: opts.areaLabel,
|
||||
materialLabel: opts.materialLabel,
|
||||
pick: !!opts.onPick,
|
||||
headLocked: !!opts.lockHead,
|
||||
};
|
||||
const ro = !!opts.readOnly;
|
||||
const editCols = opts.mode === "master" && !ro && !opts.lockHead; // 열 · 머리 고치기
|
||||
const scroll = el("div", { className: "ui-sheet__scroll" });
|
||||
const toolbar = el("div", { className: "ui-sheet__toolbar", attrs: ro ? { hidden: "" } : {} });
|
||||
const toolbar = el("div", {
|
||||
className: "ui-sheet__toolbar",
|
||||
attrs: ro || opts.lockHead ? { hidden: "" } : {},
|
||||
});
|
||||
const root = el("div", {
|
||||
className: `ui-sheet ui-sheet--${opts.mode}`,
|
||||
attrs: { tabindex: "0" },
|
||||
@@ -133,7 +142,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
state.sel = state.sel && next ? { r: state.sel.r, c: next.id } : null;
|
||||
changed();
|
||||
});
|
||||
if (opts.mode === "master" && !ro) toolbar.append(colDelete);
|
||||
if (editCols) toolbar.append(colDelete);
|
||||
|
||||
// 층마다 열 더하기 단추 — 층이 둘 이상이면 둘째 층부터(「분류1 더하기」 …) · 하나면 「열 더하기」 ·
|
||||
// 층 이름 · 층 수가 바뀌면 다시 만듦
|
||||
@@ -159,7 +168,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
|
||||
const syncToolbar = (): void => {
|
||||
const col = state.sel ? colOf(state.sel.c) : undefined;
|
||||
if (opts.mode === "master" && !ro) syncLevels(!!col);
|
||||
if (editCols) syncLevels(!!col);
|
||||
colDelete.disabled = !col || state.doc.열.length <= 1;
|
||||
};
|
||||
|
||||
@@ -295,8 +304,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
const th = target.closest<HTMLElement>("th[data-col]");
|
||||
const layer = target.closest<HTMLElement>("th[data-layer]");
|
||||
if (ro) return;
|
||||
if (th && state.mode === "master") editHead(th);
|
||||
else if (layer && state.mode === "master") editLayer(layer);
|
||||
if (th && editCols) editHead(th);
|
||||
else if (layer && editCols) editLayer(layer);
|
||||
else if (target.closest("td[data-r]")) edit();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface RenderState {
|
||||
materialLabel?: (colId: string) => string;
|
||||
/** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */
|
||||
pick?: boolean;
|
||||
/** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */
|
||||
headLocked?: boolean;
|
||||
}
|
||||
|
||||
/** 도면 · 면적산출 · 재료산출 줄 칸 글. */
|
||||
@@ -194,7 +196,8 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
|
||||
const thead = (): HTMLElement => {
|
||||
const rows = head.map((cells, level) => {
|
||||
const layer = gutter("th", doc.층?.[level] ?? "", mode === "master" ? "is-editable" : "");
|
||||
const heads = mode === "master" && !state.headLocked;
|
||||
const layer = gutter("th", doc.층?.[level] ?? "", heads ? "is-editable" : "");
|
||||
layer.dataset.layer = String(level);
|
||||
const tr = el("tr", { children: [layer] });
|
||||
for (const cell of cells) {
|
||||
@@ -208,7 +211,7 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
"data-col": cols[cell.col].id,
|
||||
},
|
||||
});
|
||||
if (mode === "master") th.classList.add("is-editable");
|
||||
if (heads) th.classList.add("is-editable");
|
||||
freeze(th, cell.col, cell.colspan);
|
||||
tr.append(th);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
export const ui_locales_m2 = {
|
||||
B01_Dashboard_MasterTemplate: ["마스터 템플릿", "Master Templates"],
|
||||
M02_Title: ["마스터 템플릿", "Master Templates"],
|
||||
M02_KindTable: ["구조물 집계 양식", "Structure summary templates"],
|
||||
M02_KindTable: ["구조물 조합", "Structure combos"],
|
||||
M02_KindDrawing: ["도면 양식", "Drawing templates"],
|
||||
M02_KindStructure: ["구조물 도면", "Structure drawings"],
|
||||
M02_KindArea: ["면적산출", "Area takeoff"],
|
||||
@@ -66,11 +66,11 @@ export const ui_locales_m2 = {
|
||||
"Company templates are read-only — copy the selected column to your own to edit",
|
||||
],
|
||||
M02_CopyToPersonal: ["개인용으로 복사", "Copy to personal"],
|
||||
M02_CopyPickFirst: ["복사할 열을 집계표에서 먼저 고름", "Pick a column in the table first"],
|
||||
M02_CopyPickFirst: ["복사할 열을 구조물 조합에서 먼저 고름", "Pick a column in the table first"],
|
||||
M02_CopyDone: ["개인용으로 복사함 — 구조물 관리(개인)에서 고침", "Copied — edit it in your own"],
|
||||
M02_CopyFailed: ["복사 안 됨 — {value}", "Copy failed — {value}"],
|
||||
M02_NeedCompany: [
|
||||
"회사부터 등록해야 구조물 집계표를 만들 수 있음",
|
||||
"회사부터 등록해야 구조물 조합을 만들 수 있음",
|
||||
"Register a company first to manage structure templates",
|
||||
],
|
||||
M02_NeedCompanyGo: ["회사 등록하러 가기", "Go to company registration"],
|
||||
|
||||
Reference in New Issue
Block a user