From b4100acc6de7e835ea83422a3d169d7c4a7b19d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 10:39:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EA=B4=80=EB=A6=AC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=E3=80=8C=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=E3=80=8D=20=E2=80=94=20=EB=82=98=EB=AC=B4=EA=B0=80=20?= =?UTF-8?q?=EC=A3=BC=20=C2=B7=20=ED=91=9C=EB=8A=94=20=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EB=B9=84=EA=B3=A0=20=C2=B7=20=EC=A0=84?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=C2=B7=20=ED=95=A9=EA=B3=84=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80(PLAN=2026-7a~e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면 이름 「구조물집계표」 · 「구조물 집계 양식」 → 「구조물 조합」(좌측 줄 · 상세 제목 · 안내글) · 파일 이름 · 양식 키 · 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 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- .../M02_MasterTemplete_ComboPanel.ts | 2 +- .../M02_MasterTemplete_Inputs.ts | 6 +- .../M02_MasterTemplete_Router.py | 2 +- .../M02_MasterTemplete_Router_Layers.py | 2 +- .../M02_MasterTemplete_Store.py | 8 +- .../M02_MasterTemplete_Structure.ts | 28 ++++- M02_MasterTemplete/M02_MasterTemplete_Tree.py | 14 +++ .../M02_MasterTemplete_TreePanel.ts | 3 +- .../M02_MasterTemplete_UI_Main.ts | 32 +++++- .../M02_MasterTemplete_UI_Page.ts | 30 +++-- .../M02_MasterTemplete_UI_Side.ts | 21 ++-- .../M02_MasterTemplete_UI_Style.css | 50 ++++++++ resources/tester/test_m02_combo_screen.py | 107 ++++++++++++++++++ resources/tester/test_m02_layer_screen.py | 3 +- resources/tester/test_m02_structure_panel.py | 2 +- resources/tester/test_m02_tree.py | 25 ++-- ui_template/sheet/ui_template_sheet.ts | 19 +++- ui_template/sheet/ui_template_sheet_render.ts | 7 +- ui_template/ui_template_locale_m2.ts | 6 +- 19 files changed, 306 insertions(+), 61 deletions(-) create mode 100644 resources/tester/test_m02_combo_screen.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts index f1bc2361..6311a601 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts @@ -38,7 +38,7 @@ const TEXT = { empty: "맞는 조합 없음", none: "조합 없음", use: "이 열에 쓰기", - useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물집계표에 바로 적힘 · 조합은 그대로", + useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물 조합에 바로 적힘 · 조합은 그대로", useOk: "바꿈", used: "이 열이 {value} 을 씀", loadFailed: "조합 목록을 못 받음 — {value}", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts index 77a3c59c..586a1378 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts @@ -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}", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router.py b/M02_MasterTemplete/M02_MasterTemplete_Router.py index 8a4ebfe6..e4759adb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router.py @@ -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}") diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 5743a60b..81a867aa 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -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} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index f1bd4860..ced3a7fc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -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 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index d7fc619b..8163f1f6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -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(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(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; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Tree.py b/M02_MasterTemplete/M02_MasterTemplete_Tree.py index 4bbdd933..32d16e1a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Tree.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Tree.py @@ -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]] = [] diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 80ece6bb..0d18ec3f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -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 { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 93af24d6..78b7e7da 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -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 }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 0870ed02..07afe8f7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -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 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 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 root.innerHTML = ""; root.append(layout); void main.open(home); + void tree.refresh(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 73b1a840..0fae8baf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -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; /** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */ showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; - /** 항목 나무(26-3a) — 항목 누름 · 고치기 전 저장 처리 · 고친 뒤 표 다시 읽기 */ - tree: Pick; } export interface SideHandle { @@ -81,8 +80,6 @@ export interface SideHandle { refresh: () => Promise; /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ 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 }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 90f5bc75..a26b107c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -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); + } +} diff --git a/resources/tester/test_m02_combo_screen.py b/resources/tester/test_m02_combo_screen.py new file mode 100644 index 00000000..b9ee90e0 --- /dev/null +++ b/resources/tester/test_m02_combo_screen.py @@ -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 + ) # 같은 이름 = 까닭 글 diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 6bd2f25f..0da4aa4d 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -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") diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index fd7e130f..1df1c34d 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -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" ) diff --git a/resources/tester/test_m02_tree.py b/resources/tester/test_m02_tree.py index 16f5e39d..90bce3ee 100644 --- a/resources/tester/test_m02_tree.py +++ b/resources/tester/test_m02_tree.py @@ -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 diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index c91e3704..7cab951e 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -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("th[data-col]"); const layer = target.closest("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 => { diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 0ae57807..90e4834c 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -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); } diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index e6db41f1..595678b6 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -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"],