Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
243d00c218 | ||
|
|
7e694a8825 | ||
|
|
65a6b6e942 | ||
|
|
9d1a4f9bdb | ||
|
|
a7f2383d90 | ||
|
|
8ae1111f16 | ||
|
|
38a13bcb0b | ||
|
|
1ae10e64e2 | ||
|
|
83e08c2c7f | ||
|
|
fabc033c53 |
@@ -7,8 +7,8 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
/** 층 이름 — 서버와 같은 글 */
|
||||
export type Layer = "system" | "company" | "personal" | "project";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing */
|
||||
export type Kind = "table" | "drawing";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure */
|
||||
export type Kind = "table" | "drawing" | "structure";
|
||||
|
||||
export interface TemplateInfo {
|
||||
종류: Kind;
|
||||
@@ -92,6 +92,32 @@ export const deleteLayerTemplate = (
|
||||
): Promise<{ ok: boolean }> =>
|
||||
call(`/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
|
||||
/* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) ---
|
||||
* 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */
|
||||
|
||||
/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · `산출근거` = 표 양식과 같은 표 문서 */
|
||||
export interface StructureDoc {
|
||||
양식: "구조물도면";
|
||||
종류: "structure";
|
||||
판: number;
|
||||
열: string;
|
||||
도번: string;
|
||||
도면: { entities: unknown[] } & Record<string, unknown>;
|
||||
산출근거: { 열: unknown[]; 줄: unknown[] } & Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 시스템 층 구조물 도면 목록 */
|
||||
export const listStructures = async (): Promise<TemplateInfo[]> =>
|
||||
(await listTemplates("system", null)).filter((t) => t.종류 === "structure");
|
||||
|
||||
/** 새로 — 도번 자동 · A1 도각을 깐 빈 도면 + 빈 산출근거 표 · 이미 있으면 StaleError */
|
||||
export const createStructure = (column: string): Promise<TemplateDoc> =>
|
||||
call(`/structures/${enc(column)}`, { method: "POST" });
|
||||
|
||||
/** `{열 id: 도번}` — 집계표 도면 줄(없는 열은 키 없음 = 「없음」) */
|
||||
export const fetchStructureNumbers = (): Promise<Record<string, string>> =>
|
||||
call("/structures/numbers");
|
||||
|
||||
/* --- 프로젝트 층 단추 다섯 --- */
|
||||
const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`;
|
||||
const post = (path: string, body: object = {}): Promise<unknown> =>
|
||||
|
||||
@@ -32,6 +32,17 @@ def get_templates() -> list[dict]:
|
||||
return _call(store.list_all)
|
||||
|
||||
|
||||
@router.get("/structures/numbers")
|
||||
def get_structure_numbers() -> dict:
|
||||
return _call(store.structure_numbers)
|
||||
|
||||
|
||||
@router.post("/structures/{column}")
|
||||
def post_structure(column: str) -> dict:
|
||||
"""구조물 도면 새로 — 열 id 하나 · 도번 자동 · A1 도각 + 빈 산출근거 표 · 이미 있으면 409."""
|
||||
return _call(store.create_structure, column)
|
||||
|
||||
|
||||
@router.get("/templates/{kind}/{name}")
|
||||
def get_template(kind: str, name: str) -> dict:
|
||||
return _call(store.read, kind, name)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""M02 마스터 템플릿 — 시스템 층 저장소 (`resources/master_template/{table,drawing}/<이름>.json`).
|
||||
"""M02 마스터 템플릿 — 시스템 층 저장소 (`resources/master_template/{종류}/<이름>.json`).
|
||||
|
||||
M01 `Store` 방식 — 판(파일 sha256 앞 16자) · 판이 다르면 409 · 원자 쓰기.
|
||||
권한은 등록하는 쪽(`main.py`)이 붙임.
|
||||
@@ -15,9 +15,15 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template"
|
||||
KINDS = ("table", "drawing") # 시험은 FOLDER 를 사본으로 바꿈
|
||||
KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈
|
||||
# 구조물 도면 = 구조물집계표 열 id 마다 하나(파일 이름 = 열 id) · 새로 만들 때 A1 도각을 깖
|
||||
STRUCTURE_TABLE = "구조물집계표"
|
||||
STRUCTURE_FRAME = "00_template_A1"
|
||||
_BASIS_HEAD = (("work", "공종"), ("spec", "규격"), ("detail", "산출 내역"), ("unit", "단위"))
|
||||
_NUMBER = re.compile(r"^구-(\d+)$")
|
||||
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
|
||||
# ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로
|
||||
_LOCK = threading.Lock()
|
||||
@@ -66,10 +72,11 @@ def read(kind: str, name: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _check_skeleton(kind: str, doc: Any) -> None:
|
||||
"""빈 문서·뼈대 없는 문서는 거절 — 표는 열 목록 · 도면은 entities 목록."""
|
||||
key = "열" if kind == "table" else "entities"
|
||||
if not isinstance(doc, dict) or not isinstance(doc.get(key), list):
|
||||
raise StoreError(400, f"양식 문서에 「{key}」 목록이 없음 — 저장하지 않음")
|
||||
"""빈 문서·뼈대 없는 문서는 거절 — 층 저장과 같은 규칙(`layers.check_skeleton`)."""
|
||||
try:
|
||||
layers.check_skeleton(kind, doc)
|
||||
except ValueError as e:
|
||||
raise StoreError(400, str(e)) from e
|
||||
|
||||
|
||||
def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
|
||||
@@ -92,3 +99,49 @@ def delete(kind: str, name: str, version: str | None = None) -> None:
|
||||
if version is not None and version_of(path.read_bytes()) != version:
|
||||
raise StoreError(409, {"stale": [name], "판": version_of(path.read_bytes())})
|
||||
path.unlink()
|
||||
|
||||
|
||||
# ── 구조물 도면 ───────────────────────────────────────
|
||||
|
||||
|
||||
def _structures() -> list[tuple[str, Any]]:
|
||||
folder = FOLDER / "structure"
|
||||
return [(p.stem, json.loads(p.read_bytes())) for p in sorted(folder.glob("[!.]*.json"))]
|
||||
|
||||
|
||||
def structure_numbers() -> dict[str, str | None]:
|
||||
"""`{열 id: 도번}` — 집계표 도면 줄이 한 번에 받음."""
|
||||
return {
|
||||
name: (doc.get("도번") if isinstance(doc, dict) else None) for name, doc in _structures()
|
||||
}
|
||||
|
||||
|
||||
def _number(doc: Any) -> int:
|
||||
found = _NUMBER.match(str(doc.get("도번") or "")) if isinstance(doc, dict) else None
|
||||
return int(found.group(1)) if found else 0
|
||||
|
||||
|
||||
def create_structure(column: str) -> dict[str, Any]:
|
||||
"""열 id 하나의 구조물 도면 새로 — 도번 = 있는 것 중 가장 큰 번호 + 1 · 이미 있으면 409."""
|
||||
path = _path("structure", column)
|
||||
table = read("table", STRUCTURE_TABLE)["문서"]
|
||||
if column not in {col.get("id") for col in table.get("열", []) if isinstance(col, dict)}:
|
||||
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 열 「{column}」")
|
||||
frame = read("drawing", STRUCTURE_FRAME)["문서"]
|
||||
basis = [{"id": key, "머리": [label], "단위": None, "꼴": "글"} for key, label in _BASIS_HEAD]
|
||||
basis.append({"id": "qty", "머리": ["수량(m당)"], "단위": None, "꼴": "수"})
|
||||
with _LOCK:
|
||||
if path.is_file():
|
||||
raise StoreError(409, f"이미 있는 구조물 도면 「{column}」")
|
||||
number = max((_number(doc) for _name, doc in _structures()), default=0) + 1
|
||||
doc = {
|
||||
"양식": "구조물도면",
|
||||
"종류": "structure",
|
||||
"판": 1,
|
||||
"열": column,
|
||||
"도번": f"구-{number:02d}",
|
||||
"도면": frame,
|
||||
"산출근거": {"양식": "산출근거", "종류": "표", "판": 1, "열": basis, "줄": []},
|
||||
}
|
||||
atomic_write_json(path, doc)
|
||||
return read("structure", column)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/* M02 구조물집계표 아래 설계 컨테이너 — 머리 줄 · 도면 미리보기 · 상세 산출근거 표. */
|
||||
|
||||
/* 표 밖 형제 자리 — 비어 있을 때 메인 간격이 벌어지지 않게 칸을 지움. */
|
||||
.m02-main__extra {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.m02-struct {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding-top: var(--spacing-12);
|
||||
border-top: 1px solid var(--color-border);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-struct__head,
|
||||
.m02-struct__subhead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-struct__title,
|
||||
.m02-struct__subtitle {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-struct__no {
|
||||
padding: 0 var(--spacing-8);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-struct__head .ui-btn,
|
||||
.m02-struct__subhead .ui-btn {
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 미리보기 — 웹캐드는 높이를 부모에서 받음(`.m02-drawing` 이 flex: 1 1 0) */
|
||||
.m02-struct__preview {
|
||||
display: flex;
|
||||
height: 24rem;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-struct__sheet {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-struct__empty {
|
||||
margin: 0;
|
||||
padding: var(--spacing-16);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Structure.ts
|
||||
* 구조물집계표 아래 설계 컨테이너 — 고른 열의 구조물 도면 미리보기 · [도면 수정] · 상세 산출근거 표.
|
||||
*
|
||||
* 구조물 도면은 집계표 열마다 하나(`resources/master_template/structure/<열 id>.json`) ·
|
||||
* 도번은 서버가 매김(`구-01` 꼴 · 없으면 「없음」) · 문서 = {열 · 도번 · 도면(CAD) · 산출근거(표)}.
|
||||
* 이 칸은 표 부품 host 밖 형제 자리에 붙음 — 집계표는 그대로 두고 여기만 열마다 다시 그림.
|
||||
* 글자는 이 파일에 둠(도면 부품 `M02_MasterTemplete_Drawing.ts` 와 같은 방식).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { createSheet, type SheetDoc, type SheetHandle } from "@ui/sheet/ui_template_sheet";
|
||||
import {
|
||||
createStructure,
|
||||
fetchStructureNumbers,
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
StaleError,
|
||||
type StructureDoc,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
mountDrawingTemplate,
|
||||
type DrawingTemplateDoc,
|
||||
type DrawingTemplateHandle,
|
||||
} from "./M02_MasterTemplete_Drawing";
|
||||
import "./M02_MasterTemplete_Structure.css";
|
||||
|
||||
/** 구조물 도면이 걸리는 집계표 이름 — 서버 `M02_MasterTemplete_Store.STRUCTURE_TABLE` 과 같은 글 */
|
||||
export const STRUCTURE_TABLE = "구조물집계표";
|
||||
|
||||
const TEXT = {
|
||||
edit: "도면 수정",
|
||||
save: "산출근거 저장",
|
||||
basis: "상세 산출근거",
|
||||
noNumber: "도번 없음",
|
||||
noDrawing: "도면 없음 — [도면 수정] 을 누르면 새로 만들어 엶",
|
||||
noBasis: "표 없음 — 도면이 아직 없음",
|
||||
pick: "집계표에서 열을 고를 것",
|
||||
created: "구조물 도면을 만들었음 — {value}",
|
||||
saved: "산출근거를 저장했음",
|
||||
dirty: "저장 안 한 산출근거 고침이 있음 — 버리고 넘어갈까?",
|
||||
kept: "열을 바꾸지 않음 — 산출근거를 먼저 저장할 것",
|
||||
stale: "그 사이 구조물 도면이 바뀜 — 열을 다시 고를 것",
|
||||
readFailed: "구조물 도면을 못 읽음 — {value}",
|
||||
makeFailed: "구조물 도면을 못 만듦 — {value}",
|
||||
saveFailed: "저장 못 함 — {value}",
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
|
||||
/** 집계표 열 — 머리 글만 씀 */
|
||||
export interface StructureColumn {
|
||||
id: string;
|
||||
머리: (string | null)[];
|
||||
}
|
||||
|
||||
/** 열 머리 글 — 층을 ` · ` 로 이음(빈 층은 건너뜀 · 다 비면 열 id) */
|
||||
export const columnHead = (col: StructureColumn): string =>
|
||||
col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" · ") || col.id;
|
||||
|
||||
export interface StructurePanelOptions {
|
||||
/** 시스템 관리자만 새로 만들고 저장함 */
|
||||
isAdmin: boolean;
|
||||
/** [도면 수정] — 그 열 구조물 도면을 CAD 편집으로 엶(페이지의 `open`) */
|
||||
onEdit: (column: string) => void;
|
||||
/** 새로 만든 뒤 — 집계표 도면 줄을 새 도번으로 다시 그림 */
|
||||
onCreated: () => void;
|
||||
/** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface StructurePanelHandle {
|
||||
root: HTMLElement;
|
||||
/** `{열 id: 도번}` — 집계표 `drawingLabel` 이 씀 */
|
||||
numbers: () => Record<string, string>;
|
||||
/** 도번 목록 다시 받기 */
|
||||
reload: () => Promise<void>;
|
||||
/** 고른 열이 바뀜 — 같은 열이면 아무것도 안 함(CAD 를 다시 안 띄움) */
|
||||
show: (colId: string | null, columns: StructureColumn[]) => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createStructurePanel(opts: StructurePanelOptions): StructurePanelHandle {
|
||||
let numbers: Record<string, string> = {};
|
||||
let column: string | null = null;
|
||||
let doc: StructureDoc | null = null;
|
||||
let version = "";
|
||||
let preview: DrawingTemplateHandle | null = null;
|
||||
let basis: SheetHandle | null = null;
|
||||
let basisDirty = false;
|
||||
let busy = false;
|
||||
let seq = 0;
|
||||
|
||||
const title = el("h3", { className: "m02-struct__title", text: TEXT.pick });
|
||||
const number = el("span", { className: "m02-struct__no" });
|
||||
const edit = createButton({ label: TEXT.edit, variant: "filled" });
|
||||
const saveBasis = createButton({ label: TEXT.save, variant: "ghost" });
|
||||
const previewHost = el("div", { className: "m02-struct__preview" });
|
||||
const basisHost = el("div", { className: "m02-struct__sheet" });
|
||||
const root = el("section", {
|
||||
className: "m02-struct",
|
||||
attrs: { hidden: "" },
|
||||
children: [
|
||||
el("div", { className: "m02-struct__head", children: [title, number, edit] }),
|
||||
previewHost,
|
||||
el("div", {
|
||||
className: "m02-struct__subhead",
|
||||
children: [el("h4", { className: "m02-struct__subtitle", text: TEXT.basis }), saveBasis],
|
||||
}),
|
||||
basisHost,
|
||||
],
|
||||
});
|
||||
|
||||
const drop = (): void => {
|
||||
preview?.destroy();
|
||||
preview = null;
|
||||
basis?.destroy();
|
||||
basis = null;
|
||||
basisDirty = false;
|
||||
};
|
||||
|
||||
const note = (text: string): HTMLElement => el("p", { className: "m02-struct__empty", text });
|
||||
|
||||
/** 미리보기 CAD 는 iframe — 안쪽 도구 모음(제목줄 · 리본)을 스타일로 숨김(같은 출처라 접근 가능) */
|
||||
const hideToolbar = (frame: HTMLIFrameElement): void => {
|
||||
const apply = (): void => {
|
||||
const inner = frame.contentDocument;
|
||||
if (!inner?.head || inner.getElementById("m02-struct-hide")) return;
|
||||
const style = inner.createElement("style");
|
||||
style.id = "m02-struct-hide";
|
||||
style.textContent = ".cad-titlebar, .cad-ribbon { display: none !important; }";
|
||||
inner.head.append(style);
|
||||
};
|
||||
frame.addEventListener("load", apply);
|
||||
apply();
|
||||
};
|
||||
|
||||
/** 미리보기 · 산출근거 표 — 문서가 없으면 없음 글 */
|
||||
const paintBody = (): void => {
|
||||
drop();
|
||||
number.textContent = doc?.도번 || numbers[column ?? ""] || TEXT.noNumber;
|
||||
edit.hidden = !opts.isAdmin;
|
||||
saveBasis.hidden = !opts.isAdmin || !doc;
|
||||
if (!doc) {
|
||||
previewHost.replaceChildren(note(TEXT.noDrawing));
|
||||
basisHost.replaceChildren(note(TEXT.noBasis));
|
||||
return;
|
||||
}
|
||||
previewHost.replaceChildren();
|
||||
basisHost.replaceChildren();
|
||||
// 미리보기 — 도면 양식과 같은 부품을 읽기만으로. 자동백업 칸은 편집 화면과 갈라 둠.
|
||||
preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, {
|
||||
readOnly: true,
|
||||
name: `preview:${column ?? ""}`,
|
||||
layer: "system",
|
||||
});
|
||||
const frame = previewHost.querySelector("iframe");
|
||||
if (frame) hideToolbar(frame);
|
||||
basis = createSheet(basisHost, doc.산출근거 as unknown as SheetDoc, {
|
||||
mode: "master",
|
||||
onChange: () => (basisDirty = true),
|
||||
});
|
||||
};
|
||||
|
||||
async function load(col: StructureColumn | null): Promise<void> {
|
||||
const mine = ++seq;
|
||||
drop();
|
||||
if (!col) {
|
||||
root.hidden = true;
|
||||
column = null;
|
||||
doc = null;
|
||||
return;
|
||||
}
|
||||
root.hidden = false;
|
||||
column = col.id;
|
||||
title.textContent = columnHead(col);
|
||||
number.textContent = numbers[col.id] ?? TEXT.noNumber;
|
||||
doc = null;
|
||||
version = "";
|
||||
previewHost.replaceChildren();
|
||||
basisHost.replaceChildren();
|
||||
try {
|
||||
const got = await readTemplate("system", "structure", col.id, null);
|
||||
if (mine !== seq) return;
|
||||
doc = got.문서 as StructureDoc;
|
||||
version = got.판 ?? "";
|
||||
} catch (error) {
|
||||
if (mine !== seq) return;
|
||||
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
|
||||
if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(error)), "error");
|
||||
}
|
||||
paintBody();
|
||||
}
|
||||
|
||||
const show = (colId: string | null, columns: StructureColumn[]): void => {
|
||||
if (colId === column) return;
|
||||
const next = colId ? (columns.find((c) => c.id === colId) ?? null) : null;
|
||||
if (!basisDirty) return void load(next);
|
||||
void showConfirmDialog(TEXT.dirty).then((ok) => {
|
||||
if (!ok) return void showToast(TEXT.kept, "info");
|
||||
basisDirty = false;
|
||||
void load(next);
|
||||
});
|
||||
};
|
||||
|
||||
saveBasis.addEventListener("click", async () => {
|
||||
const at = column;
|
||||
if (!at || !doc || !basis || busy) return;
|
||||
busy = true;
|
||||
saveBasis.disabled = true;
|
||||
try {
|
||||
const next: StructureDoc = {
|
||||
...doc,
|
||||
산출근거: basis.getDoc() as unknown as StructureDoc["산출근거"],
|
||||
};
|
||||
const info = await saveTemplate("system", "structure", at, null, version, next);
|
||||
version = info.판;
|
||||
doc = next;
|
||||
basisDirty = false;
|
||||
showToast(TEXT.saved, "success");
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof StaleError ? TEXT.stale : fill(TEXT.saveFailed, why(error)),
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
busy = false;
|
||||
saveBasis.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
edit.addEventListener("click", async () => {
|
||||
const at = column;
|
||||
if (!at || busy) return;
|
||||
if (basisDirty && !(await showConfirmDialog(TEXT.dirty))) return;
|
||||
basisDirty = false;
|
||||
if (!(await opts.confirmLeave())) return;
|
||||
if (!doc) {
|
||||
busy = true;
|
||||
edit.disabled = true;
|
||||
try {
|
||||
const got = await createStructure(at);
|
||||
doc = got.문서 as StructureDoc;
|
||||
version = got.판 ?? "";
|
||||
numbers = await fetchStructureNumbers();
|
||||
showToast(fill(TEXT.created, doc.도번), "success");
|
||||
opts.onCreated(); // 좌측 목록 · 집계표 도면 줄
|
||||
} catch (error) {
|
||||
showToast(fill(TEXT.makeFailed, why(error)), "error");
|
||||
return;
|
||||
} finally {
|
||||
busy = false;
|
||||
edit.disabled = false;
|
||||
}
|
||||
}
|
||||
opts.onEdit(at);
|
||||
});
|
||||
|
||||
return {
|
||||
root,
|
||||
numbers: () => numbers,
|
||||
reload: async () => {
|
||||
numbers = await fetchStructureNumbers().catch(() => ({}));
|
||||
},
|
||||
show,
|
||||
destroy: () => {
|
||||
seq += 1;
|
||||
drop();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,14 @@ import {
|
||||
StaleError,
|
||||
type Kind,
|
||||
type Layer,
|
||||
type StructureDoc,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
createStructurePanel,
|
||||
STRUCTURE_TABLE,
|
||||
type StructureColumn,
|
||||
type StructurePanelHandle,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
|
||||
export interface Selection {
|
||||
layer: Layer;
|
||||
@@ -28,6 +35,12 @@ interface EditorHandle {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/** 표 부품 손잡이 — 도면 줄 도번을 다시 그리려고 `recalc` 을 씀 */
|
||||
interface SheetEditorHandle extends EditorHandle {
|
||||
getDoc: () => { 열: StructureColumn[] };
|
||||
recalc: () => void;
|
||||
}
|
||||
|
||||
type Mods = Record<string, () => Promise<unknown>>;
|
||||
// 표 부품(sub3) · 도면 부품(sub2) — 파일 이름은 각 창이 정함 → 폴더를 훑어 내보낸 이름으로 찾음
|
||||
const SHEET_MODS: Mods = import.meta.glob("../ui_template/sheet/*.ts");
|
||||
@@ -65,12 +78,14 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴
|
||||
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", {
|
||||
className: "m02-main",
|
||||
children: [
|
||||
el("div", { className: "m02-main__head", children: [title, slot, reload, save] }),
|
||||
notice,
|
||||
host,
|
||||
extra,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -78,6 +93,8 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
let version = "";
|
||||
let loaded: unknown = null;
|
||||
let editor: EditorHandle | null = null;
|
||||
let sheet: SheetEditorHandle | null = null;
|
||||
let panel: StructurePanelHandle | null = null;
|
||||
let seq = 0;
|
||||
let dirty = false;
|
||||
|
||||
@@ -111,9 +128,31 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
const drop = (): void => {
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
sheet = null;
|
||||
panel?.destroy();
|
||||
panel = null;
|
||||
extra.replaceChildren();
|
||||
slot.replaceChildren();
|
||||
};
|
||||
|
||||
/** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 미리보기 · [도면 수정] · 상세 산출근거 */
|
||||
function buildPanel(): StructurePanelHandle {
|
||||
const made = createStructurePanel({
|
||||
isAdmin,
|
||||
confirmLeave,
|
||||
// 같은 페이지에서 그 구조물 도면을 CAD 편집으로 엶 · 좌측 목록을 다시 받아
|
||||
// 고름 표시를 옮김(`sel` 은 `open` 이 기다리기 전에 이미 새 것 · 목록은 `getOpen` 을 봄)
|
||||
onEdit: (column) => {
|
||||
void open({ layer: "system", projectId: null, kind: "structure", name: column });
|
||||
onSaved?.();
|
||||
},
|
||||
// 새로 만든 뒤 — 집계표 도면 줄을 새 도번으로 다시 그림
|
||||
onCreated: () => sheet?.recalc(),
|
||||
});
|
||||
extra.replaceChildren(made.root);
|
||||
return made;
|
||||
}
|
||||
|
||||
async function mount(doc: unknown, at: Selection): Promise<void> {
|
||||
const readOnly = !canEdit(at.layer, isAdmin);
|
||||
host.replaceChildren();
|
||||
@@ -122,21 +161,33 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
SHEET_MODS,
|
||||
"createSheet",
|
||||
);
|
||||
if (create) {
|
||||
editor = create(host, doc, {
|
||||
mode: at.layer === "project" ? "project" : "master",
|
||||
onChange: () => (dirty = !readOnly),
|
||||
});
|
||||
return;
|
||||
}
|
||||
return empty(L("M02_NoSheet"));
|
||||
if (!create) return empty(L("M02_NoSheet"));
|
||||
// 구조물집계표(시스템 층)만 — 도면 줄에 도번 · 칸을 고르면 아래 컨테이너
|
||||
const struct = at.layer === "system" && at.name === STRUCTURE_TABLE ? buildPanel() : null;
|
||||
if (struct) await struct.reload();
|
||||
panel = struct;
|
||||
editor = create(host, doc, {
|
||||
mode: at.layer === "project" ? "project" : "master",
|
||||
onChange: () => (dirty = !readOnly),
|
||||
...(struct
|
||||
? {
|
||||
drawingLabel: (colId: string) => struct.numbers()[colId] ?? "",
|
||||
onSelect: (s: { colId: string | null }) =>
|
||||
struct.show(s.colId, sheet?.getDoc().열 ?? []),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (struct) sheet = editor as SheetEditorHandle;
|
||||
return;
|
||||
}
|
||||
const mountDrawing = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
|
||||
DRAWING_MODS,
|
||||
"mountDrawingTemplate",
|
||||
);
|
||||
if (mountDrawing) {
|
||||
editor = mountDrawing(host, doc, {
|
||||
// 구조물 도면은 CAD 문서가 `도면` 칸에 들어 있음 — 도번 · 산출근거는 안 건드림
|
||||
const cad = at.kind === "structure" ? (doc as StructureDoc).도면 : doc;
|
||||
editor = mountDrawing(host, cad, {
|
||||
onSave: () => void doSave(),
|
||||
readOnly,
|
||||
name: at.name,
|
||||
@@ -176,6 +227,11 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
)
|
||||
: got.문서;
|
||||
if (mine !== seq) return;
|
||||
// 구조물 도면은 열 id 로 열리니 머리에 도번을 함께 보임
|
||||
if (next.kind === "structure") {
|
||||
const number = (got.문서 as StructureDoc)?.도번;
|
||||
if (number) title.textContent = `${number} · ${next.name}`;
|
||||
}
|
||||
await mount(shown, next);
|
||||
} catch (error) {
|
||||
if (mine !== seq) return;
|
||||
@@ -189,7 +245,12 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
if (!sel) return;
|
||||
const at = sel;
|
||||
try {
|
||||
const doc = editor ? await editor.getDoc() : loaded;
|
||||
const edited = editor ? await editor.getDoc() : loaded;
|
||||
// 구조물 도면 — CAD 는 `도면` 칸만 다룸 · 도번 · 산출근거는 읽은 그대로 둠
|
||||
const doc =
|
||||
at.kind === "structure" && loaded
|
||||
? { ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] }
|
||||
: edited;
|
||||
const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc);
|
||||
version = info.판;
|
||||
loaded = doc;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
/** 서버 종류 추가 전까지 `Kind` 에 없음 — 목록 응답에 있을 때만 그림 */
|
||||
const STRUCTURE = "structure" as Kind;
|
||||
const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
{ kind: "table", label: () => L("M02_KindTable") },
|
||||
{ kind: "drawing", label: () => L("M02_KindDrawing") },
|
||||
@@ -55,6 +58,8 @@ export interface SideOptions {
|
||||
export interface SideHandle {
|
||||
root: HTMLElement;
|
||||
refresh: () => Promise<void>;
|
||||
/** 좌측 고름 표시만 옮김 — 화면은 열지 않음 */
|
||||
select: (kind: Kind, name: string) => void;
|
||||
}
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
@@ -70,7 +75,31 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
let items: TemplateInfo[] = [];
|
||||
let seq = 0;
|
||||
|
||||
const listHost = el("div", { className: "m02-side__lists" });
|
||||
let picked: { kind: Kind; name: string } | null = null;
|
||||
|
||||
const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement =>
|
||||
el("section", {
|
||||
className: "m02-side__group ui-collapsible ui-sidebar-section",
|
||||
children: [el("h3", { className: "ui-collapsible__title", text: title }), body, ...more],
|
||||
});
|
||||
const bodies = new Map<Kind, HTMLElement>(
|
||||
[...KINDS.map((k) => k.kind), STRUCTURE].map((k) => [
|
||||
k,
|
||||
el("div", { className: "m02-side__items" }),
|
||||
]),
|
||||
);
|
||||
const listHost = el("div", {
|
||||
className: "m02-side__lists",
|
||||
children: [
|
||||
section(L("M02_KindTable"), bodies.get("table")!),
|
||||
section(
|
||||
L("M02_KindDrawing"),
|
||||
bodies.get("drawing")!,
|
||||
section(L("M02_KindStructure"), bodies.get(STRUCTURE)!),
|
||||
),
|
||||
],
|
||||
});
|
||||
attachCollapsible(listHost);
|
||||
|
||||
const btnNew = createButton({ label: L("M02_New"), variant: "filled" });
|
||||
const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost" });
|
||||
@@ -80,6 +109,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
const root = el("div", { className: "m02-side", children: [listHost, editRow] });
|
||||
|
||||
const isOpen = (info: TemplateInfo): boolean => {
|
||||
if (picked) return picked.kind === info.종류 && picked.name === info.이름;
|
||||
const cur = opt.getOpen();
|
||||
return !!cur && cur.layer === layer && cur.kind === info.종류 && cur.name === info.이름;
|
||||
};
|
||||
@@ -90,28 +120,22 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
};
|
||||
|
||||
function paintList(): void {
|
||||
listHost.replaceChildren(
|
||||
...KINDS.map(({ kind, label }) => {
|
||||
const mine = items.filter((i) => i.종류 === kind);
|
||||
return el("div", {
|
||||
className: "m02-side__group",
|
||||
children: [
|
||||
el("h4", { text: label() }),
|
||||
...(mine.length
|
||||
? mine.map((info) => {
|
||||
const b = el("button", {
|
||||
className: `m02-side__item${isOpen(info) ? " is-active" : ""}`,
|
||||
text: info.이름,
|
||||
attrs: { type: "button", title: info.수정일 },
|
||||
});
|
||||
b.addEventListener("click", () => void select(info));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
for (const [kind, body] of bodies) {
|
||||
const mine = items.filter((i) => i.종류 === kind);
|
||||
body.replaceChildren(
|
||||
...(mine.length
|
||||
? mine.map((info) => {
|
||||
const b = el("button", {
|
||||
className: `m02-side__item${isOpen(info) ? " is-active" : ""}`,
|
||||
text: info.이름,
|
||||
attrs: { type: "button", title: info.수정일 },
|
||||
});
|
||||
b.addEventListener("click", () => void select(info));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function paintButtons(): void {
|
||||
@@ -123,6 +147,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
|
||||
async function select(info: TemplateInfo): Promise<void> {
|
||||
if (!isOpen(info) && !(await opt.confirmLeave())) return;
|
||||
picked = null;
|
||||
opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 });
|
||||
paintList();
|
||||
paintButtons();
|
||||
@@ -218,5 +243,12 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
});
|
||||
|
||||
void refresh();
|
||||
return { root, refresh };
|
||||
return {
|
||||
root,
|
||||
refresh,
|
||||
select: (kind, name) => {
|
||||
picked = { kind, name };
|
||||
paintList();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-side__group h4 {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
.m02-side__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-side__item {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""M02 양식 층 — 네 층의 자리 · 읽기 · 쓰기 · 복사 · manifest.
|
||||
|
||||
층 넷 (PLAN 10-5):
|
||||
system `resources/master_template/{table,drawing}/<이름>.json` (git)
|
||||
company `storage/{회사}/templates/{table,drawing}/`
|
||||
personal `storage/{회사}/{사용자}/templates/{table,drawing}/`
|
||||
project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing}/`
|
||||
system `resources/master_template/{table,drawing,structure}/<이름>.json` (git)
|
||||
company `storage/{회사}/templates/{table,drawing,structure}/`
|
||||
personal `storage/{회사}/{사용자}/templates/{table,drawing,structure}/`
|
||||
project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing,structure}/`
|
||||
+ 초기 사본 `templates/_initial/`
|
||||
|
||||
- 층 폴더마다 `manifest.json` — 그 폴더에 든 양식의 출처 `{"table/이름": {층, 이름, 판, 적용일}}`.
|
||||
@@ -26,7 +26,7 @@ from common_util.common_util_json import atomic_write_json
|
||||
from config import config_system
|
||||
|
||||
LAYERS = ("system", "company", "personal", "project")
|
||||
KINDS = ("table", "drawing")
|
||||
KINDS = ("table", "drawing", "structure")
|
||||
TEMPLATES_DIRNAME = "templates"
|
||||
INITIAL_DIRNAME = "_initial"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
@@ -162,13 +162,25 @@ def read_template(layer_dir: str | Path, kind: str, name: str) -> dict[str, Any]
|
||||
}
|
||||
|
||||
|
||||
_SKELETON = {
|
||||
"table": ("열",),
|
||||
"drawing": ("entities",),
|
||||
"structure": ("도면.entities", "산출근거.열"),
|
||||
}
|
||||
|
||||
|
||||
def check_skeleton(kind: str, document: Any) -> None:
|
||||
"""뼈대 없는 문서는 거절 — 표 = `열` 목록 · 도면 = `entities` 목록(빈 `{}` 저장 막기)."""
|
||||
key = {"table": "열", "drawing": "entities"}.get(kind)
|
||||
"""뼈대 없는 문서는 거절 — 표 = `열` 목록 · 도면 = `entities` 목록 ·
|
||||
구조물 도면 = `도면.entities` · `산출근거.열` 목록(빈 `{}` 저장 막기)."""
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("양식 문서는 JSON 객체여야 합니다.")
|
||||
if key and not isinstance(document.get(key), list):
|
||||
raise ValueError(f"양식 문서에 `{key}` 목록이 없습니다 — 빈 문서는 저장하지 않습니다.")
|
||||
for key in _SKELETON.get(kind, ()):
|
||||
node: Any = document
|
||||
*parents, last = key.split(".")
|
||||
for part in parents:
|
||||
node = node.get(part) if isinstance(node, dict) else None
|
||||
if not isinstance(node, dict) or not isinstance(node.get(last), list):
|
||||
raise ValueError(f"양식 문서에 `{key}` 목록이 없습니다 — 빈 문서는 저장하지 않습니다.")
|
||||
|
||||
|
||||
def write_template(
|
||||
|
||||
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
"""M02 구조물 도면(structure) — 새로 · 도번 자동 · 409 · 저장 · 지운 뒤 번호 · 프로젝트 복사."""
|
||||
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router
|
||||
|
||||
REAL = store.FOLDER
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
for kind in store.KINDS:
|
||||
(tmp_path / kind).mkdir()
|
||||
shutil.copy(REAL / "table/구조물집계표.json", tmp_path / "table")
|
||||
shutil.copy(REAL / "drawing/00_template_A1.json", tmp_path / "drawing")
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_새로_도번_자동_409_없는_열(client):
|
||||
made = client.post("/api/m02/structures/pp_len")
|
||||
assert made.status_code == 200
|
||||
doc = made.json()["문서"]
|
||||
assert (doc["양식"], doc["종류"], doc["열"], doc["도번"]) == (
|
||||
"구조물도면",
|
||||
"structure",
|
||||
"pp_len",
|
||||
"구-01",
|
||||
)
|
||||
assert (
|
||||
doc["도면"]["entities"] and doc["도면"] == store.read("drawing", "00_template_A1")["문서"]
|
||||
)
|
||||
basis = doc["산출근거"]
|
||||
assert [c["머리"][0] for c in basis["열"]] == ["공종", "규격", "산출 내역", "단위", "수량(m당)"]
|
||||
assert basis["줄"] == [] and basis["종류"] == "표"
|
||||
|
||||
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "구-02"
|
||||
assert client.post("/api/m02/structures/pp_len").status_code == 409
|
||||
assert client.post("/api/m02/structures/없는열").status_code == 404
|
||||
assert client.get("/api/m02/structures/numbers").json() == {"pp_len": "구-01", "rv": "구-02"}
|
||||
rows = client.get("/api/m02/templates").json()
|
||||
assert {(r["종류"], r["이름"]) for r in rows if r["종류"] == "structure"} == {
|
||||
("structure", "pp_len"),
|
||||
("structure", "rv"),
|
||||
}
|
||||
|
||||
|
||||
def test_저장_뼈대_거름(client):
|
||||
got = client.post("/api/m02/structures/pp_len").json()
|
||||
doc = got["문서"]
|
||||
doc["산출근거"]["줄"] = [{"id": "r1", "값": {"work": "관 부설"}}]
|
||||
saved = client.put("/api/m02/templates/structure/pp_len", json={"판": got["판"], "문서": doc})
|
||||
assert saved.status_code == 200 and saved.json()["판"] != got["판"]
|
||||
for bad in ({}, {**doc, "도면": {}}, {**doc, "산출근거": {"열": "x"}}, {**doc, "도면": []}):
|
||||
r = client.put(
|
||||
"/api/m02/templates/structure/pp_len", json={"판": saved.json()["판"], "문서": bad}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert client.get("/api/m02/templates/structure/pp_len").json()["문서"] == doc
|
||||
|
||||
|
||||
def test_지워도_번호를_당기지_않음(client):
|
||||
for column in ("pp_len", "rv", "ms"):
|
||||
client.post(f"/api/m02/structures/{column}")
|
||||
assert client.delete("/api/m02/templates/structure/pp_len").status_code == 200
|
||||
assert client.get("/api/m02/structures/numbers").json() == {"rv": "구-02", "ms": "구-03"}
|
||||
assert client.post("/api/m02/structures/fb").json()["문서"]["도번"] == "구-04"
|
||||
# 가장 큰 번호를 지우면 그 번호부터 다시(있는 것 중 가장 큰 번호 + 1)
|
||||
client.delete("/api/m02/templates/structure/fb")
|
||||
assert client.post("/api/m02/structures/ec").json()["문서"]["도번"] == "구-04"
|
||||
|
||||
|
||||
def test_프로젝트_복사와_층_저장에_실림(client, tmp_path, monkeypatch):
|
||||
doc = client.post("/api/m02/structures/pp_len").json()["문서"]
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path)
|
||||
project = tmp_path / "project"
|
||||
copied = layers.seed_project(project)
|
||||
assert "structure/pp_len" in copied["작업본"] and "structure/pp_len" in copied["초기"]
|
||||
work = layers.project_dir(project)
|
||||
assert layers.read_template(work, "structure", "pp_len")["문서"] == doc
|
||||
assert layers.read_template(layers.initial_dir(project), "structure", "pp_len")["문서"] == doc
|
||||
assert ("structure", "pp_len") in {(r["종류"], r["이름"]) for r in layers.list_templates(work)}
|
||||
layers.check_skeleton("structure", doc)
|
||||
with pytest.raises(ValueError):
|
||||
layers.check_skeleton("structure", {**doc, "산출근거": None})
|
||||
@@ -0,0 +1,93 @@
|
||||
"""M02 구조물 설계 컨테이너(`M02_MasterTemplete_Structure.ts`) 붙는 자리 시험.
|
||||
|
||||
화면 조작은 ORCA 로 봄 — 여기서는 창끼리 계약이 안 어긋나는지만 봄.
|
||||
· 집계표 이름이 서버(`STRUCTURE_TABLE`)와 같은 글인가
|
||||
· 컨테이너가 쓰는 이름(표 부품 · 도면 부품 · 서버 호출)이 정말 내보내진 것인가
|
||||
· 열 머리 글 잇기(`columnHead`)를 Node 로 바로 돌려 봄
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
PANEL = M02 / "M02_MasterTemplete_Structure.ts"
|
||||
MAIN = M02 / "M02_MasterTemplete_UI_Main.ts"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_집계표_이름이_서버와_같음():
|
||||
found = re.search(r'STRUCTURE_TABLE = "([^"]+)"', _text(PANEL))
|
||||
assert found and found.group(1) == store.STRUCTURE_TABLE
|
||||
|
||||
|
||||
def test_소스가_700줄을_넘지_않음():
|
||||
for path in (PANEL, MAIN, M02 / "M02_MasterTemplete_Structure.css"):
|
||||
assert len(_text(path).splitlines()) <= 700, path.name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "names"),
|
||||
[
|
||||
(
|
||||
"M02_MasterTemplete_Api_Fetch.ts",
|
||||
["createStructure", "fetchStructureNumbers", "readTemplate", "saveTemplate"],
|
||||
),
|
||||
("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"]),
|
||||
("../ui_template/sheet/ui_template_sheet.ts", ["createSheet"]),
|
||||
],
|
||||
)
|
||||
def test_컨테이너가_쓰는_이름이_내보내진_것(source: str, names: list[str]):
|
||||
"""sub2·sub3 가 이름을 바꾸면 여기서 먼저 걸림."""
|
||||
body = _text(M02 / source)
|
||||
panel = _text(PANEL)
|
||||
for name in names:
|
||||
assert re.search(rf"export (const|function|interface) {name}\b", body), f"{source}:{name}"
|
||||
assert name in panel, name
|
||||
|
||||
|
||||
def test_집계표만_컨테이너를_붙임():
|
||||
"""다른 표 양식에는 안 붙음 — 시스템 층 · 이름이 집계표일 때만."""
|
||||
body = _text(MAIN)
|
||||
assert 'at.layer === "system" && at.name === STRUCTURE_TABLE ? buildPanel() : null' in body
|
||||
# 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로
|
||||
assert '{ ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] }' in body
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_열_머리_글_잇기():
|
||||
"""`columnHead` — 빈 층은 건너뛰고 ` · ` 로 이음 · 다 비면 열 id."""
|
||||
found = re.search(r"^export const columnHead[\s\S]*?;$", _text(PANEL), re.M)
|
||||
assert found, "columnHead 를 못 찾음"
|
||||
# 부품 파일은 화면 모듈을 부르므로 통째로 못 싣음 — 그 함수만 떼어 Node 가 타입을 벗겨 돌림
|
||||
script = (
|
||||
"interface StructureColumn { id: string; 머리: (string | null)[] }\n"
|
||||
+ found.group(0).replace("export ", "")
|
||||
+ "\nconsole.log(JSON.stringify(process.argv.slice(1)"
|
||||
+ ".map((a) => columnHead(JSON.parse(a)))));"
|
||||
)
|
||||
cases = [
|
||||
{"id": "pv_l", "머리": ["콘크리트포장", "T=(두께별)", "L"]},
|
||||
{"id": "no", "머리": ["NO", None, None]},
|
||||
{"id": "etc", "머리": [None, " ", None]},
|
||||
]
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script,
|
||||
*[json.dumps(c, ensure_ascii=False) for c in cases]],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == ["콘크리트포장 · T=(두께별) · L", "NO", "etc"]
|
||||
@@ -0,0 +1,64 @@
|
||||
"""표 도면 줄 · 칸 고르기 신호 — `ui_template_sheet_ops.ts` 를 Node 로 바로 돌려 봄.
|
||||
|
||||
도면 줄 `s:drawing` = master 만 · 일위대가 줄 바로 아래(키보드 차례) · project 에는 없음.
|
||||
`selectionOf` = 격자가 `onSelect` 로 넘기는 모양({row, col, colId} · 모르는 열은 col -1 · colId null).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OPS = ROOT / "ui_template" / "sheet" / "ui_template_sheet_ops.ts"
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
SCRIPT = """
|
||||
const { navRows, selectionOf } = await import(process.argv[1]);
|
||||
const doc = JSON.parse(process.argv[2]);
|
||||
console.log(JSON.stringify({
|
||||
master: navRows(doc, "master"),
|
||||
project: navRows(doc, "project"),
|
||||
pick: [selectionOf(doc, "s:drawing", "c2"), selectionOf(doc, "d:r1", "c1"), selectionOf(doc, "s:unit", "zz")],
|
||||
}));
|
||||
"""
|
||||
|
||||
DOC = {
|
||||
"양식": "시험",
|
||||
"종류": "표",
|
||||
"판": 1,
|
||||
"열": [{"id": "c1", "머리": ["가"]}, {"id": "c2", "머리": ["나"]}],
|
||||
"줄": [{"id": "r1", "값": {}}],
|
||||
"합계줄": [{"id": "t1", "이름": "계", "식": "SUM"}],
|
||||
}
|
||||
|
||||
|
||||
def _run() -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e",
|
||||
SCRIPT, OPS.as_uri(), json.dumps(DOC, ensure_ascii=False)],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_drawing_row_under_price_master_only():
|
||||
got = _run()
|
||||
assert got["master"] == [
|
||||
"s:unit", "s:formula", "s:desc", "s:price", "s:drawing", "d:r1", "t:t1",
|
||||
] # fmt: skip
|
||||
assert got["project"] == ["s:unit", "d:r1", "t:t1"]
|
||||
|
||||
|
||||
def test_selection_shape():
|
||||
assert _run()["pick"] == [
|
||||
{"row": "s:drawing", "col": 1, "colId": "c2"},
|
||||
{"row": "d:r1", "col": 0, "colId": "c1"},
|
||||
{"row": "s:unit", "col": -1, "colId": None},
|
||||
]
|
||||
@@ -1,8 +1,9 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet.ts
|
||||
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange})` → `{getDoc, setDoc, recalc, destroy}`.
|
||||
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, onSelect})` → `{getDoc, setDoc, recalc, destroy}`.
|
||||
*
|
||||
* master = 시스템 관리자 양식 고치기 — 머리 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 · 손 열.
|
||||
* 도면 줄(읽기만) = `drawingLabel` 글 · 칸을 고르면 `onSelect`.
|
||||
* project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽.
|
||||
* 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음.
|
||||
* 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
|
||||
@@ -17,6 +18,10 @@ import {
|
||||
canDeleteRow,
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
KEY_DRAWING,
|
||||
KEY_UNIT,
|
||||
navRows,
|
||||
selectionOf,
|
||||
type SheetMode,
|
||||
setCell,
|
||||
setColumnWidth,
|
||||
@@ -25,22 +30,25 @@ import {
|
||||
import { headDepth } from "./ui_template_sheet_header";
|
||||
import { recalcSheet } from "./ui_template_sheet_recalc";
|
||||
import {
|
||||
KEY_UNIT,
|
||||
drawingText,
|
||||
keyEditable,
|
||||
navRows,
|
||||
type RenderState,
|
||||
renderSheet,
|
||||
} from "./ui_template_sheet_render";
|
||||
import { st } from "./ui_template_sheet_text";
|
||||
import type { SheetColumn, SheetDoc, SheetResult } from "./ui_template_sheet_types";
|
||||
import type { SheetColumn, SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_types";
|
||||
import "./ui_template_sheet.css";
|
||||
|
||||
export type { SheetMode } from "./ui_template_sheet_ops";
|
||||
export type { SheetDoc, SheetResult } from "./ui_template_sheet_types";
|
||||
export type { SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_types";
|
||||
|
||||
export interface SheetOptions {
|
||||
mode: SheetMode;
|
||||
onChange?: (doc: SheetDoc) => void;
|
||||
/** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */
|
||||
drawingLabel?: (colId: string) => string;
|
||||
/** 칸을 고름 — 누르기 · 방향키 · Tab. */
|
||||
onSelect?: (sel: SheetSelection) => void;
|
||||
}
|
||||
|
||||
export interface SheetHandle {
|
||||
@@ -58,6 +66,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
mode: opts.mode,
|
||||
result: { 계산: {}, 합계: {}, 오류: [] },
|
||||
sel: null,
|
||||
drawingLabel: opts.drawingLabel,
|
||||
};
|
||||
const scroll = el("div", { className: "ui-sheet__scroll" });
|
||||
const toolbar = el("div", { className: "ui-sheet__toolbar" });
|
||||
@@ -151,6 +160,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
for (const cell of cells) cell.classList.add("is-selected");
|
||||
if (reveal) cells[0]?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
syncToolbar();
|
||||
opts.onSelect?.(selectionOf(state.doc, r, c));
|
||||
};
|
||||
const move = (dr: number, dc: number): void => {
|
||||
const keys = navRows(state.doc, state.mode);
|
||||
@@ -171,6 +181,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
if (key === "s:formula") return col.식 ?? "";
|
||||
if (key === "s:desc") return col.설명 ?? "";
|
||||
if (key === "s:price") return col.일위대가 ?? "";
|
||||
if (key === KEY_DRAWING) return drawingText(state, col.id);
|
||||
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
|
||||
const formula = row?.식?.[col.id] ?? col.식;
|
||||
if (formula) return `=${formula}`;
|
||||
@@ -178,6 +189,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
return raw === null || raw === undefined ? "" : String(raw);
|
||||
};
|
||||
const write = (key: string, col: SheetColumn, text: string): void => {
|
||||
if (key === KEY_DRAWING) return; // 읽기만
|
||||
const value = text.trim();
|
||||
if (key === KEY_UNIT) col.단위 = value || null;
|
||||
else if (key === "s:formula") {
|
||||
|
||||
@@ -5,13 +5,41 @@
|
||||
* ⚠ 타입 말고는 import 하지 않음 — 시험이 Node 로 이 파일을 바로 돌림(`test_sheet_pages.py`).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SheetCell, SheetColumn, SheetDoc, SheetRow } from "./ui_template_sheet_types";
|
||||
import type {
|
||||
SheetCell,
|
||||
SheetColumn,
|
||||
SheetDoc,
|
||||
SheetRow,
|
||||
SheetSelection,
|
||||
} from "./ui_template_sheet_types";
|
||||
|
||||
export type SheetMode = "master" | "project";
|
||||
|
||||
export const ROW_FIXED_ALL = "전구간";
|
||||
const PAGE_ROWS = 50;
|
||||
|
||||
export const KEY_UNIT = "s:unit";
|
||||
export const KEY_DRAWING = "s:drawing";
|
||||
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 줄은 읽기만. */
|
||||
export const MASTER_ROWS = ["s:formula", "s:desc", "s:price", KEY_DRAWING] as const;
|
||||
export type MasterRowKey = (typeof MASTER_ROWS)[number];
|
||||
|
||||
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
|
||||
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
|
||||
const special = mode === "master" ? [KEY_UNIT, ...MASTER_ROWS] : [KEY_UNIT];
|
||||
return [
|
||||
...special,
|
||||
...orderedRows(doc).map((r) => `d:${r.id}`),
|
||||
...(doc.합계줄 ?? []).map((t) => `t:${t.id}`),
|
||||
];
|
||||
}
|
||||
|
||||
/** 고른 칸 → 페이지에 알릴 모양. */
|
||||
export function selectionOf(doc: SheetDoc, row: string, colId: string): SheetSelection {
|
||||
const col = doc.열.findIndex((c) => c.id === colId);
|
||||
return { row, col, colId: col >= 0 ? colId : null };
|
||||
}
|
||||
|
||||
/** 겹치지 않는 새 id — `c1` · `c2` … */
|
||||
function freshId(prefix: string, used: Iterable<string>): string {
|
||||
const taken = new Set(used);
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* 표 그리기 — 문서 + 풀이 결과 → 쪽마다 `<table>`(머리 층 · 단위 줄 · 본문 · 합계 줄).
|
||||
*
|
||||
* 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음.
|
||||
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price`.
|
||||
* master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
|
||||
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price|drawing`.
|
||||
* master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 · 도면 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
cellEditable,
|
||||
isBoundColumn,
|
||||
isCalcColumn,
|
||||
orderedRows,
|
||||
KEY_DRAWING,
|
||||
KEY_UNIT,
|
||||
MASTER_ROWS,
|
||||
type MasterRowKey,
|
||||
pagePlan,
|
||||
type SheetMode,
|
||||
} from "./ui_template_sheet_ops";
|
||||
@@ -22,8 +25,6 @@ import { groupDigits, isNotice, totalFormula } from "./ui_template_sheet_recalc"
|
||||
import { st } from "./ui_template_sheet_text";
|
||||
import type { SheetColumn, SheetDoc, SheetResult, SheetRow } from "./ui_template_sheet_types";
|
||||
|
||||
export const KEY_UNIT = "s:unit";
|
||||
const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const;
|
||||
const GUTTER_MIN = 64;
|
||||
const GUTTER_MAX = 140;
|
||||
const DEFAULT_WIDTH = { 수: 72, 글: 96 };
|
||||
@@ -33,8 +34,14 @@ export interface RenderState {
|
||||
mode: SheetMode;
|
||||
result: SheetResult;
|
||||
sel: { r: string; c: string } | null;
|
||||
/** 도면 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
|
||||
drawingLabel?: (colId: string) => string;
|
||||
}
|
||||
|
||||
/** 도면 줄 칸 글. */
|
||||
export const drawingText = (state: RenderState, colId: string): string =>
|
||||
state.drawingLabel?.(colId)?.trim() || st("Drawing_None");
|
||||
|
||||
/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
|
||||
const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>): number =>
|
||||
Math.max(
|
||||
@@ -48,18 +55,9 @@ function headFont(): string {
|
||||
return `600 ${rem * 0.82}px ${getComputedStyle(document.body).fontFamily}`;
|
||||
}
|
||||
|
||||
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
|
||||
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
|
||||
const special = mode === "master" ? [KEY_UNIT, ...MASTER_ROWS] : [KEY_UNIT];
|
||||
return [
|
||||
...special,
|
||||
...orderedRows(doc).map((r) => `d:${r.id}`),
|
||||
...(doc.합계줄 ?? []).map((t) => `t:${t.id}`),
|
||||
];
|
||||
}
|
||||
|
||||
/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로). */
|
||||
/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로) · 도면 줄은 읽기만. */
|
||||
export function keyEditable(state: RenderState, key: string, col: SheetColumn): boolean {
|
||||
if (key === KEY_DRAWING) return false;
|
||||
if (key.startsWith("s:")) return state.mode === "master";
|
||||
if (key.startsWith("t:")) return false;
|
||||
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
|
||||
@@ -88,7 +86,7 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
const names = columnNames(cols);
|
||||
const font = headFont();
|
||||
const mins = headMinWidths(cols, depth, font);
|
||||
// 줄 머리 칸 폭 — 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 합계 이름이 안 잘리게
|
||||
// 줄 머리 칸 폭 — 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 도면 · 합계 이름이 안 잘리게
|
||||
const GUTTER = Math.min(
|
||||
GUTTER_MAX,
|
||||
Math.max(
|
||||
@@ -99,6 +97,7 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
st("Row_Formula"),
|
||||
st("Row_Desc"),
|
||||
st("Row_Unit_Price"),
|
||||
st("Row_Drawing"),
|
||||
...(doc.합계줄 ?? []).map((t) => t.이름),
|
||||
].map((label) => Math.ceil(textWidth(label, font)) + 16),
|
||||
),
|
||||
@@ -159,10 +158,15 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
return tr;
|
||||
};
|
||||
|
||||
const masterRow = (key: (typeof MASTER_ROWS)[number]): HTMLElement => {
|
||||
const label = { "s:formula": "Row_Formula", "s:desc": "Row_Desc", "s:price": "Row_Unit_Price" }[
|
||||
key
|
||||
] as "Row_Formula" | "Row_Desc" | "Row_Unit_Price";
|
||||
const masterRow = (key: MasterRowKey): HTMLElement => {
|
||||
const label = (
|
||||
{
|
||||
"s:formula": "Row_Formula",
|
||||
"s:desc": "Row_Desc",
|
||||
"s:price": "Row_Unit_Price",
|
||||
"s:drawing": "Row_Drawing",
|
||||
} as const
|
||||
)[key];
|
||||
const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] });
|
||||
cols.forEach((col, j) => {
|
||||
const text =
|
||||
@@ -170,7 +174,9 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
? formulaLabel(col.식 ?? "", names, col.id)
|
||||
: key === "s:desc"
|
||||
? ""
|
||||
: (col.일위대가 ?? "");
|
||||
: key === KEY_DRAWING
|
||||
? drawingText(state, col.id)
|
||||
: (col.일위대가 ?? "");
|
||||
const td = bodyCell(key, col, j, text);
|
||||
td.classList.remove("is-num");
|
||||
if (key === "s:formula" && col.식) {
|
||||
|
||||
@@ -16,6 +16,8 @@ const TEXT = {
|
||||
Row_Formula: ["식", "Formula"],
|
||||
Row_Desc: ["들어갈 것", "Source"],
|
||||
Row_Unit_Price: ["일위대가", "Unit price"],
|
||||
Row_Drawing: ["도면", "Drawing"],
|
||||
Drawing_None: ["없음", "None"],
|
||||
Kind_Bound: ["설계값", "Design"],
|
||||
Kind_Calc: ["계산", "Calc"],
|
||||
Kind_Hand: ["손 입력", "Manual"],
|
||||
|
||||
@@ -78,6 +78,14 @@ export interface SheetDoc {
|
||||
보기?: SheetView;
|
||||
}
|
||||
|
||||
/** 고른 칸 — `row` = 줄 열쇠(`d:<줄id>` · `t:<합계id>` · `s:unit|formula|desc|price|drawing`) ·
|
||||
* `col` = 열 차례(0부터 · 없으면 -1) · `colId` = 열 id(없으면 null). */
|
||||
export interface SheetSelection {
|
||||
row: string;
|
||||
col: number;
|
||||
colId: string | null;
|
||||
}
|
||||
|
||||
export interface SheetError {
|
||||
줄: string;
|
||||
열: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ export const ui_locales_m2 = {
|
||||
M02_Title: ["마스터 템플릿", "Master Templates"],
|
||||
M02_KindTable: ["표 양식", "Table templates"],
|
||||
M02_KindDrawing: ["도면 양식", "Drawing templates"],
|
||||
M02_KindStructure: ["구조물 도면", "Structure drawings"],
|
||||
M02_NoTemplates: ["양식 없음", "None"],
|
||||
M02_New: ["새로", "New"],
|
||||
M02_Copy: ["본떠 만들기", "Copy as new"],
|
||||
|
||||
Reference in New Issue
Block a user