Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1
충돌 셋(UI_Side · UI_Style · locale_m2)은 양쪽을 모두 살려 풂 — 옵션 보이기 · 항목 고르기 문구 · 층 화면 문구를 한 벌로, 도면 양식 줄은 시스템 층에서만.
This commit is contained in:
+3
-1
@@ -52,8 +52,10 @@ function bootstrap(): void {
|
||||
|
||||
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
|
||||
let shown = "";
|
||||
let shownHash = "";
|
||||
const renderAll = (): void => {
|
||||
shown = currentRoute();
|
||||
shownHash = window.location.hash;
|
||||
const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute());
|
||||
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
|
||||
void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더
|
||||
@@ -68,7 +70,7 @@ function bootstrap(): void {
|
||||
setLeaveGuard(null);
|
||||
renderAll();
|
||||
} else {
|
||||
history.replaceState(null, "", `#/${shown}`); // 취소 — 머문 채 주소만 되돌림
|
||||
history.replaceState(null, "", shownHash); // 취소 — 머문 채 주소만 되돌림
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+10
-5
@@ -78,15 +78,20 @@ export async function isAuthenticated(): Promise<boolean> {
|
||||
return fetchSession();
|
||||
}
|
||||
|
||||
/** 현재 해시에서 라우트 경로 추출 (#/a01-home → a01-home) */
|
||||
/** 현재 해시에서 라우트 경로 추출 (#/a01-home → a01-home · `?이름=값` 뒤는 뗌) */
|
||||
export function currentRoute(): RoutePath {
|
||||
const hash = window.location.hash.replace(/^#\/?/, "").trim();
|
||||
const hash = window.location.hash.replace(/^#\/?/, "").split("?")[0].trim();
|
||||
return (hash || DEFAULT_ROUTE) as RoutePath;
|
||||
}
|
||||
|
||||
/** 지정 라우트로 이동 (해시 변경 → hashchange 이벤트로 렌더 트리거) */
|
||||
export function navigateTo(route: RoutePath): void {
|
||||
const target = `#/${route}`;
|
||||
/** 현재 해시의 `?이름=값` 인자 (#/m02-master-template?layer=personal → layer=personal) */
|
||||
export function currentQuery(): URLSearchParams {
|
||||
return new URLSearchParams(window.location.hash.split("?")[1] ?? "");
|
||||
}
|
||||
|
||||
/** 지정 라우트로 이동 (해시 변경 → hashchange 이벤트로 렌더 트리거) · `query` = `?이름=값` 인자 */
|
||||
export function navigateTo(route: RoutePath, query?: Record<string, string>): void {
|
||||
const target = `#/${route}${query ? `?${new URLSearchParams(query)}` : ""}`;
|
||||
if (window.location.hash === target) {
|
||||
// 동일 해시면 hashchange가 안 뜨므로 수동 트리거
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
|
||||
@@ -148,6 +148,23 @@ function buildSystemSettingsPanel(): HTMLElement {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 구조물 관리 카드 — 개인용은 모두 · 회사용은 회사 관리자와 시스템 관리자(21-0) */
|
||||
function buildStructurePanel(state: DashboardState): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__actions";
|
||||
const go = (layer: "company" | "personal", label: string): HTMLElement =>
|
||||
createButton({
|
||||
label,
|
||||
variant: "ghost",
|
||||
onClick: () => navigateTo(ROUTES.M02_MASTER_TEMPLATE, { layer }),
|
||||
});
|
||||
if (state.user.role === "ADMIN" || state.user.role === "SYSTEM_ADMIN") {
|
||||
wrap.append(go("company", L("B01_Dashboard_StructureCompany")));
|
||||
}
|
||||
wrap.append(go("personal", L("B01_Dashboard_StructurePersonal")));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildPage(state: DashboardState): HTMLElement {
|
||||
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
|
||||
const page = document.createElement("div");
|
||||
@@ -169,6 +186,7 @@ function buildPage(state: DashboardState): HTMLElement {
|
||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||
]),
|
||||
buildTempUploadSection(),
|
||||
section(L("B01_Dashboard_StructureManage"), buildStructurePanel(state)),
|
||||
section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [
|
||||
createButton({ label: "+", onClick: () => openAddMemberModal() }),
|
||||
]),
|
||||
@@ -183,6 +201,7 @@ function buildPage(state: DashboardState): HTMLElement {
|
||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||
]),
|
||||
buildTempUploadSection(),
|
||||
section(L("B01_Dashboard_StructureManage"), buildStructurePanel(state)),
|
||||
section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [
|
||||
createButton({ label: "+", onClick: () => openAddMemberModal() }),
|
||||
]),
|
||||
@@ -197,6 +216,7 @@ function buildPage(state: DashboardState): HTMLElement {
|
||||
// 임시 보관함 — 프로젝트를 만들기 전에 자료를 올려 두는 곳이라 프로젝트 목록
|
||||
// 바로 아래에 둔다(2026-08-08 사용자 지시).
|
||||
buildTempUploadSection(),
|
||||
section(L("B01_Dashboard_StructureManage"), buildStructurePanel(state)),
|
||||
section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,15 @@ function reason(detail: unknown, status: number): string {
|
||||
return lines.filter(Boolean).join(" · ") || `HTTP ${status}`;
|
||||
}
|
||||
|
||||
/** 지금 화면의 층 — 시스템 = 옛 길 그대로 · 회사 · 개인 = `/layers/{층}` 아래(구조물집계표 · 조합 길 모두) */
|
||||
let activeLayer: Layer = "system";
|
||||
export const setActiveLayer = (layer: Layer): void => {
|
||||
activeLayer = layer;
|
||||
};
|
||||
export const getActiveLayer = (): Layer => activeLayer;
|
||||
const scoped = <T>(path: string, init: RequestInit = {}): Promise<T> =>
|
||||
call<T>(`${activeLayer === "system" ? "" : `/layers/${activeLayer}`}${path}`, init);
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : "");
|
||||
const head = (layer: Layer): string =>
|
||||
@@ -122,7 +131,7 @@ export const listStructures = async (): Promise<TemplateInfo[]> =>
|
||||
|
||||
/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 면적산출 · 재료산출 통합문서) · 이미 있으면 StaleError */
|
||||
export const createStructure = (column: string): Promise<TemplateDoc> =>
|
||||
call(`/structures/${enc(column)}`, { method: "POST" });
|
||||
scoped(`/structures/${enc(column)}`, { method: "POST" });
|
||||
|
||||
/** 한 열의 번호 — 도번 `SD` · 면적번 `SA` · 재료번 `SM` + 같은 8자리
|
||||
* [종류][분류1][분류2][분류3](없는 쪽은 빈 글) */
|
||||
@@ -134,7 +143,7 @@ export interface StructureNumbers {
|
||||
|
||||
/** `{열 id: {도번, 면적번, 재료번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
|
||||
export const fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>> =>
|
||||
call("/structures/numbers");
|
||||
scoped("/structures/numbers");
|
||||
|
||||
/** 구조물집계표조합 한 줄 — 구조물 열 하나(키 `SS` + 6자리 · 한 번 준 키는 안 바뀜) */
|
||||
export interface StructureCombo extends StructureNumbers {
|
||||
@@ -161,7 +170,7 @@ export interface StructureCombos {
|
||||
}
|
||||
|
||||
/** 조합 표 — 서버가 집계표 [저장] 때 맞춤(읽기만) */
|
||||
export const fetchStructureCombos = (): Promise<StructureCombos> => call("/structure-combos");
|
||||
export const fetchStructureCombos = (): Promise<StructureCombos> => scoped("/structure-combos");
|
||||
|
||||
/** 조합 갈라 내기 결과 — `새로만듦` false = 같은 담은 로직의 조합이 이미 있어 그 키를 가리킴 */
|
||||
export interface BranchedCombo {
|
||||
@@ -182,14 +191,14 @@ export const branchCombo = (
|
||||
판?: string;
|
||||
},
|
||||
): Promise<BranchedCombo> =>
|
||||
call(`/structures/${enc(column)}/combo`, { method: "POST", body: JSON.stringify(body) });
|
||||
scoped(`/structures/${enc(column)}/combo`, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
/** 있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 · 자재조합 키만 바꿈(조합은 그대로 · `새로만듦` false) */
|
||||
export const setColumnCombo = (
|
||||
column: string,
|
||||
body: { 종류: "일위대가" | "자재조합"; 키: string; 판?: string },
|
||||
): Promise<BranchedCombo> =>
|
||||
call(`/structures/${enc(column)}/use-combo`, { method: "POST", body: JSON.stringify(body) });
|
||||
scoped(`/structures/${enc(column)}/use-combo`, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
/** 자재 조합(키 `UM`) 담은 자재 한 줄 */
|
||||
export interface MaterialItem {
|
||||
@@ -210,7 +219,7 @@ export interface MaterialCombo {
|
||||
}
|
||||
|
||||
export const fetchMaterialCombo = (key: string): Promise<MaterialCombo> =>
|
||||
call(`/material-combos/${enc(key)}`);
|
||||
scoped(`/material-combos/${enc(key)}`);
|
||||
|
||||
/** 이 열의 자재 조합을 고친 담은 자재로 갈라 냄 — 규칙은 `branchCombo` 와 같음 ·
|
||||
* `본뜬키` 빈 글 = 이 열의 첫 자재 조합 */
|
||||
@@ -218,7 +227,7 @@ export const branchMaterialCombo = (
|
||||
column: string,
|
||||
body: { 본뜬키?: string; 담은자재: MaterialItem[]; 이름: string; 판?: string },
|
||||
): Promise<BranchedCombo> =>
|
||||
call(`/structures/${enc(column)}/material-combo`, {
|
||||
scoped(`/structures/${enc(column)}/material-combo`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
@@ -229,14 +238,14 @@ export const listMaterialCombos = (): Promise<{
|
||||
판: string;
|
||||
줄: MaterialCombo[];
|
||||
쓰는곳: Record<string, string[]>;
|
||||
}> => call("/material-combos");
|
||||
}> => scoped("/material-combos");
|
||||
|
||||
/** 새로 · 본떠 만들기(`본뜬키`) — 키는 서버가 표 「다음」 번호로 줌 */
|
||||
export const createMaterialCombo = (
|
||||
판: string,
|
||||
줄: Omit<MaterialCombo, "키">,
|
||||
): Promise<{ 키: string; 판: string; 줄: MaterialCombo }> =>
|
||||
call("/material-combos", { method: "POST", body: JSON.stringify({ 판, 줄 }) });
|
||||
scoped("/material-combos", { method: "POST", body: JSON.stringify({ 판, 줄 }) });
|
||||
|
||||
/** 고치기 — 이름 · 비고 · 담은자재(본뜬키 · 소유는 서버가 있던 것으로) */
|
||||
export const editMaterialCombo = (
|
||||
@@ -244,11 +253,11 @@ export const editMaterialCombo = (
|
||||
판: string,
|
||||
줄: MaterialCombo,
|
||||
): Promise<{ 키: string; 판: string; 줄: MaterialCombo }> =>
|
||||
call(`/material-combos/${enc(키)}`, { method: "PUT", body: JSON.stringify({ 판, 줄 }) });
|
||||
scoped(`/material-combos/${enc(키)}`, { method: "PUT", body: JSON.stringify({ 판, 줄 }) });
|
||||
|
||||
/** 지우기 — 구조물 열이 쓰면 서버가 막고 그 열 이름들을 까닭으로 줌 */
|
||||
export const deleteMaterialCombo = (키: string, 판: string): Promise<unknown> =>
|
||||
call(`/material-combos/${enc(키)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
scoped(`/material-combos/${enc(키)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
|
||||
/* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */
|
||||
export interface OptionKind {
|
||||
@@ -275,19 +284,31 @@ export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]):
|
||||
|
||||
/** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */
|
||||
export const copyStructure = async (column: string, from: string): Promise<void> => {
|
||||
await call(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
|
||||
await scoped(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
|
||||
};
|
||||
|
||||
/** 다른 열(`from`) 면적산출 내용(알약 연결 포함)을 받는 열(`column`)로 가져옴 — 면적번 · 열은 그대로 */
|
||||
export const copyArea = async (column: string, from: string): Promise<void> => {
|
||||
await call(`/area/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
|
||||
await scoped(`/area/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
|
||||
};
|
||||
|
||||
/** 다른 열(`from`) 재료산출 내용(알약 연결 포함)을 받는 열(`column`)로 가져옴 — 재료번 · 열은 그대로 */
|
||||
export const copyMaterial = async (column: string, from: string): Promise<void> => {
|
||||
await call(`/material/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
|
||||
await scoped(`/material/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
|
||||
};
|
||||
|
||||
/** 회사 · 개인 층의 집계표 항목(열 하나 + 도면 · 면적 · 재료 파일 + 자재조합 줄)을 내 개인 층으로 복사 —
|
||||
* 새 SS 키로 만들고 `본뜬` 표시 · `fromOwner` = 회사 층 회사 · 개인 층 「회사/사용자」 */
|
||||
export const copyItemToPersonal = (body: {
|
||||
fromLayer: Layer;
|
||||
fromOwner: string | null;
|
||||
key: string;
|
||||
}): Promise<{ key: string }> =>
|
||||
call("/layers/personal/items/copy", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ from_layer: body.fromLayer, from_owner: body.fromOwner, key: body.key }),
|
||||
});
|
||||
|
||||
/* --- 프로젝트 층 단추 다섯 --- */
|
||||
const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`;
|
||||
const post = (path: string, body: object = {}): Promise<unknown> =>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 서버 까닭 글. 갈라 내기 길(`branchCombo` · `branchMaterialCombo` · `fetchMaterialCombo`)은 `M02_MasterTemplete_Api_Fetch.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readTemplate } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { getActiveLayer, readTemplate } from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
/** 구조물집계표 이름 — `M02_MasterTemplete_Structure.STRUCTURE_TABLE` 과 같은 글 */
|
||||
const STRUCTURE_TABLE = "구조물집계표";
|
||||
@@ -23,7 +23,9 @@ export const reasonOf = (error: unknown): string => (error instanceof Error ? er
|
||||
/** 집계표(시스템 층)에서 이 열의 일위대가 · 자재조합 키와 같이 쓰는 다른 열 수 */
|
||||
export async function readColumnBase(column: string): Promise<ColumnBase> {
|
||||
type Col = { id: string; 일위대가?: string | null; 자재조합?: string | null };
|
||||
const table = await readTemplate("system", "table", STRUCTURE_TABLE, null).catch(() => null);
|
||||
const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch(
|
||||
() => null,
|
||||
);
|
||||
const cols = ((table?.문서 as { 열?: Col[] } | null)?.열 ?? []) as Col[];
|
||||
const mine = cols.find((c) => c.id === column);
|
||||
const same = (key: string | null | undefined, pick: (c: Col) => string | null | undefined) =>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Layer.ts
|
||||
* M02 화면의 층 — 주소 인자(`#/m02-master-template?layer=personal|company` · 없으면 system) ·
|
||||
* 층별 제목 · 고칠 권한(시스템 = SYSTEM_ADMIN · 회사 = role ADMIN 또는 is_master · 개인 = 본인) ·
|
||||
* 회사 없음 안내 · 회사용 읽기만일 때 [개인용으로 복사] 막대.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { currentQuery, navigateTo } from "../A00_Common/router";
|
||||
import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
export type ManageLayer = "system" | "company" | "personal";
|
||||
|
||||
/** 주소 인자에서 층 — 모르는 값 · 없음 = 시스템 */
|
||||
export function layerFromHash(): ManageLayer {
|
||||
const value = currentQuery().get("layer");
|
||||
return value === "company" || value === "personal" ? value : "system";
|
||||
}
|
||||
|
||||
export const titleOf = (layer: ManageLayer): string =>
|
||||
layer === "company"
|
||||
? L("M02_TitleCompany")
|
||||
: layer === "personal"
|
||||
? L("M02_TitlePersonal")
|
||||
: L("M02_Title");
|
||||
|
||||
export interface LayerAccess {
|
||||
/** 이 층을 고칠 수 있나 — 서버도 같은 규칙으로 막음 */
|
||||
canWrite: boolean;
|
||||
/** 회사 · 개인 층인데 회사가 없음 — 「회사부터 등록」 안내 */
|
||||
needCompany: boolean;
|
||||
}
|
||||
|
||||
export function accessFor(layer: ManageLayer, user: SessionUser | null): LayerAccess {
|
||||
if (layer === "system") return { canWrite: user?.role === "SYSTEM_ADMIN", needCompany: false };
|
||||
if (!user?.company_id) return { canWrite: false, needCompany: true };
|
||||
const companyAdmin = user.role === "ADMIN" || !!user.is_master;
|
||||
return { canWrite: layer === "personal" || companyAdmin, needCompany: false };
|
||||
}
|
||||
|
||||
/** 회사 없음 — 개인 · 회사 층은 회사 폴더 아래라 회사부터 */
|
||||
export function buildNeedCompany(): HTMLElement {
|
||||
const go = createButton({ label: L("M02_NeedCompanyGo"), variant: "filled" });
|
||||
go.addEventListener("click", () => navigateTo(ROUTES.B01_ACCOUNT));
|
||||
return el("div", {
|
||||
className: "m02-layer-need",
|
||||
children: [el("p", { text: L("M02_NeedCompany") }), go],
|
||||
});
|
||||
}
|
||||
|
||||
export interface ReadOnlyBar {
|
||||
root: HTMLElement;
|
||||
/** 집계표에서 고른 열 id — 없으면 null */
|
||||
setColumn: (column: string | null) => void;
|
||||
}
|
||||
|
||||
/** 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사](고른 열 하나를 내 개인 층으로) */
|
||||
export function buildReadOnlyBar(user: SessionUser | null): ReadOnlyBar {
|
||||
let column: string | null = null;
|
||||
const copy = createButton({ label: L("M02_CopyToPersonal"), variant: "filled", size: "sm" });
|
||||
copy.disabled = true;
|
||||
copy.addEventListener("click", async () => {
|
||||
if (!column) return showToast(L("M02_CopyPickFirst"), "error");
|
||||
copy.disabled = true;
|
||||
try {
|
||||
const key = (await fetchStructureCombos()).줄.find((row) => row.열 === column)?.키;
|
||||
if (!key) throw new Error(L("M02_CopyPickFirst"));
|
||||
await copyItemToPersonal({
|
||||
fromLayer: "company",
|
||||
fromOwner: user?.company_id == null ? null : String(user.company_id),
|
||||
key,
|
||||
});
|
||||
showToast(L("M02_CopyDone"), "success");
|
||||
} catch (error) {
|
||||
const why = error instanceof Error ? error.message : "";
|
||||
showToast(L("M02_CopyFailed").replace("{value}", why), "error");
|
||||
} finally {
|
||||
copy.disabled = !column;
|
||||
}
|
||||
});
|
||||
return {
|
||||
root: el("div", {
|
||||
className: "m02-layer-bar",
|
||||
children: [el("span", { text: L("M02_ReadOnlyCompany") }), copy],
|
||||
}),
|
||||
setColumn: (id) => {
|
||||
column = id;
|
||||
copy.disabled = !id;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
copyMaterial,
|
||||
copyStructure,
|
||||
fetchStructureNumbers,
|
||||
getActiveLayer,
|
||||
readTemplate,
|
||||
type StructureDoc,
|
||||
type StructureNumbers,
|
||||
@@ -208,7 +209,9 @@ export async function pillsFor(
|
||||
): Promise<{ links: LinkOptions; notice?: string }> {
|
||||
if (kind === "material") return { links: { 항목: [], 모드: "자재", 자재찾기: searchMaterials } };
|
||||
const none = (notice: string) => ({ links: { 항목: [], 모드: "입력" as const }, notice });
|
||||
const table = await readTemplate("system", "table", STRUCTURE_TABLE, null).catch(() => null);
|
||||
const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch(
|
||||
() => null,
|
||||
);
|
||||
const cols = (table?.문서 as { 열?: { id: string; 일위대가?: string | null }[] } | null)?.열;
|
||||
const combo = cols?.find((c) => c.id === column)?.일위대가;
|
||||
if (!combo) return none(TEXT.noPrice);
|
||||
@@ -376,7 +379,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, {
|
||||
readOnly: true,
|
||||
name: `preview:${column ?? ""}`,
|
||||
layer: "system",
|
||||
layer: getActiveLayer(),
|
||||
panelsCollapsed: true, // 특성 · 도면층 패널은 접힌 채 — › 를 누르면 펼침
|
||||
preview: true, // 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤
|
||||
originMarker: true, // 좌표 0,0 기준점 십자
|
||||
@@ -409,8 +412,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
c.host.replaceChildren();
|
||||
}
|
||||
const [drawing, ...books] = await Promise.allSettled([
|
||||
readTemplate("system", "structure", col.id, null),
|
||||
...sheets.map((c) => readTemplate("system", c.kind, col.id, null)),
|
||||
readTemplate(getActiveLayer(), "structure", col.id, null),
|
||||
...sheets.map((c) => readTemplate(getActiveLayer(), c.kind, col.id, null)),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc;
|
||||
|
||||
@@ -92,9 +92,15 @@ export interface MainHandle {
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
/** 이 층에서 고칠 수 있나 — 시스템은 관리자만 · 회사는 여기서 못 고침(공식으로 저장으로만) */
|
||||
/** 이 층에서 고칠 수 있나 — `isAdmin` = 이 화면 층의 고칠 권한(페이지가 층별로 정함) */
|
||||
export function canEdit(layer: Layer, isAdmin: boolean): boolean {
|
||||
return layer === "project" || layer === "personal" || (layer === "system" && isAdmin);
|
||||
return layer === "project" || isAdmin;
|
||||
}
|
||||
|
||||
/** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 */
|
||||
export interface MainExtras {
|
||||
top?: HTMLElement;
|
||||
onColumn?: (column: string | null) => void;
|
||||
}
|
||||
|
||||
/** `onOpened` — 무엇을 열든(좌측 · 카드 [도면 수정] · 다시 읽기) 연 바로 뒤 부름(좌측 고름 표시) */
|
||||
@@ -102,6 +108,7 @@ export function createMain(
|
||||
isAdmin: boolean,
|
||||
onSaved?: () => void,
|
||||
onOpened?: () => void,
|
||||
extras: MainExtras = {},
|
||||
): MainHandle {
|
||||
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
|
||||
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
|
||||
@@ -115,6 +122,7 @@ export function createMain(
|
||||
const root = el("div", {
|
||||
className: "m02-main",
|
||||
children: [
|
||||
...(extras.top ? [extras.top] : []),
|
||||
el("div", { className: "m02-main__head", children: [title, back, slot, reload, save] }),
|
||||
notice,
|
||||
host,
|
||||
@@ -131,7 +139,7 @@ export function createMain(
|
||||
let sheetHandle: SpreadsheetHandle | null = null;
|
||||
let matPanel: MaterialPanel | null = null;
|
||||
let matState: MaterialState | null = null;
|
||||
let cpanel: ComboPanel | null = null; // 면적산출 · 재료산출 오른쪽 조합 패널(시스템 층)
|
||||
let cpanel: ComboPanel | null = null; // 면적산출 · 재료산출 오른쪽 조합 패널(프로젝트 층 아님)
|
||||
let seq = 0;
|
||||
let dirty = false;
|
||||
|
||||
@@ -166,7 +174,7 @@ export function createMain(
|
||||
const bar = (): void => {
|
||||
reload.hidden = !sel;
|
||||
save.hidden = !sel || !canEdit(sel.layer, isAdmin);
|
||||
back.hidden = !sel || sel.layer !== "system" || !(sel.kind in NUMBER_FIELD);
|
||||
back.hidden = !sel || !(sel.kind in NUMBER_FIELD);
|
||||
};
|
||||
|
||||
const drop = (): void => {
|
||||
@@ -185,13 +193,13 @@ export function createMain(
|
||||
};
|
||||
|
||||
/** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 · 재료산출 카드 */
|
||||
function buildPanel(): StructurePanelHandle {
|
||||
function buildPanel(layer: Layer): StructurePanelHandle {
|
||||
const made = createStructurePanel({
|
||||
isAdmin,
|
||||
confirmLeave,
|
||||
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
|
||||
onEdit: (column, label, kind) => {
|
||||
void open({ layer: "system", projectId: null, kind, name: column, label });
|
||||
void open({ layer, projectId: null, kind, name: column, label });
|
||||
},
|
||||
});
|
||||
extra.replaceChildren(made.root);
|
||||
@@ -211,7 +219,7 @@ export function createMain(
|
||||
): Promise<void> {
|
||||
const mine = seq;
|
||||
try {
|
||||
const master = at.layer === "system" && kind === "material";
|
||||
const master = at.layer !== "project" && kind === "material";
|
||||
if (master && !matState) matState = await loadMaterialState(at.name);
|
||||
const [{ createSpreadsheet }, pills] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
@@ -244,7 +252,7 @@ export function createMain(
|
||||
);
|
||||
editor = handle;
|
||||
sheetHandle = handle;
|
||||
if (at.layer !== "system") return;
|
||||
if (at.layer === "project") return;
|
||||
// 오른쪽 조합 패널 — 한 번 만들고 시트를 다시 붙여도 그대로(늘 시트 뒤)
|
||||
cpanel ??= buildComboPanel({
|
||||
kind,
|
||||
@@ -333,9 +341,10 @@ export function createMain(
|
||||
"createSheet",
|
||||
);
|
||||
if (!create) return empty(L("M02_NoSheet"));
|
||||
// 구조물집계표(시스템 층)만 — 도면 · 면적산출 · 재료산출 줄에 번호 · 칸을 고르면 아래 컨테이너 ·
|
||||
// 구조물집계표(프로젝트 층 아님)만 — 도면 · 면적산출 · 재료산출 줄에 번호 · 칸을 고르면 아래 컨테이너 ·
|
||||
// 고칠 수 있으면 일위대가 · 도면 · 면적산출 · 재료산출 칸은 조회 후 선택
|
||||
const struct = at.layer === "system" && at.name === STRUCTURE_TABLE ? buildPanel() : null;
|
||||
const struct =
|
||||
at.layer !== "project" && at.name === STRUCTURE_TABLE ? buildPanel(at.layer) : null;
|
||||
if (struct) await struct.reload();
|
||||
panel = struct;
|
||||
// ⚠ sheet 는 바깥(다시 열기 등으로 drop() 이 null 로 되돌리는) 값 — 표 부품이
|
||||
@@ -351,7 +360,10 @@ export function createMain(
|
||||
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
|
||||
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
|
||||
materialLabel: (colId: string) => struct.numbers()[colId]?.재료번 ?? "",
|
||||
onSelect: (s: { colId: string | null }) => struct.show(s.colId, columns()),
|
||||
onSelect: (s: { colId: string | null }) => {
|
||||
struct.show(s.colId, columns());
|
||||
extras.onColumn?.(s.colId);
|
||||
},
|
||||
...(readOnly
|
||||
? {}
|
||||
: {
|
||||
@@ -488,7 +500,12 @@ export function createMain(
|
||||
back.addEventListener("click", async () => {
|
||||
const column = sel?.name;
|
||||
if (!column || !(await confirmLeave())) return;
|
||||
await open({ layer: "system", projectId: null, kind: "table", name: STRUCTURE_TABLE });
|
||||
await open({
|
||||
layer: sel?.layer ?? "system",
|
||||
projectId: null,
|
||||
kind: "table",
|
||||
name: STRUCTURE_TABLE,
|
||||
});
|
||||
if (sel?.kind === "table") sheet?.selectColumn(column); // 그 사이 다른 것을 열었으면 안 고름
|
||||
});
|
||||
reload.addEventListener("click", async () => {
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Page.ts
|
||||
* 마스터 템플릿 화면 — 좌측 도킹 패널(시스템 양식 목록) / 우측 편집 칸(표·도면 부품)
|
||||
* 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록) / 우측 편집 칸(표·도면 부품)
|
||||
*
|
||||
* 화면은 로그인만 확인 — 시스템 층 고치기는 시스템 관리자일 때만 켬(서버도 막음).
|
||||
* 층 = 주소 인자 `?layer=personal|company`(없으면 시스템) — 같은 화면 한 벌.
|
||||
* 화면은 로그인만 확인 — 고치기는 층별 권한일 때만 켬(서버도 막음 · `M02_MasterTemplete_Layer.ts`).
|
||||
* ========================================================================== */
|
||||
|
||||
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";
|
||||
import {
|
||||
accessFor,
|
||||
buildNeedCompany,
|
||||
buildReadOnlyBar,
|
||||
layerFromHash,
|
||||
titleOf,
|
||||
} from "./M02_MasterTemplete_Layer";
|
||||
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
|
||||
import { createMain, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildSide } from "./M02_MasterTemplete_UI_Side";
|
||||
@@ -17,20 +25,31 @@ import "./M02_MasterTemplete_UI_Style.css";
|
||||
|
||||
export async function renderM02MasterTemplate(root: HTMLElement): Promise<void> {
|
||||
const user = await fetchSessionUser().catch(() => null);
|
||||
const isAdmin = user?.role === "SYSTEM_ADMIN";
|
||||
const layer = layerFromHash();
|
||||
setActiveLayer(layer);
|
||||
const access = accessFor(layer, user);
|
||||
if (access.needCompany) {
|
||||
root.replaceChildren(buildNeedCompany());
|
||||
return;
|
||||
}
|
||||
const isAdmin = access.canWrite; // 이 층을 고칠 수 있나(하위 부품의 「관리자」 = 같은 뜻)
|
||||
// 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사]
|
||||
const readOnlyBar = layer === "company" && !isAdmin ? buildReadOnlyBar(user) : null;
|
||||
const main = createMain(
|
||||
isAdmin,
|
||||
() => void side.refresh(),
|
||||
() => side.paint(),
|
||||
{ top: readOnlyBar?.root, onColumn: (column) => readOnlyBar?.setColumn(column) },
|
||||
);
|
||||
// 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음
|
||||
const home: Selection = {
|
||||
layer: "system",
|
||||
layer,
|
||||
projectId: null,
|
||||
kind: "table",
|
||||
name: STRUCTURE_TABLE,
|
||||
};
|
||||
const side = buildSide({
|
||||
layer,
|
||||
isAdmin,
|
||||
onOpen: (sel) => void main.open(sel ?? home),
|
||||
getOpen: main.current,
|
||||
@@ -40,7 +59,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
const overlays = createWorkflowOverlays({
|
||||
title: L("M02_Title"),
|
||||
title: titleOf(layer),
|
||||
optionsContent: el("div", { className: "m02-master__side", children: [side.root] }),
|
||||
showProjectName: false,
|
||||
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
saveTemplate,
|
||||
StaleError,
|
||||
type Kind,
|
||||
type Layer,
|
||||
type StructureNumbers,
|
||||
type TemplateInfo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
@@ -60,6 +61,9 @@ const STUB_DRAWING = {
|
||||
};
|
||||
|
||||
export interface SideOptions {
|
||||
/** 이 화면의 층 — 도면 양식 목록은 시스템 층에만 있음 */
|
||||
layer: Layer;
|
||||
/** 이 층을 고칠 수 있나 */
|
||||
isAdmin: boolean;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
getOpen: () => Selection | null;
|
||||
@@ -88,7 +92,7 @@ const failed = (error: unknown): void =>
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
|
||||
export function buildSide(opt: SideOptions): SideHandle {
|
||||
const layer = "system" as const;
|
||||
const layer = opt.layer;
|
||||
let items: TemplateInfo[] = [];
|
||||
let columns: StructureColumn[] = [];
|
||||
let numbers: Record<string, StructureNumbers> = {};
|
||||
@@ -151,7 +155,9 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
children: [
|
||||
summary,
|
||||
section(options.title, options.body),
|
||||
section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
|
||||
...(layer === "system"
|
||||
? [section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"))]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
attachCollapsible(listHost);
|
||||
|
||||
@@ -193,3 +193,25 @@
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 층 화면 — 회사용 읽기만 안내 막대 · 회사 없음 안내 */
|
||||
.m02-layer-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-layer-need {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-24);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""M02 층 화면(`?layer=personal|company`) 붙는 자리 시험 — 화면 조작은 ORCA.
|
||||
|
||||
· 권한 규칙(`accessFor`)을 Node 로 바로 돌림 — 시스템 SYSTEM_ADMIN ·
|
||||
회사 ADMIN/is_master · 개인 본인 · 회사 없음
|
||||
· 주소 인자를 라우트에서 떼는지 · 대시보드 카드 역할별 단추 · 층 길 접두어
|
||||
"""
|
||||
|
||||
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"
|
||||
LAYER = M02 / "M02_MasterTemplete_Layer.ts"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_층별_고칠_권한():
|
||||
found = re.search(r"^export function accessFor[\s\S]*?^}", _text(LAYER), re.M)
|
||||
assert found, "accessFor 를 못 찾음"
|
||||
script = (
|
||||
'type ManageLayer = "system" | "company" | "personal";\n'
|
||||
"interface SessionUser { role?: string; company_id?: number | null; is_master?: boolean }\n"
|
||||
"interface LayerAccess { canWrite: boolean; needCompany: boolean }\n"
|
||||
+ found.group(0).replace("export ", "")
|
||||
+ "\nconst cases = JSON.parse(process.argv[1]);"
|
||||
+ "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1]))));"
|
||||
)
|
||||
sysadmin = {"role": "SYSTEM_ADMIN", "company_id": None}
|
||||
admin = {"role": "ADMIN", "company_id": 3}
|
||||
master = {"role": "USER", "company_id": 3, "is_master": True}
|
||||
user = {"role": "USER", "company_id": 3}
|
||||
orphan = {"role": "USER", "company_id": None}
|
||||
cases = [
|
||||
["system", sysadmin], ["system", admin], ["system", None],
|
||||
["company", admin], ["company", master], ["company", user], ["company", orphan],
|
||||
["personal", user], ["personal", admin], ["personal", orphan], ["personal", sysadmin],
|
||||
] # fmt: skip
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script, json.dumps(cases)],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
ok = {"canWrite": True, "needCompany": False}
|
||||
ro = {"canWrite": False, "needCompany": False}
|
||||
no_company = {"canWrite": False, "needCompany": True}
|
||||
assert json.loads(result.stdout) == [
|
||||
ok, ro, ro, # 시스템 = SYSTEM_ADMIN 만
|
||||
ok, ok, ro, no_company, # 회사 = ADMIN · is_master · 일반은 읽기만 · 회사 없음 안내
|
||||
ok, ok, no_company, no_company, # 개인 = 본인 · 회사 없으면 안내
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def test_주소_인자를_라우트에서_뗌():
|
||||
router = _text(ROOT / "A00_Common" / "router.ts")
|
||||
assert '.split("?")[0]' in router
|
||||
assert "export function currentQuery()" in router
|
||||
assert "query?: Record<string, string>" in router
|
||||
layer = _text(LAYER)
|
||||
assert 'value === "company" || value === "personal" ? value : "system"' in layer
|
||||
|
||||
|
||||
def test_대시보드_카드_역할별_단추():
|
||||
page = _text(ROOT / "B01_Dashboard" / "B01_Dashboard_UI_Page.ts")
|
||||
assert (
|
||||
page.count('section(L("B01_Dashboard_StructureManage"), buildStructurePanel(state))') == 3
|
||||
)
|
||||
body = page.split("function buildStructurePanel")[1].split("function buildPage")[0]
|
||||
# 개인용은 모두 · 회사용은 ADMIN · SYSTEM_ADMIN 만
|
||||
assert 'state.user.role === "ADMIN" || state.user.role === "SYSTEM_ADMIN"' in body
|
||||
assert body.index('go("company"') < body.index('go("personal"')
|
||||
assert "ROUTES.M02_MASTER_TEMPLATE, { layer }" in body
|
||||
config = _text(ROOT / "config" / "config_frontend.ts")
|
||||
assert re.search(r"PROTECTED_ROUTES[\s\S]*?ROUTES\.M02_MASTER_TEMPLATE,[\s\S]*?\];", config)
|
||||
|
||||
|
||||
def test_층_길_접두어와_고정_풀림():
|
||||
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
|
||||
assert 'activeLayer === "system" ? "" : `/layers/${activeLayer}`' in api
|
||||
for path in ("/structures/numbers", "/structure-combos", "/material-combos"):
|
||||
assert re.search(rf'scoped\(\s*[`"]{re.escape(path)}', api), path
|
||||
assert "/layers/personal/items/copy" in api
|
||||
for name in ("Structure", "Combo_Api"):
|
||||
assert '"system"' not in _text(M02 / f"M02_MasterTemplete_{name}.ts")
|
||||
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
|
||||
assert "const layer = opt.layer;" in side
|
||||
assert 'layer === "system"' in side # 도면 양식은 시스템 층에만
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert 'sel.layer !== "system"' not in main
|
||||
assert 'return layer === "project" || isAdmin;' in main
|
||||
|
||||
|
||||
def test_읽기만_회사용_개인용으로_복사_막대():
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert 'layer === "company" && !isAdmin ? buildReadOnlyBar(user)' in page
|
||||
assert "onColumn: (column) => readOnlyBar?.setColumn(column)" in page
|
||||
assert "access.needCompany" in page and "buildNeedCompany()" in page
|
||||
assert "titleOf(layer)" in page
|
||||
layer = _text(LAYER)
|
||||
assert 'fromLayer: "company"' in layer and "M02_CopyToPersonal" in layer
|
||||
@@ -81,9 +81,12 @@ def test_sub2_가_부르는_이름과_모양():
|
||||
|
||||
|
||||
def test_집계표만_컨테이너를_붙임():
|
||||
"""다른 표 양식에는 안 붙음 — 시스템 층 · 이름이 집계표일 때만."""
|
||||
"""다른 표 양식에는 안 붙음 — 프로젝트 층 아님 · 이름이 집계표일 때만."""
|
||||
body = _text(MAIN)
|
||||
assert 'at.layer === "system" && at.name === STRUCTURE_TABLE ? buildPanel() : null' in body
|
||||
assert re.search(
|
||||
r'at\.layer !== "project" && at\.name === STRUCTURE_TABLE\s*\? buildPanel\(at\.layer\)\s*: null',
|
||||
body,
|
||||
)
|
||||
# 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로
|
||||
assert '{ ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] }' in body
|
||||
|
||||
|
||||
@@ -79,4 +79,22 @@ export const ui_locales_m2 = {
|
||||
M02_PickerManage: ["관리 화면에서 고치기", "Edit in manager"],
|
||||
M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"],
|
||||
M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"],
|
||||
B01_Dashboard_StructureManage: ["구조물 관리", "Structure management"],
|
||||
B01_Dashboard_StructureCompany: ["회사용", "Company"],
|
||||
B01_Dashboard_StructurePersonal: ["개인용", "Personal"],
|
||||
M02_TitleCompany: ["구조물 관리(회사)", "Structure management (company)"],
|
||||
M02_TitlePersonal: ["구조물 관리(개인)", "Structure management (personal)"],
|
||||
M02_ReadOnlyCompany: [
|
||||
"회사용은 복사해서 씀 — 고치려면 고른 열을 개인용으로 복사",
|
||||
"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_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"],
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user