diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index f2133c829..691426e65 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts - * 마스터 템플릿 좌측 패널 — 「구조물 조합」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 · - * 재료산출 · 자재 조합) · 「도면 양식」(새로 · 본떠 · 지우기). 서버 길은 계약 `6_계약.md`. + * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무 → 수평선 → 하위 구조물 도면 · 일위대가 · + * 면적산출 · 재료산출 · 자재 조합 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기). + * 서버 길은 계약 `6_계약.md`. * 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 — * 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) · * 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`. @@ -46,13 +47,22 @@ 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") }, +/** 구조물 열마다 한 줄씩인 하위 목록 — 종류 · 번호 칸(null = 열 줄 아직 없음) · 제목 · + * `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가 → 면적산출 */ +type ColumnKind = "structure" | "labor" | "area" | "material"; +const COLUMN_KINDS: { + kind: ColumnKind; + key: keyof StructureNumbers | null; + label: () => string; + all: boolean; +}[] = [ + { kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true }, + { kind: "labor", key: null, label: () => L("M02_KindLabor"), all: true }, + { kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true }, + { kind: "material", key: "재료번", label: () => L("M02_KindMaterial"), all: false }, ]; +/** 「전체 항목」 줄이 있는 하위 — 누르면 H 목록 화면(`onAll`) */ +export type AllKind = "structure" | "labor" | "area"; /** 새 도면 양식의 첫 문서 — 빈 웹캐드 문서 */ const STUB_DRAWING = { format: 6, @@ -75,6 +85,8 @@ export interface SideOptions { showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; + /** 하위 맨 위 「전체 항목」 줄 — 메인 칸에 그 하위 목록(화면 = `ItemList` · 페이지가 이음) */ + onAll?: (kind: AllKind) => void; } export interface SideHandle { @@ -102,6 +114,8 @@ export function buildSide(opt: SideOptions): SideHandle { let items: TemplateInfo[] = []; let columns: StructureColumn[] = []; let numbers: Record = {}; + /** 지금 연 「전체 항목」 — 양식 · 다른 보기 화면이 열리면 지움 */ + let allPicked: AllKind | null = null; let seq = 0; const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement => @@ -133,7 +147,7 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => (options.clear(), paint()), + onOpen: () => (options.clear(), (allPicked = null), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 @@ -145,7 +159,7 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (matset.clear(), paint()), + onOpen: () => (matset.clear(), (allPicked = null), paint()), }); /** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */ const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => { @@ -154,12 +168,14 @@ export function buildSide(opt: SideOptions): SideHandle { }); return sec; }; - // 구조물 집계 양식 — 층 단추 · 나무 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) · + const columnSections = COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])); + // 구조물 집계 양식 — 층 단추 · 나무 → 수평선 → 하위 컨테이너(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) · // 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6) const summary = section( L("M02_KindTable"), el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }), - ...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])), + el("hr", { className: "m02-side__rule" }), + ...columnSections, matsetSection, ); summary.classList.remove("is-collapsed"); @@ -219,9 +235,25 @@ export function buildSide(opt: SideOptions): SideHandle { } } - /** 구조물 도면 · 면적산출 · 재료산출 줄 — 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ - function paintRows(body: HTMLElement, kind: ColumnKind, key: keyof StructureNumbers): void { + /** 맨 위 「전체 항목」 줄(PLAN 39-2) — 누르면 그 하위 목록(`onAll`) */ + function allRow(kind: AllKind): HTMLElement { + const b = el("button", { + className: `m02-side__item m02-side__all ui-sidebar-row${allPicked === kind ? " is-active" : ""}`, + text: L("M02_ItemsAll"), + attrs: { type: "button", "data-all": kind }, + }); + b.addEventListener("click", () => void selectAll(kind)); + return b; + } + + /** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ + function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void { + const { key } = k; + const head = k.all ? [allRow(k.kind as AllKind)] : []; + if (key === null) return body.replaceChildren(...head); + const kind = k.kind as Kind; // 번호 칸이 있는 하위만 — 일위대가 열 줄은 번호(SU)가 선 뒤 body.replaceChildren( + ...head, ...(columns.length ? columns.map((col) => { const num = numbers[col.id]?.[key] || L("M02_StructureNone"); @@ -236,8 +268,7 @@ export function buildSide(opt: SideOptions): SideHandle { : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), ); } - const paintColumns = (): void => - COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key)); + const paintColumns = (): void => COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k)); /** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */ function paintButtons(): void { @@ -248,6 +279,7 @@ export function buildSide(opt: SideOptions): SideHandle { /** 고름 표시는 여기서만 — 목록 · 열 줄 · 자재 조합 줄을 함께 그려야 두 줄이 동시에 안 남음 * (양식이 열려 있으면 자재 조합 고름은 지움 · 자재 조합 상세는 열린 양식이 없음) */ function paint(): void { + if (opt.getOpen()) allPicked = null; paintList(); paintColumns(); paintButtons(); @@ -255,13 +287,22 @@ export function buildSide(opt: SideOptions): SideHandle { if (opt.getOpen()) options.clear(); } + async function selectAll(kind: AllKind): Promise { + if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return; + matset.clear(); + options.clear(); + allPicked = kind; + opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림 + paint(); + } + // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) async function select(info: TemplateInfo): Promise { if (!isOpen(info) && !(await opt.confirmLeave())) return; opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 }); } - async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise { + async function selectColumn(col: StructureColumn, kind: Kind): Promise { if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return; // 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」) opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) }); @@ -377,7 +418,13 @@ export function buildSide(opt: SideOptions): SideHandle { } const reveal = (target: "옵션" | "도면" | "일위대가"): void => { - if (target === "일위대가") return; // 구조물 조합은 처음부터 펼침 + // 일위대가 = 구조물 조합(처음부터 펼침) 안 일위대가 하위를 펼치고 「전체 항목」 + if (target === "일위대가") { + columnSections[COLUMN_KINDS.findIndex((k) => k.kind === "labor")].classList.remove( + "is-collapsed", + ); + return void selectAll("labor"); + } extras?.[target].classList.remove("is-collapsed"); if (target === "옵션") options.body.querySelector("button")?.click(); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index c4c51d1ca..aa14b148e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -15,6 +15,13 @@ margin-left: var(--spacing-8); } +/* 구조물 조합 안 층 단추 · 나무와 하위 컨테이너 사이 수평선(PLAN 39-2) */ +.m02-side__rule { + margin: var(--spacing-8) 0; + border: 0; + border-top: 1px solid var(--color-border); +} + .m02-side__row { display: flex; flex-wrap: wrap; diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 755dac40b..0776b560f 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -29,6 +29,22 @@ def test_나무_부품에_일위대가_전용_칸_없음(): assert gone not in panel, gone +def test_하위_차례_전체_항목_이름(): + side = _text("M02_MasterTemplete_UI_Side.ts") + kinds = side.split("const COLUMN_KINDS")[1].split("];")[0] + order = [kinds.index(f'kind: "{k}"') for k in ("structure", "labor", "area")] + assert order == sorted(order) # 구조물 도면 → 일위대가 → 면적산출 + assert 'label: () => L("M02_KindLabor"), all: true' in kinds + assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음 + assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side + assert 'return void selectAll("labor");' in side # 해시 open=일위대가 + locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8") + assert 'M02_KindLabor: ["일위대가"' in locale and 'M02_ItemsAll: ["전체 항목"' in locale + assert 'B01_Dashboard_CardUnitCombo: ["일위대가",' in locale + css = _text("M02_MasterTemplete_UI_Style.css") + assert ".m02-side__rule {" in css and "border-top: 1px solid var(--color-border);" in css + + def test_층_단추는_구조물_조합_한_벌(): page = _text("M02_MasterTemplete_UI_Page.ts") assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나 diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index 6df8d180b..5d1a98e5e 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -120,17 +120,11 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에 assert side.index("옵션: section(options.title") < side.index( '도면: section(L("M02_KindDrawing")' ) - # 차례 = 일위대가 조합(27-2b) → 구조물 조합 → 옵션 → 도면 양식 - order = [ - "...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())]" - " : []),", - " summary,", - " ...(extras ? [extras.옵션, extras.도면] : []),", - ] - assert "\n".join(order) in side - # 자재 조합을 열면 이 줄 · 일위대가 고름 지움 / 이 줄을 열면 자재 조합 · 일위대가 고름 지움 - assert "onOpen: () => (options.clear(), labor?.clear(), paint())" in side - assert "onOpen: () => (matset.clear(), labor?.clear(), paint())" in side + # 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 + assert "children: [summary, ...(extras ? [extras.옵션, extras.도면" in side + # 자재 조합을 열면 이 줄 · 「전체 항목」 고름 지움 / 이 줄을 열면 자재 조합 · 「전체 항목」 고름 지움 + assert "onOpen: () => (options.clear(), (allPicked = null), paint())" in side + assert "onOpen: () => (matset.clear(), (allPicked = null), paint())" in side paint = side.split("function paint(): void {")[1].split("\n }\n")[0] assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움 api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8") diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index bb9b3341f..b10a492d8 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -154,9 +154,16 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우 assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만 assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만 assert "i.이름 === STRUCTURE_TABLE" in side # 표는 구조물집계표 한 줄만 - # 구조물 도면 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘) + # 구조물 도면 · 일위대가 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘) · + # 층 단추 · 나무와 하위 사이 수평선(39-2) summary = side.split("const summary = section(")[1].split(");")[0] - assert "COLUMN_KINDS.map" in summary and "matsetSection" in summary + assert ( + summary.index("opt.tree") + < summary.index("m02-side__rule") + < summary.index("...columnSections") + ) + assert "matsetSection" in summary + assert "const columnSections = COLUMN_KINDS.map(" in side assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a) ROOT / "ui_template" / "ui_template_locale_m2.ts" ) @@ -184,7 +191,11 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐(): """카드 [도면 수정] 등 무엇으로 열든 편집 칸이 좌측 `paint` 를 부름 — 목록 · 열 줄을 함께 그림.""" side = _text(SIDE) assert "picked" not in side # 따로 든 고름 상태 없음 — 편집 칸의 지금 열린 것만 봄 - assert re.search(r"function paint\(\): void \{\s*paintList\(\);\s*paintColumns\(\);", side) + assert re.search( + r"function paint\(\): void \{\s*if \(opt\.getOpen\(\)\) allPicked = null;\s*" + r"paintList\(\);\s*paintColumns\(\);", + side, + ) # 양식이 열리면 「전체 항목」 고름도 지움(39-2) for fn in ("async function select(", "async function selectColumn("): body = side.split(fn)[1].split("\n }\n")[0] assert "paint" not in body, fn # 여기서 한쪽만 그리면 두 줄이 남음 @@ -192,8 +203,8 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐(): # 자재 조합 상세도 같은 한 곳 — 열면 양식 · 열 줄 고름 지움 · 양식을 열면 자재 조합 고름 지움 assert re.search(r"sel = null;\s*onOpened\?\.\(\);", _text(MAIN)) assert "if (opt.getOpen()) matset.clear();" in side.split("function paint(): void {")[1] - # 편집 칸이 열 때마다 좌측 `paint`(27-2b 뒤 = 일위대가 화면 해시 맞춤도 같은 자리) - assert re.search(r"\(\) => \{\s*side\.paint\(\);", _text(M02 / "M02_MasterTemplete_UI_Page.ts")) + # 편집 칸이 열 때마다 좌측 `paint`(일위대가 화면 해시 맞춤은 39-2 에서 지움) + assert "() => side.paint()," in _text(M02 / "M02_MasterTemplete_UI_Page.ts") def test_리본_접힘_모드에서_탭으로_편_것은_밖을_누르면_다시_접힘(): diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index eff4717b9..1058adaa6 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -4,7 +4,7 @@ * ========================================================================== */ export const ui_locales_m2 = { - B01_Dashboard_CardUnitCombo: ["일위대가 조합", "Unit cost combos"], + B01_Dashboard_CardUnitCombo: ["일위대가", "Unit costs"], B01_Dashboard_CardStructCombo: ["구조물 조합", "Structure combos"], B01_Dashboard_CardStructOption: ["구조물 옵션", "Structure options"], B01_Dashboard_CardDrawing: ["도면 양식", "Drawing templates"], @@ -172,4 +172,6 @@ export const ui_locales_m2 = { M02_TreeSaveGo: ["저장하고 계속", "Save and continue"], M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"], M02_TreeDone: ["나무를 고침", "Tree updated"], + M02_KindLabor: ["일위대가", "Unit costs"], + M02_ItemsAll: ["전체 항목", "All items"], } as const;