diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 384a33212..3cc44bd1e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -8,8 +8,9 @@ import { API_BASE_URL } from "@config/config_frontend"; /** 층 이름 — 서버와 같은 글 */ export type Layer = "system" | "company" | "personal" | "project"; /** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure · - * 「상세 산출근거」 = basis(구조물집계표 열마다 한 통합문서) */ -export type Kind = "table" | "drawing" | "structure" | "basis"; + * 「면적산출」 = area · 「재료산출」 = material(둘 다 구조물집계표 열마다 한 통합문서) · + * @deprecated "basis" — 옛 이름(서버 길은 area 로 보냄) · sub2 가 새 이름으로 옮기면 지움 */ +export type Kind = "table" | "drawing" | "structure" | "area" | "material" | "basis"; export interface TemplateInfo { 종류: Kind; @@ -49,8 +50,10 @@ const enc = encodeURIComponent; const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : ""); const head = (layer: Layer): string => layer === "system" ? "/templates" : `/layers/${layer}/templates`; +/** 서버 길의 종류 — 옛 "basis" 는 "area" 로 */ +const kindPath = (kind: Kind): string => (kind === "basis" ? "area" : kind); const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string => - `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`; + `${head(layer)}/${kindPath(kind)}/${enc(name)}${layer === "system" ? "" : q(projectId)}`; /** 시스템 길은 배열 · 층 길은 `{층, 양식: [...]}` — 둘 다 목록으로 */ export const listTemplates = async ( @@ -82,7 +85,7 @@ export const saveTemplate = ( /** 시스템 층만 지움 */ export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> => - call(`/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); + call(`/templates/${kindPath(kind)}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); /** 개인·회사 층에서 지움 — 판이 맞을 때만(다르면 409) */ export const deleteLayerTemplate = ( @@ -91,12 +94,14 @@ export const deleteLayerTemplate = ( name: string, 판: string, ): Promise<{ ok: boolean }> => - call(`/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); + call(`/layers/${layer}/templates/${kindPath(kind)}/${enc(name)}?판=${enc(판)}`, { + method: "DELETE", + }); /* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) --- * 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */ -/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · 산출근거는 따로(`basis` · 같은 열 id) */ +/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · 면적산출 · 재료산출은 따로(같은 열 id) */ export interface StructureDoc { 양식: "구조물도면"; 종류: "structure"; @@ -110,30 +115,45 @@ export interface StructureDoc { export const listStructures = async (): Promise => (await listTemplates("system", null)).filter((t) => t.종류 === "structure"); -/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 산출근거 통합문서) · 이미 있으면 StaleError */ +/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 면적산출 · 재료산출 통합문서) · 이미 있으면 StaleError */ export const createStructure = (column: string): Promise => call(`/structures/${enc(column)}`, { method: "POST" }); -/** 한 열의 번호 — 도번 `SD` · 표번 `SC` + 같은 6자리 [종류][분류1][분류2](없는 쪽은 빈 글) */ +/** 한 열의 번호 — 도번 `SD` · 면적번 `SA` · 재료번 `SM` + 같은 8자리 + * [종류][분류1][분류2][분류3](없는 쪽은 빈 글) */ export interface StructureNumbers { 도번: string; - 표번: string; + 면적번: string; + 재료번: string; + /** @deprecated 옛 이름 — 면적번과 같은 값 · sub2 가 옮기면 지움 */ + 표번?: string; } -/** `{열 id: {도번, 표번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */ -export const fetchStructureNumbers = (): Promise> => - call("/structures/numbers"); +/** `{열 id: {도번, 면적번, 재료번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */ +export const fetchStructureNumbers = async (): Promise> => { + const got = await call>("/structures/numbers"); + for (const row of Object.values(got)) row.표번 = row.면적번; + return got; +}; /** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */ export const copyStructure = async (column: string, from: string): Promise => { await call(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); }; -/** 다른 열(`from`) 산출근거 내용을 받는 열(`column`)로 가져옴 — 표번 · 열은 받는 쪽 그대로 */ -export const copyBasis = async (column: string, from: string): Promise => { - await call(`/basis/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); +/** 다른 열(`from`) 면적산출 내용(알약 연결 포함)을 받는 열(`column`)로 가져옴 — 면적번 · 열은 그대로 */ +export const copyArea = async (column: string, from: string): Promise => { + await call(`/area/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); }; +/** 다른 열(`from`) 재료산출 내용(알약 연결 포함)을 받는 열(`column`)로 가져옴 — 재료번 · 열은 그대로 */ +export const copyMaterial = async (column: string, from: string): Promise => { + await call(`/material/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); +}; + +/** @deprecated 옛 이름 — copyArea · sub2 가 옮기면 지움 */ +export const copyBasis = copyArea; + /* --- 프로젝트 층 단추 다섯 --- */ const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`; const post = (path: string, body: object = {}): Promise => diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index c39364c36..207f3fe3c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,8 +1,9 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts - * 마스터 템플릿 좌측 패널 — 시스템 양식 목록(표/도면/구조물 도면/산출근거) · 새로 · 본떠 · 지우기. - * 서버 길은 계약 `6_계약.md`. 구조물 도면 · 산출근거 줄은 집계표 구조물 열(꼴=수)마다 하나씩 — - * 서버가 미리 만들어 둠(도번 SD · 표번 SC + 같은 6자리) · 줄 글은 번호가 맨 앞(「SD010101 돌기슭막이 …」). + * 마스터 템플릿 좌측 패널 — 시스템 양식 목록(표/도면/구조물 도면/면적산출/재료산출) · 새로 · 본떠 · 지우기. + * 서버 길은 계약 `6_계약.md`. 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 — + * 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) · + * 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). * ========================================================================== */ import { @@ -40,6 +41,13 @@ const KINDS: { kind: Kind; label: () => string }[] = [ { kind: "table", label: () => L("M02_KindTable") }, { kind: "drawing", label: () => L("M02_KindDrawing") }, ]; +/** 구조물 열마다 한 줄씩인 목록 셋 — 종류 · 번호 칸 · 제목 */ +type ColumnKind = "structure" | "area" | "material"; +const COLUMN_KINDS: { kind: ColumnKind; key: keyof StructureNumbers; label: () => string }[] = [ + { kind: "structure", key: "도번", label: () => L("M02_KindStructure") }, + { kind: "area", key: "면적번", label: () => L("M02_KindArea") }, + { kind: "material", key: "재료번", label: () => L("M02_KindMaterial") }, +]; /** 새 양식의 첫 문서 — 표는 `3_표양식.md` 4장 틀 · 도면은 빈 웹캐드 문서 */ function stubDoc(kind: Kind, name: string): unknown { return kind === "table" @@ -102,15 +110,15 @@ export function buildSide(opt: SideOptions): SideHandle { const bodies = new Map( KINDS.map((k) => [k.kind, el("div", { className: "m02-side__items ui-sidebar-list" })]), ); - const structureBody = el("div", { className: "m02-side__items ui-sidebar-list" }); - const basisBody = el("div", { className: "m02-side__items ui-sidebar-list" }); + const columnBodies = COLUMN_KINDS.map(() => + el("div", { className: "m02-side__items ui-sidebar-list" }), + ); const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", children: [ section(L("M02_KindTable"), bodies.get("table")!), section(L("M02_KindDrawing"), bodies.get("drawing")!), - section(L("M02_KindStructure"), structureBody), - section(L("M02_KindBasis"), basisBody), + ...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])), ], }); attachCollapsible(listHost); @@ -158,14 +166,12 @@ export function buildSide(opt: SideOptions): SideHandle { } } - /** 구조물 도면 · 산출근거 줄 — 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ - function paintRows(body: HTMLElement, kind: "structure" | "basis"): void { + /** 구조물 도면 · 면적산출 · 재료산출 줄 — 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ + function paintRows(body: HTMLElement, kind: ColumnKind, key: keyof StructureNumbers): void { body.replaceChildren( ...(columns.length ? columns.map((col) => { - const num = - (kind === "structure" ? numbers[col.id]?.도번 : numbers[col.id]?.표번) || - L("M02_StructureNone"); + const num = numbers[col.id]?.[key] || L("M02_StructureNone"); const b = el("button", { className: `m02-side__item ui-sidebar-row${isPicked(kind, col.id) ? " is-active" : ""}`, text: structureLabel(num, col), @@ -177,8 +183,8 @@ export function buildSide(opt: SideOptions): SideHandle { : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), ); } - const paintStructure = (): void => paintRows(structureBody, "structure"); - const paintBasis = (): void => paintRows(basisBody, "basis"); + const paintColumns = (): void => + COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key)); function paintButtons(): void { const editable = canEdit(layer, opt.isAdmin); @@ -195,13 +201,12 @@ export function buildSide(opt: SideOptions): SideHandle { paintButtons(); } - async function selectColumn(col: StructureColumn, kind: "structure" | "basis"): Promise { + async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise { if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return; picked = null; // 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」) opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) }); - paintStructure(); - paintBasis(); + paintColumns(); paintButtons(); } @@ -219,8 +224,7 @@ export function buildSide(opt: SideOptions): SideHandle { numbers = {}; showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error"); } - paintStructure(); - paintBasis(); + paintColumns(); } async function refresh(): Promise { @@ -320,8 +324,7 @@ export function buildSide(opt: SideOptions): SideHandle { select: (kind, name) => { picked = { kind, name }; paintList(); - paintStructure(); - paintBasis(); + paintColumns(); }, }; } diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index b1510126f..e24f76dad 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -49,7 +49,8 @@ def test_소스가_700줄을_넘지_않음(): ), # 산출근거 저장은 컨테이너가 안 함 — [산출근거 수정] 이 여는 메인 칸 전체 편집이 함 ("M02_MasterTemplete_Api_Fetch.ts", ["saveTemplate"], MAIN), - # 좌측 구조물 도면 · 산출근거 줄 번호는 한 번에 받음(`{열 id: {도번, 표번}}`) + # 좌측 구조물 도면 · 면적산출 · 재료산출 줄 번호는 한 번에 받음 + # (`{열 id: {도번, 면적번, 재료번}}`) ("M02_MasterTemplete_Api_Fetch.ts", ["fetchStructureNumbers"], SIDE), ("M02_MasterTemplete_Structure.ts", ["structureLabel", "isStructureColumn"], SIDE), ("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"], PANEL), @@ -68,12 +69,15 @@ def test_컨테이너가_쓰는_이름이_내보내진_것(source: str, names: l def test_sub2_가_부르는_이름과_모양(): """브레인 확정 이름 · 모양 — sub2 가 이대로 부름(바꾸면 브레인에게).""" body = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") - for name in ("copyStructure", "copyBasis"): + for name in ("copyStructure", "copyArea", "copyMaterial"): assert re.search( rf"export const {name} = async \(column: string, from: string\): Promise", body ), name - assert "fetchStructureNumbers = (): Promise>" in body - assert re.search(r"interface StructureNumbers \{\s*도번: string;\s*표번: string;\s*\}", body) + assert "fetchStructureNumbers = async (): Promise>" in body + assert re.search( + r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*재료번: string;", body + ) + assert re.search(r'export type Kind = [^;]*"area" \| "material"', body) def test_집계표만_컨테이너를_붙임(): diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index d0cf0197f..c6c9b2fbc 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -9,7 +9,8 @@ export const ui_locales_m2 = { M02_KindTable: ["표 양식", "Table templates"], M02_KindDrawing: ["도면 양식", "Drawing templates"], M02_KindStructure: ["구조물 도면", "Structure drawings"], - M02_KindBasis: ["산출근거", "Cost basis"], + M02_KindArea: ["면적산출", "Area takeoff"], + M02_KindMaterial: ["재료산출", "Material takeoff"], M02_NoTemplates: ["양식 없음", "None"], M02_New: ["새로", "New"], M02_Copy: ["본떠 만들기", "Copy as new"],