feat(M02): 좌측 구조물 도면 · 면적산출 · 재료산출 줄 · Api_Fetch 새 이름

- copyArea · copyMaterial · fetchStructureNumbers → {도번, 면적번, 재료번} · Kind area · material
- 옛 이름 copyBasis · 표번 · Kind basis 는 sub2 가 옮길 때까지 별칭(@deprecated)
- 좌측 목록 셋 · 줄 글 = 번호 맨 앞 + 이름 + 변수값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
This commit is contained in:
2026-09-28 12:08:56 +09:00
co-authored by Claude Opus 5.5
parent 89fc686962
commit 0e5a19eab8
4 changed files with 69 additions and 41 deletions
@@ -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<TemplateInfo[]> =>
(await listTemplates("system", null)).filter((t) => t.종류 === "structure");
/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 산출근거 통합문서) · 이미 있으면 StaleError */
/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 면적산출 · 재료산출 통합문서) · 이미 있으면 StaleError */
export const createStructure = (column: string): Promise<TemplateDoc> =>
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<Record<string, StructureNumbers>> =>
call("/structures/numbers");
/** `{열 id: {도번, 면적번, 재료번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
export const fetchStructureNumbers = async (): Promise<Record<string, StructureNumbers>> => {
const got = await call<Record<string, StructureNumbers>>("/structures/numbers");
for (const row of Object.values(got)) row.표번 = row.면적번;
return got;
};
/** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */
export const copyStructure = async (column: string, from: string): Promise<void> => {
await call(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
};
/** 다른 열(`from`) 산출근거 내용을 받는 열(`column`)로 가져옴 — 표번 · 열은 받는 쪽 그대로 */
export const copyBasis = async (column: string, from: string): Promise<void> => {
await call(`/basis/${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" });
};
/** 다른 열(`from`) 재료산출 내용(알약 연결 포함)을 받는 열(`column`)로 가져옴 — 재료번 · 열은 그대로 */
export const copyMaterial = async (column: string, from: string): Promise<void> => {
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<unknown> =>
@@ -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<Kind, HTMLElement>(
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<void> {
async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise<void> {
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<void> {
@@ -320,8 +324,7 @@ export function buildSide(opt: SideOptions): SideHandle {
select: (kind, name) => {
picked = { kind, name };
paintList();
paintStructure();
paintBasis();
paintColumns();
},
};
}
+8 -4
View File
@@ -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<void>", body
), name
assert "fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>>" in body
assert re.search(r"interface StructureNumbers \{\s*도번: string;\s*표번: string;\s*\}", body)
assert "fetchStructureNumbers = async (): Promise<Record<string, StructureNumbers>>" 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_집계표만_컨테이너를_붙임():
+2 -1
View File
@@ -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"],